{
+ setPrevGradient(null);
+ setIsTransitioning(false);
+ }}
+ />
+ )}
+
{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 (
diff --git a/frontend/src/core/components/onboarding/slides/DesktopInstallSlide.tsx b/frontend/src/core/components/onboarding/slides/DesktopInstallSlide.tsx
index 3c06a8775..a759611ae 100644
--- a/frontend/src/core/components/onboarding/slides/DesktopInstallSlide.tsx
+++ b/frontend/src/core/components/onboarding/slides/DesktopInstallSlide.tsx
@@ -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,
},
};
}
diff --git a/frontend/src/core/components/onboarding/slides/PlanOverviewSlide.tsx b/frontend/src/core/components/onboarding/slides/PlanOverviewSlide.tsx
index e5e5218c4..d344f68f7 100644
--- a/frontend/src/core/components/onboarding/slides/PlanOverviewSlide.tsx
+++ b/frontend/src/core/components/onboarding/slides/PlanOverviewSlide.tsx
@@ -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 = (
+
+ As an admin, you can manage users, configure settings, and monitor server health. The first{' '}
+ {freeTierLimit} people on your server get to use Stirling free of charge.
+
+ );
+
return {
key: isAdmin ? 'admin-overview' : 'plan-overview',
title: isAdmin ? 'Admin Overview' : 'Plan Overview',
- body: isAdmin ? (
+ body: isAdmin ? adminBody : (
- 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.
-
- ) : (
-
- For the next 30 days, you'll enjoy unlimited Pro access 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.
),
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,
},
};
}
diff --git a/frontend/src/core/components/onboarding/slides/SecurityCheckSlide.tsx b/frontend/src/core/components/onboarding/slides/SecurityCheckSlide.tsx
new file mode 100644
index 000000000..515542bc7
--- /dev/null
+++ b/frontend/src/core/components/onboarding/slides/SecurityCheckSlide.tsx
@@ -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: (
+
+
+
+
+ Oops! Your Stirling server doesn't have an admin yet.
+
+
+
+
+ ),
+ background: {
+ gradientStops: ['#5B21B6', '#2563EB'],
+ circles: UNIFIED_CIRCLE_CONFIG,
+ },
+ };
+}
+
+
diff --git a/frontend/src/core/components/onboarding/slides/ServerLicenseSlide.tsx b/frontend/src/core/components/onboarding/slides/ServerLicenseSlide.tsx
new file mode 100644
index 000000000..c9a0fa5c6
--- /dev/null
+++ b/frontend/src/core/components/onboarding/slides/ServerLicenseSlide.tsx
@@ -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 ? (
+
+ Our licensing permits up to {freeTierLimit} users for free per server. You have{' '}
+ {overLimitUserCopy} Stirling users. To continue uninterrupted, upgrade to the Stirling Server
+ plan - unlimited seats, PDF text editing, and full admin control for $99/server/mo.
+
+ ) : (
+
+ Our licensing permits up to {freeTierLimit} 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.
+
+ );
+
+ return {
+ key,
+ title,
+ body,
+ background: {
+ gradientStops: isOverLimit ? ['#F472B6', '#8B5CF6'] : ['#F97316', '#F59E0B'],
+ circles: UNIFIED_CIRCLE_CONFIG,
+ },
+ };
+}
+
+
diff --git a/frontend/src/core/components/onboarding/slides/WelcomeSlide.tsx b/frontend/src/core/components/onboarding/slides/WelcomeSlide.tsx
index 69f0ac47d..538165460 100644
--- a/frontend/src/core/components/onboarding/slides/WelcomeSlide.tsx
+++ b/frontend/src/core/components/onboarding/slides/WelcomeSlide.tsx
@@ -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: (
-
+
Welcome to Stirling
-
+
V2
@@ -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,
},
};
}
diff --git a/frontend/src/core/components/onboarding/slides/types.ts b/frontend/src/core/components/onboarding/slides/types.ts
index f7cea06e4..2477b1818 100644
--- a/frontend/src/core/components/onboarding/slides/types.ts
+++ b/frontend/src/core/components/onboarding/slides/types.ts
@@ -25,3 +25,9 @@ export interface SlideConfig {
background: AnimatedSlideBackgroundProps;
downloadUrl?: string;
}
+
+export interface LicenseNotice {
+ totalUsers: number | null;
+ freeTierLimit: number;
+ isOverLimit: boolean;
+}
diff --git a/frontend/src/core/components/onboarding/slides/unifiedBackgroundConfig.ts b/frontend/src/core/components/onboarding/slides/unifiedBackgroundConfig.ts
new file mode 100644
index 000000000..393b34156
--- /dev/null
+++ b/frontend/src/core/components/onboarding/slides/unifiedBackgroundConfig.ts
@@ -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,
+ },
+];
+
diff --git a/frontend/src/core/components/onboarding/tourGlow.ts b/frontend/src/core/components/onboarding/tourGlow.ts
new file mode 100644
index 000000000..a043f7c01
--- /dev/null
+++ b/frontend/src/core/components/onboarding/tourGlow.ts
@@ -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'));
+};
+
diff --git a/frontend/src/core/components/onboarding/tourSteps.ts b/frontend/src/core/components/onboarding/tourSteps.ts
new file mode 100644
index 000000000..a3f7b6cfc
--- /dev/null
+++ b/frontend/src/core/components/onboarding/tourSteps.ts
@@ -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,
+}
+
diff --git a/frontend/src/core/components/onboarding/userStepsConfig.ts b/frontend/src/core/components/onboarding/userStepsConfig.ts
new file mode 100644
index 000000000..d6defced3
--- /dev/null
+++ b/frontend/src/core/components/onboarding/userStepsConfig.ts
@@ -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 {
+ 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 Tools 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 Crop 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 Crop 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 Files 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 Workbench - 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 Viewer 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 Page Editor 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 Active Files 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 Right Rail 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 Crop 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 Review 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 Pin 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 Help button whenever you like to see this tour again."),
+ position: 'right',
+ padding: 10,
+ },
+ };
+}
+
diff --git a/frontend/src/core/components/shared/Footer.tsx b/frontend/src/core/components/shared/Footer.tsx
index 52ee4165b..07b5213ba 100644
--- a/frontend/src/core/components/shared/Footer.tsx
+++ b/frontend/src/core/components/shared/Footer.tsx
@@ -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;
diff --git a/frontend/src/core/components/shared/QuickAccessBar.tsx b/frontend/src/core/components/shared/QuickAccessBar.tsx
index f8a1d1649..56fa82ac5 100644
--- a/frontend/src/core/components/shared/QuickAccessBar.tsx
+++ b/frontend/src/core/components/shared/QuickAccessBar.tsx
@@ -268,7 +268,12 @@ const QuickAccessBar = forwardRef((_, ref) => {
startTour('tools')}
+ onClick={() =>
+ startTour('tools', {
+ source: 'quick-access-help-button',
+ metadata: { entry: 'direct' },
+ })
+ }
>
{renderNavButton(buttonConfig, index)}
@@ -285,7 +290,12 @@ const QuickAccessBar = forwardRef((_, ref) => {
}
- onClick={() => startTour('tools')}
+ onClick={() =>
+ startTour('tools', {
+ source: 'quick-access-help-menu',
+ metadata: { entry: 'menu-tools' },
+ })
+ }
>
@@ -298,7 +308,12 @@ const QuickAccessBar = forwardRef
((_, ref) => {
}
- onClick={() => startTour('admin')}
+ onClick={() =>
+ startTour('admin', {
+ source: 'quick-access-help-menu',
+ metadata: { entry: 'menu-admin' },
+ })
+ }
>
diff --git a/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx b/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx
index de9994705..d87707ec6 100644
--- a/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx
+++ b/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx
@@ -107,7 +107,10 @@ const GeneralSection: React.FC = ({ hideTitle = false }) =>
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' },
diff --git a/frontend/src/core/components/tools/ToolPanelModePrompt.tsx b/frontend/src/core/components/tools/ToolPanelModePrompt.tsx
index 357217af0..6196af995 100644
--- a/frontend/src/core/components/tools/ToolPanelModePrompt.tsx
+++ b/frontend/src/core/components/tools/ToolPanelModePrompt.tsx
@@ -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 (
diff --git a/frontend/src/core/contexts/CookieConsentContext.tsx b/frontend/src/core/contexts/CookieConsentContext.tsx
new file mode 100644
index 000000000..6c996a1e6
--- /dev/null
+++ b/frontend/src/core/contexts/CookieConsentContext.tsx
@@ -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(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(() => ({
+ isReady: analyticsEnabled && isInitialized,
+ showCookieConsent,
+ showCookiePreferences,
+ }), [analyticsEnabled, isInitialized, showCookieConsent, showCookiePreferences]);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useCookieConsentContext = (): CookieConsentContextValue => {
+ const context = useContext(CookieConsentContext);
+ if (!context) {
+ throw new Error('useCookieConsentContext must be used within a CookieConsentProvider');
+ }
+ return context;
+};
+
diff --git a/frontend/src/core/contexts/OnboardingContext.tsx b/frontend/src/core/contexts/OnboardingContext.tsx
index e59feaaab..3599af5c7 100644
--- a/frontend/src/core/contexts/OnboardingContext.tsx
+++ b/frontend/src/core/contexts/OnboardingContext.tsx
@@ -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;
+}
+
+interface PendingTourRequest {
+ type: TourType;
+ source?: string;
+ metadata?: Record;
+ 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(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('tools');
- const [showWelcomeModal, setShowWelcomeModal] = useState(false);
const [startAfterToolModeSelection, setStartAfterToolModeSelection] = useState(false);
- const shouldShow = useShouldShowWelcomeModal();
+ const [pendingTourRequest, setPendingTourRequest] = useState(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 (
= ({ ch
closeTour,
completeTour,
resetTour,
- showWelcomeModal,
- setShowWelcomeModal,
startAfterToolModeSelection,
setStartAfterToolModeSelection,
+ pendingTourRequest,
+ clearPendingTourRequest,
}}
>
{children}
diff --git a/frontend/src/core/contexts/ToolWorkflowContext.tsx b/frontend/src/core/contexts/ToolWorkflowContext.tsx
index 7c657506e..950b18e1d 100644
--- a/frontend/src/core/contexts/ToolWorkflowContext.tsx
+++ b/frontend/src/core/contexts/ToolWorkflowContext.tsx
@@ -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]);
diff --git a/frontend/src/core/hooks/useCookieConsent.ts b/frontend/src/core/hooks/useCookieConsent.ts
index 5c437d717..9f7470a0f 100644
--- a/frontend/src/core/hooks/useCookieConsent.ts
+++ b/frontend/src/core/hooks/useCookieConsent.ts
@@ -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
};
};
diff --git a/frontend/src/core/services/preferencesService.ts b/frontend/src/core/services/preferencesService.ts
index bd9753008..502d6dd1b 100644
--- a/frontend/src/core/services/preferencesService.ts
+++ b/frontend/src/core/services/preferencesService.ts
@@ -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';