From d23318cfa695edb0ea06cb4db17c50bf65a41997 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Sat, 11 Jul 2026 12:41:48 +0100 Subject: [PATCH] Feature/onboarding updates for policies and portal (#6926) --- .../public/locales/en-US/translation.toml | 31 +- .../onboarding/SaasOnboardingModal.tsx | 171 ++------ .../components/onboarding/saasFlowResolver.ts | 37 +- .../onboarding/saasOnboardingFlowConfig.ts | 44 +- .../onboarding/useSaasOnboardingState.ts | 5 +- .../src/core/components/FileManager.tsx | 1 + .../InitialOnboardingModal.module.css | 187 ++++++++- .../InitialOnboardingModal/renderButtons.tsx | 32 +- .../core/components/onboarding/Onboarding.tsx | 201 +++------ .../OnboardingModalSlide.stories.tsx | 208 ++++++++++ .../onboarding/OnboardingModalSlide.tsx | 231 +++-------- .../onboarding/OnboardingSlideShell.tsx | 226 ++++++++++ .../components/onboarding/OnboardingTour.tsx | 32 +- .../onboarding/StaticOnboardingSlide.tsx | 67 +++ .../components/onboarding/adminStepsConfig.ts | 389 ++++++++++-------- .../onboarding/onboardingFlowConfig.ts | 80 ++-- .../onboarding/onboardingSlideTypes.ts | 65 +++ .../orchestrator/onboardingConfig.ts | 23 +- .../orchestrator/onboardingStorage.ts | 79 ++++ .../orchestrator/useOnboardingOrchestrator.ts | 29 +- .../onboarding/slides/DesktopInstallTitle.tsx | 49 ++- .../onboarding/slides/ProcessorIntroSlide.tsx | 30 ++ .../components/onboarding/tourRegistry.ts | 105 +++++ .../components/onboarding/userStepsConfig.ts | 20 +- .../core/components/shared/AppConfigModal.tsx | 2 +- .../core/components/shared/FileSidebar.tsx | 4 + .../core/components/shared/LandingPage.css | 2 +- .../core/components/shared/LandingPage.tsx | 7 - .../components/shared/LoadingFallback.tsx | 4 +- .../shared/SidebarChecklistSlot.tsx | 13 + frontend/editor/src/core/constants/events.ts | 5 +- frontend/editor/src/core/pages/HomePage.tsx | 17 +- .../core/tests/stubbed/main-dashboard.spec.ts | 37 -- .../src/core/utils/settingsNavigation.ts | 7 + .../DesktopSaasOnboardingBootstrap.tsx | 73 +--- .../src/portal/components/LoginScreen.tsx | 30 +- .../saas/components/OnboardingBootstrap.tsx | 33 +- .../onboarding/OnboardingChecklist.module.css | 168 ++++++++ .../onboarding/OnboardingChecklist.tsx | 293 +++++++++++++ .../components/onboarding/OnboardingTour.tsx | 117 +++++- .../saas/components/shared/AppConfigModal.tsx | 18 +- .../shared/SidebarChecklistSlot.tsx | 13 + .../shared/config/saasConfigNavSections.tsx | 42 +- frontend/editor/src/saas/utils/appSettings.ts | 13 +- 44 files changed, 2308 insertions(+), 932 deletions(-) create mode 100644 frontend/editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx create mode 100644 frontend/editor/src/core/components/onboarding/OnboardingSlideShell.tsx create mode 100644 frontend/editor/src/core/components/onboarding/StaticOnboardingSlide.tsx create mode 100644 frontend/editor/src/core/components/onboarding/onboardingSlideTypes.ts create mode 100644 frontend/editor/src/core/components/onboarding/slides/ProcessorIntroSlide.tsx create mode 100644 frontend/editor/src/core/components/onboarding/tourRegistry.ts create mode 100644 frontend/editor/src/core/components/shared/SidebarChecklistSlot.tsx create mode 100644 frontend/editor/src/saas/components/onboarding/OnboardingChecklist.module.css create mode 100644 frontend/editor/src/saas/components/onboarding/OnboardingChecklist.tsx create mode 100644 frontend/editor/src/saas/components/shared/SidebarChecklistSlot.tsx diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index d9ed967d62..d323a5a5f9 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -4647,7 +4647,6 @@ welcomeTitle = "You've been invited!" [landing] addFiles = "Add Files" -heroSubtitle = "Drop in or add an existing PDF to get started." mobileUpload = "Upload from Mobile" openFromComputer = "Open from computer" uploadFromComputer = "Upload from computer" @@ -4962,11 +4961,11 @@ text = "Decide how you want the text output formatted:" title = "Output" [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." +activeFiles = "The Active Files view shows all of the PDFs you have loaded into the tool." allTools = "This is the Tools panel, where you can browse and select from all available PDF tools." close = "Close" 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." -fileCheckbox = "Clicking one of the files selects it for processing. You can select multiple files for batch operations." +fileCheckbox = "Files on the workbench are selected for processing. You can select multiple files for batch operations using the left files sidebar." fileReplacement = "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools." filesButton = "The Files button on the Quick Access bar allows you to upload PDFs to use the tools on." fileSources = "You can upload new files or access recent files from here. For the tour, we'll just use a sample file." @@ -4976,6 +4975,7 @@ pinButton = "You can use the Pin button if you'd rather your fi 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. " runButton = "Once the tool has been configured, this button allows you to run the tool on all the selected PDFs." selectCropTool = "Let's select the Crop tool to demonstrate how to use one of the tools." +stepOf = "Step {{current}} of {{total}}" 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." workbench = "This is the Workbench - the main area where you view and edit your PDFs." wrapUp = "You're all set! You can replay this tour anytime - just open Settings and find it here in the Tours section under Help." @@ -4988,6 +4988,26 @@ showMeAround = "Show me around" skipForNow = "Skip for now" skipTheTour = "Skip the tour" +[onboarding.checklist] +dismiss = "Dismiss" +title = "Set up Stirling PDF" + +[onboarding.checklist.downloadDesktop] +description = "Run Stirling natively on your machine" +title = "Download Stirling for Desktop" + +[onboarding.checklist.inviteTeam] +description = "Collaborate with your team" +title = "Invite team members" + +[onboarding.checklist.shareAnalytics] +description = "Help improve Stirling" +title = "Share anonymous usage data" + +[onboarding.checklist.takeTour] +description = "See how Stirling works in a quick walkthrough" +title = "Take the tour" + [onboarding.desktopInstall] body = "Stirling works best as a desktop app. You can use it offline, access documents faster, and make edits locally on your computer." selectOs = "Select operating system" @@ -5005,6 +5025,11 @@ adminTitle = "Admin Overview" userBody = "Invite teammates, assign roles, and keep your documents organized in one secure workspace. Enable login mode whenever you're ready to grow beyond solo use." userTitle = "Plan Overview" +[onboarding.processorIntro] +body = "Stirling now runs Policies — automated rules that classify, secure, and process every document as it arrives. Set them up and monitor runs in the Processor." +cta = "Check out the Processor" +title = "Check out the Stirling Processor" + [onboarding.saas.freeEditor] freeLine = "The editor is now completely free." premium = "We've added loads of new features, including Policies and Agent Chat." diff --git a/frontend/editor/src/cloud/components/onboarding/SaasOnboardingModal.tsx b/frontend/editor/src/cloud/components/onboarding/SaasOnboardingModal.tsx index 5aa52034a4..48d2b3087f 100644 --- a/frontend/editor/src/cloud/components/onboarding/SaasOnboardingModal.tsx +++ b/frontend/editor/src/cloud/components/onboarding/SaasOnboardingModal.tsx @@ -1,15 +1,16 @@ -import React from "react"; -import { Modal, Stack } from "@mantine/core"; import BoltRoundedIcon from "@mui/icons-material/BoltRounded"; import GroupAddRoundedIcon from "@mui/icons-material/GroupAddRounded"; import { useTranslation } from "react-i18next"; -import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground"; -import OnboardingStepper from "@app/components/onboarding/OnboardingStepper"; -import { renderButtons } from "@app/components/onboarding/renderButtons"; -import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css"; +import OnboardingSlideShell, { + ShellHero, + type ShellButton, +} from "@app/components/onboarding/OnboardingSlideShell"; import { useSaasOnboardingState } from "@app/components/onboarding/useSaasOnboardingState"; -import { BASE_PATH } from "@app/constants/app"; -import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex"; +import { + type SlideId, + type ButtonAction, + type ButtonDefinition, +} from "@app/components/onboarding/saasOnboardingFlowConfig"; interface SaasOnboardingModalProps { opened: boolean; @@ -20,6 +21,7 @@ interface SaasOnboardingModalProps { * false (slide shown) so the web (saas) flow is unchanged. */ hideDesktopInstall?: boolean; + slideIds?: SlideId[]; } export default function SaasOnboardingModal(props: SaasOnboardingModalProps) { @@ -39,130 +41,45 @@ export default function SaasOnboardingModal(props: SaasOnboardingModalProps) { handleButtonAction, } = flow; - const renderHero = () => { - if (slideDefinition.hero.type === "dual-icon") { - return ( -
-
- Stirling icon -
-
- ); - } - - if (slideDefinition.hero.type === "logo") { - return ( - Stirling logo - ); - } - - return ( -
- {slideDefinition.hero.type === "bolt" && ( - - )} - {slideDefinition.hero.type === "team" && ( - - )} -
+ const heroType = slideDefinition.hero.type; + const hero = + heroType === "dual-icon" || heroType === "logo" ? ( + + ) : ( + + {heroType === "bolt" && } + {heroType === "team" && } + ); + + const resolveLabel = (button: ButtonDefinition) => { + const label = button.label ?? ""; + if (!label) return ""; + const fallback = label.split(".").pop() || label; + return t(label, fallback); }; + const buttons: ShellButton[] = slideDefinition.buttons.map((button) => ({ + key: button.key, + back: button.type === "icon", + label: resolveLabel(button), + primary: (button.variant ?? "secondary") === "primary", + action: button.action, + disabled: button.disabledWhen?.(flowState) ?? false, + })); + return ( - handleButtonAction(action as ButtonAction)} onClose={props.onClose} - closeOnClickOutside={false} - centered - size="lg" - radius="lg" - withCloseButton={false} - zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE} - styles={{ - body: { padding: 0 }, - content: { - overflow: "hidden", - border: "none", - background: "var(--bg-surface)", - maxHeight: "90vh", - display: "flex", - flexDirection: "column", - }, - }} - > - -
- -
- {renderHero()} -
-
- -
- -
- {currentSlide.title} -
- -
-
- {currentSlide.body} -
- -
- - - -
- {renderButtons({ - slideDefinition, - flowState, - onAction: handleButtonAction, - t, - })} -
-
-
-
-
+ /> ); } diff --git a/frontend/editor/src/cloud/components/onboarding/saasFlowResolver.ts b/frontend/editor/src/cloud/components/onboarding/saasFlowResolver.ts index b6fb199730..436a9b4bba 100644 --- a/frontend/editor/src/cloud/components/onboarding/saasFlowResolver.ts +++ b/frontend/editor/src/cloud/components/onboarding/saasFlowResolver.ts @@ -1,4 +1,8 @@ import { SlideId } from "@app/components/onboarding/saasOnboardingFlowConfig"; +import { + resolveFlowIds, + type FlowStep, +} from "@app/components/onboarding/onboardingSlideTypes"; export interface SaasFlowInputs { /** Free-tier wallet with one-time allowance remaining — show the usage meter. */ @@ -13,22 +17,23 @@ export interface SaasFlowInputs { hideDesktopInstall?: boolean; } +// The SaaS flow as data: free-editor and desktop-install bookend it; the usage +// meter and team slides slot in when their conditions hold. This is the same +// "flow = the steps that apply, in order" shape the core flow uses, resolved +// through the shared {@link resolveFlowIds} helper. +const SAAS_FLOW: FlowStep[] = [ + { id: "free-editor", when: () => true }, + { id: "usage", when: (input) => input.showUsageSlide }, + { id: "team", when: (input) => input.showTeamSlide }, + { id: "desktop-install", when: (input) => !input.hideDesktopInstall }, +]; + /** - * Resolves the SaaS onboarding slide sequence. The free-editor pitch and - * desktop install bookend the flow; the usage meter and team slides slot in - * when their conditions hold. When {@link SaasFlowInputs.hideDesktopInstall} is - * set, the closing desktop-install slide is dropped (used by the desktop app, - * which has no reason to pitch its own download). + * Resolves the SaaS onboarding slide sequence. When + * {@link SaasFlowInputs.hideDesktopInstall} is set, the closing desktop-install + * slide is dropped (used by the desktop app, which has no reason to pitch its + * own download). */ -export function resolveSaasFlow({ - showUsageSlide, - showTeamSlide, - hideDesktopInstall = false, -}: SaasFlowInputs): SlideId[] { - return [ - "free-editor", - ...(showUsageSlide ? (["usage"] as const) : []), - ...(showTeamSlide ? (["team"] as const) : []), - ...(hideDesktopInstall ? [] : (["desktop-install"] as const)), - ]; +export function resolveSaasFlow(inputs: SaasFlowInputs): SlideId[] { + return resolveFlowIds(SAAS_FLOW, inputs); } diff --git a/frontend/editor/src/cloud/components/onboarding/saasOnboardingFlowConfig.ts b/frontend/editor/src/cloud/components/onboarding/saasOnboardingFlowConfig.ts index 91af54611b..78dc40b829 100644 --- a/frontend/editor/src/cloud/components/onboarding/saasOnboardingFlowConfig.ts +++ b/frontend/editor/src/cloud/components/onboarding/saasOnboardingFlowConfig.ts @@ -2,7 +2,14 @@ import FreeEditorSlide from "@app/components/onboarding/slides/FreeEditorSlide"; import UsageSnapshotSlide from "@app/components/onboarding/slides/UsageSnapshotSlide"; import TeamSlide from "@app/components/onboarding/slides/TeamSlide"; import DesktopInstallSlide from "@app/components/onboarding/slides/DesktopInstallSlide"; -import { SlideConfig } from "@app/types/types"; +import type { + OSOption, + ButtonDefinition as ButtonDefinitionBase, + HeroDefinition as HeroDefinitionBase, + SlideDefinition as SlideDefinitionBase, +} from "@app/components/onboarding/onboardingSlideTypes"; + +export type { OSOption }; export type SlideId = "free-editor" | "usage" | "team" | "desktop-install"; @@ -12,12 +19,6 @@ export type ButtonAction = "next" | "prev" | "close" | "download-selected"; export type FlowState = Record; -export interface OSOption { - label: string; - url: string; - value: string; -} - export interface SlideFactoryParams { osLabel: string; osUrl: string; @@ -25,27 +26,17 @@ export interface SlideFactoryParams { onDownloadUrlChange?: (url: string) => void; } -export interface HeroDefinition { - type: HeroType; -} +export type HeroDefinition = HeroDefinitionBase; -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 type ButtonDefinition = ButtonDefinitionBase; -export interface SlideDefinition { - id: SlideId; - createSlide: (params: SlideFactoryParams) => SlideConfig; - hero: HeroDefinition; - buttons: ButtonDefinition[]; -} +export type SlideDefinition = SlideDefinitionBase< + SlideId, + ButtonAction, + FlowState, + HeroType, + SlideFactoryParams +>; const BACK_BUTTON: ButtonDefinition = { key: "back", @@ -95,7 +86,6 @@ export const SLIDE_DEFINITIONS: Record = { DesktopInstallSlide({ osLabel, osUrl, osOptions, onDownloadUrlChange }), hero: { type: "dual-icon" }, buttons: [ - { ...BACK_BUTTON, key: "desktop-back" }, { key: "desktop-skip", type: "button", diff --git a/frontend/editor/src/cloud/components/onboarding/useSaasOnboardingState.ts b/frontend/editor/src/cloud/components/onboarding/useSaasOnboardingState.ts index 0b76c38a8f..22f41ec12d 100644 --- a/frontend/editor/src/cloud/components/onboarding/useSaasOnboardingState.ts +++ b/frontend/editor/src/cloud/components/onboarding/useSaasOnboardingState.ts @@ -31,12 +31,14 @@ interface UseSaasOnboardingStateProps { * (slide shown) so the web (saas) flow is unchanged. */ hideDesktopInstall?: boolean; + slideIds?: SlideId[]; } export function useSaasOnboardingState({ opened, onClose, hideDesktopInstall = false, + slideIds, }: UseSaasOnboardingStateProps): UseSaasOnboardingStateResult | null { const { loading } = useAuth(); const { wallet } = useWallet(); @@ -89,8 +91,9 @@ export function useSaasOnboardingState({ const flowSlideIds = useMemo( () => + slideIds ?? resolveSaasFlow({ showUsageSlide, showTeamSlide, hideDesktopInstall }), - [showUsageSlide, showTeamSlide, hideDesktopInstall], + [slideIds, showUsageSlide, showTeamSlide, hideDesktopInstall], ); const totalSteps = flowSlideIds.length; const maxIndex = Math.max(totalSteps - 1, 0); diff --git a/frontend/editor/src/core/components/FileManager.tsx b/frontend/editor/src/core/components/FileManager.tsx index 16cded856c..d05d05ea2b 100644 --- a/frontend/editor/src/core/components/FileManager.tsx +++ b/frontend/editor/src/core/components/FileManager.tsx @@ -198,6 +198,7 @@ const FileManager: React.FC = ({ selectedTool }) => { }} >
btn.group === "left", + // Back/icon buttons anchor the left edge; all text actions (skip + primary) + // cluster together on the right, matching the onboarding card layout. + const backButtons = slideDefinition.buttons.filter( + (btn) => btn.type === "icon", ); - const rightButtons = slideDefinition.buttons.filter( - (btn) => btn.group === "right", + const actionButtons = slideDefinition.buttons.filter( + (btn) => btn.type !== "icon", ); const resolveButtonLabel = (button: ButtonDefinition) => { @@ -75,7 +77,7 @@ export function SlideButtons({ ); } - const variant = button.variant ?? "secondary"; + const isPrimary = (button.variant ?? "secondary") === "primary"; const label = resolveButtonLabel(button); return ( @@ -83,28 +85,24 @@ export function SlideButtons({ key={button.key} onClick={() => onAction(button.action)} disabled={disabled} - variant={variant === "primary" ? "primary" : "secondary"} - accent={ - button.accent ?? (variant === "primary" ? "default" : "neutral") - } + variant={isPrimary ? "primary" : "quiet"} + accent={button.accent ?? (isPrimary ? "default" : "neutral")} > {label} ); }; - if (leftButtons.length === 0) { - return {rightButtons.map(renderButton)}; - } + const actions = {actionButtons.map(renderButton)}; - if (rightButtons.length === 0) { - return {leftButtons.map(renderButton)}; + if (backButtons.length === 0) { + return {actions}; } return ( - - {leftButtons.map(renderButton)} - {rightButtons.map(renderButton)} + + {backButtons.map(renderButton)} + {actions} ); } diff --git a/frontend/editor/src/core/components/onboarding/Onboarding.tsx b/frontend/editor/src/core/components/onboarding/Onboarding.tsx index 86976784e9..90fde8f79f 100644 --- a/frontend/editor/src/core/components/onboarding/Onboarding.tsx +++ b/frontend/editor/src/core/components/onboarding/Onboarding.tsx @@ -11,6 +11,7 @@ import OnboardingTour, { type CloseArgs, } from "@app/components/onboarding/OnboardingTour"; import OnboardingModalSlide from "@app/components/onboarding/OnboardingModalSlide"; +import StaticOnboardingSlide from "@app/components/onboarding/StaticOnboardingSlide"; import { useServerLicenseRequest, useTourRequest, @@ -24,9 +25,7 @@ import { import ToolPanelModePrompt from "@app/components/tools/ToolPanelModePrompt"; import { useTourOrchestration } from "@app/contexts/TourOrchestrationContext"; import { useAdminTourOrchestration } from "@app/contexts/AdminTourOrchestrationContext"; -import { createUserStepsConfig } from "@app/components/onboarding/userStepsConfig"; -import { createAdminStepsConfig } from "@app/components/onboarding/adminStepsConfig"; -import { createWhatsNewStepsConfig } from "@app/components/onboarding/whatsNewStepsConfig"; +import { getTourSteps } from "@app/components/onboarding/tourRegistry"; import { removeAllGlows } from "@app/components/onboarding/tourGlow"; import { useFilesModalContext } from "@app/contexts/FilesModalContext"; import { useServerExperience } from "@app/hooks/useServerExperience"; @@ -180,6 +179,10 @@ export default function Onboarding() { setIsTourOpen(true); break; } + case "open-processor": + actions.complete(); + navigate("/portal"); + break; case "skip-to-license": actions.complete(); break; @@ -220,77 +223,25 @@ export default function Onboarding() { const tourOrch = useTourOrchestration(); const adminTourOrch = useAdminTourOrchestration(); - const userStepsConfig = useMemo( + const tourSteps = useMemo( () => - createUserStepsConfig({ + getTourSteps(runtimeState.tourType, { t, - actions: { - saveWorkbenchState: tourOrch.saveWorkbenchState, - closeFilesModal, - backToAllTools: tourOrch.backToAllTools, - selectCropTool: tourOrch.selectCropTool, - loadSampleFile: tourOrch.loadSampleFile, - switchToActiveFiles: tourOrch.switchToActiveFiles, - pinFile: tourOrch.pinFile, - revealFileCardHoverMenu: tourOrch.revealFileCardHoverMenu, - modifyCropSettings: tourOrch.modifyCropSettings, - executeTool: tourOrch.executeTool, - openFilesModal, - openSettingsHelpSection: () => - adminTourOrch.navigateToSection("help"), - }, + workbench: tourOrch, + admin: adminTourOrch, + openFilesModal, + closeFilesModal, }), - [t, tourOrch, adminTourOrch, closeFilesModal, openFilesModal], + [ + runtimeState.tourType, + t, + tourOrch, + adminTourOrch, + openFilesModal, + closeFilesModal, + ], ); - const whatsNewStepsConfig = useMemo( - () => - createWhatsNewStepsConfig({ - t, - actions: { - saveWorkbenchState: tourOrch.saveWorkbenchState, - closeFilesModal, - backToAllTools: tourOrch.backToAllTools, - openFilesModal, - loadSampleFile: tourOrch.loadSampleFile, - switchToViewer: tourOrch.switchToViewer, - switchToPageEditor: tourOrch.switchToPageEditor, - switchToActiveFiles: tourOrch.switchToActiveFiles, - }, - }), - [t, tourOrch, closeFilesModal, openFilesModal], - ); - - const adminStepsConfig = useMemo( - () => - createAdminStepsConfig({ - t, - actions: { - saveAdminState: adminTourOrch.saveAdminState, - openConfigModal: adminTourOrch.openConfigModal, - navigateToSection: adminTourOrch.navigateToSection, - scrollNavToSection: adminTourOrch.scrollNavToSection, - }, - }), - [t, adminTourOrch], - ); - - const tourSteps = useMemo(() => { - switch (runtimeState.tourType) { - case "admin": - return Object.values(adminStepsConfig); - case "whatsnew": - return Object.values(whatsNewStepsConfig); - default: - return Object.values(userStepsConfig); - } - }, [ - adminStepsConfig, - runtimeState.tourType, - userStepsConfig, - whatsNewStepsConfig, - ]); - useEffect(() => { if (externalTourRequested) { actions.updateRuntimeState({ tourType: requestedTourType }); @@ -427,26 +378,18 @@ export default function Onboarding() { return null; } - // Show analytics modal before onboarding if needed + // Interrupt slides shown outside the normal step flow. Precedence is + // preserved by evaluation order: analytics consent → first login → MFA → + // external license notice. if (showAnalyticsModal) { - const slideDefinition = SLIDE_DEFINITIONS["analytics-choice"]; - const slideContent = slideDefinition.createSlide({ - osLabel: "", - osUrl: "", - selectedRole: null, - onRoleSelect: () => {}, - analyticsError, - analyticsLoading, - }); - return ( - {}} // No skip allowed + allowDismiss={false} onAction={async (action) => { if (action === "enable-analytics") { await handleAnalyticsChoice(true); @@ -454,102 +397,72 @@ export default function Onboarding() { await handleAnalyticsChoice(false); } }} - allowDismiss={false} /> ); } if (firstLoginModalOpen) { - const baseSlideDefinition = SLIDE_DEFINITIONS["first-login"]; - const slideContent = baseSlideDefinition.createSlide({ - osLabel: "", - osUrl: "", - selectedRole: null, - onRoleSelect: () => {}, - firstLoginUsername: runtimeState.firstLoginUsername, - onPasswordChanged: handlePasswordChanged, - usingDefaultCredentials: runtimeState.usingDefaultCredentials, - }); - return ( - {}} - onAction={async (action) => { + allowDismiss={false} + onAction={(action) => { if (action === "complete-close") { handlePasswordChanged(); } }} - allowDismiss={false} /> ); } if (mfaModalOpen) { console.log("[Onboarding] Rendering MFA setup modal slide."); - const baseSlideDefinition = SLIDE_DEFINITIONS["mfa-setup"]; - const slideContent = baseSlideDefinition.createSlide({ - osLabel: "", - osUrl: "", - selectedRole: null, - onRoleSelect: () => {}, - onMfaSetupComplete: handleMfaSetupComplete, - }); - return ( - {}} - onAction={async (action) => { + allowDismiss={false} + onAction={(action) => { if (action === "complete-close") { handleMfaSetupComplete(); } }} - allowDismiss={false} /> ); } if (showLicenseSlide) { - const baseSlideDefinition = SLIDE_DEFINITIONS["server-license"]; - // Remove back button for external license notice - const slideDefinition = { - ...baseSlideDefinition, - buttons: baseSlideDefinition.buttons.filter( - (btn) => btn.key !== "license-back", - ), - }; const effectiveLicenseNotice = externalLicenseNotice || runtimeState.licenseNotice; - const slideContent = slideDefinition.createSlide({ - osLabel: "", - osUrl: "", - osOptions: [], - onDownloadUrlChange: () => {}, - selectedRole: null, - onRoleSelect: () => {}, - licenseNotice: effectiveLicenseNotice, - loginEnabled: serverExperience.loginEnabled, - }); - return ( - + buttons.filter((btn) => btn.key !== "license-back") + } runtimeState={{ ...runtimeState, licenseNotice: effectiveLicenseNotice, }} - modalSlideCount={1} - currentModalSlideIndex={0} + params={{ + osOptions: [], + onDownloadUrlChange: () => {}, + licenseNotice: effectiveLicenseNotice, + loginEnabled: serverExperience.loginEnabled, + }} onSkip={closeLicenseSlide} onAction={(action) => { if (action === "see-plans") { diff --git a/frontend/editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx b/frontend/editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx new file mode 100644 index 0000000000..f493051403 --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/OnboardingModalSlide.stories.tsx @@ -0,0 +1,208 @@ +import { useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +// The shared preview only loads the portal tokens; the onboarding modal reads +// the editor theme tokens (--bg-surface, --onboarding-title, …), so load them +// here or the modal surface renders transparent over the dark overlay. +import "@app/styles/theme.css"; +import OnboardingModalSlide from "@app/components/onboarding/OnboardingModalSlide"; +import { + SLIDE_DEFINITIONS, + type SlideId, + type SlideFactoryParams, +} from "@app/components/onboarding/onboardingFlowConfig"; +import { + DEFAULT_RUNTIME_STATE, + type OnboardingRuntimeState, +} from "@app/components/onboarding/orchestrator/onboardingConfig"; + +/** + * Every onboarding modal slide, rendered through the real + * {@link OnboardingModalSlide} + {@link SLIDE_DEFINITIONS} so design changes + * here reflect the production flow. Each story is one slide (or a meaningful + * variant of one), including its hero, stepper and action buttons. + */ + +// Sensible defaults for every slide factory; individual stories override the +// few fields their slide actually reads. +const BASE_PARAMS: SlideFactoryParams = { + osLabel: "macOS (Apple Silicon)", + osUrl: "#", + osOptions: [ + { label: "macOS (Apple Silicon)", url: "#", value: "mac-arm" }, + { label: "macOS (Intel)", url: "#", value: "mac-intel" }, + { label: "Windows", url: "#", value: "windows" }, + { label: "Linux", url: "#", value: "linux" }, + ], + onDownloadUrlChange: () => {}, + selectedRole: null, + onRoleSelect: () => {}, + licenseNotice: { + totalUsers: null, + freeTierLimit: 5, + isOverLimit: false, + requiresLicense: false, + }, + loginEnabled: true, + firstLoginUsername: "admin", + onPasswordChanged: () => {}, + usingDefaultCredentials: false, + analyticsError: null, + analyticsLoading: false, + onMfaSetupComplete: () => {}, +}; + +interface SlideStageProps { + slideId: SlideId; + params?: Partial; + runtime?: Partial; + allowDismiss?: boolean; + /** + * Total steps in the flow. Defaults to 1 → a single standalone card with no + * progress bar or step pill (how the SaaS checklist items render). Set > 1 + * only to demonstrate the stepped-flow treatment. + */ + stepCount?: number; + /** 0-based active step, used only when stepCount > 1. */ + stepIndex?: number; +} + +function SlideStage({ + slideId, + params, + runtime, + allowDismiss = true, + stepCount = 1, + stepIndex = 0, +}: SlideStageProps) { + const merged: SlideFactoryParams = { ...BASE_PARAMS, ...params }; + // Live role selection so the SecurityCheck dropdown + its gated "Next" button + // behave the same way they do in the real flow. + const [selectedRole, setSelectedRole] = useState(merged.selectedRole); + + const definition = SLIDE_DEFINITIONS[slideId]; + const slideContent = definition.createSlide({ + ...merged, + selectedRole, + onRoleSelect: setSelectedRole, + }); + + const runtimeState: OnboardingRuntimeState = { + ...DEFAULT_RUNTIME_STATE, + ...runtime, + selectedRole, + licenseNotice: merged.licenseNotice ?? DEFAULT_RUNTIME_STATE.licenseNotice, + }; + + return ( + {}} + onAction={() => {}} + allowDismiss={allowDismiss} + /> + ); +} + +const meta: Meta = { + title: "Onboarding/Modal Slides", + component: SlideStage, + parameters: { layout: "fullscreen" }, +}; +export default meta; + +type Story = StoryObj; + +/** "Welcome to Stirling" — the V2 intro slide (rocket hero). */ +export const Welcome: Story = { args: { slideId: "welcome" } }; + +/** The only stepped example: a multi-step flow shows the step pill + progress + * bar. Every other story is a standalone single card (no steps). */ +export const SteppedFlowExample: Story = { + args: { slideId: "admin-overview", stepCount: 9, stepIndex: 4 }, +}; + +/** Force a password change on first login (user types current + new). */ +export const FirstLogin: Story = { args: { slideId: "first-login" } }; + +/** First login when the account is still on the default `stirling` password — + * the current-password field is hidden. */ +export const FirstLoginDefaultCredentials: Story = { + args: { + slideId: "first-login", + params: { usingDefaultCredentials: true }, + }, +}; + +/** Desktop app download prompt with an OS picker (dual-icon hero). */ +export const DesktopInstall: Story = { args: { slideId: "desktop-install" } }; + +/** Role confirmation — the "Next" button stays disabled until a role is picked. */ +export const SecurityCheck: Story = { args: { slideId: "security-check" } }; + +/** Admin overview with login mode already enabled (diamond hero). */ +export const AdminOverviewLoginEnabled: Story = { + args: { + slideId: "admin-overview", + params: { loginEnabled: true }, + }, +}; + +/** Admin overview before login mode is enabled — different body copy. */ +export const AdminOverviewLoginDisabled: Story = { + args: { + slideId: "admin-overview", + params: { loginEnabled: false }, + }, +}; + +/** Server license, within the free tier. */ +export const ServerLicense: Story = { + args: { + slideId: "server-license", + params: { + licenseNotice: { + totalUsers: 3, + freeTierLimit: 5, + isOverLimit: false, + requiresLicense: false, + }, + }, + }, +}; + +/** Server license, over the free-tier seat limit — "Upgrade now" CTA. */ +export const ServerLicenseOverLimit: Story = { + args: { + slideId: "server-license", + params: { + licenseNotice: { + totalUsers: 12, + freeTierLimit: 5, + isOverLimit: true, + requiresLicense: true, + }, + }, + }, +}; + +/** Quick tour offer before dropping the user into the tools. */ +export const TourOverview: Story = { args: { slideId: "tour-overview" } }; + +/** Opt-in analytics choice (analytics hero). */ +export const AnalyticsChoice: Story = { args: { slideId: "analytics-choice" } }; + +/** Analytics choice showing an error banner (e.g. save failed). */ +export const AnalyticsChoiceError: Story = { + args: { + slideId: "analytics-choice", + params: { analyticsError: "Couldn't save your analytics preference." }, + }, +}; + +/** Two-factor setup (fetches a QR on mount; shows its error state without a + * backend in Storybook). */ +export const MfaSetup: Story = { args: { slideId: "mfa-setup" } }; diff --git a/frontend/editor/src/core/components/onboarding/OnboardingModalSlide.tsx b/frontend/editor/src/core/components/onboarding/OnboardingModalSlide.tsx index b8135739bd..7c54713665 100644 --- a/frontend/editor/src/core/components/onboarding/OnboardingModalSlide.tsx +++ b/frontend/editor/src/core/components/onboarding/OnboardingModalSlide.tsx @@ -1,29 +1,26 @@ /** - * OnboardingModalSlide Component + * OnboardingModalSlide * - * Renders a single modal slide in the onboarding flow. - * Handles the hero image, content, stepper, and button actions. + * Editor-flow adapter over the shared {@link OnboardingSlideShell}: maps a + * SLIDE_DEFINITIONS entry (hero + buttons) and its resolved content onto the + * shell so the editor onboarding renders the same card as every other flow. */ -import React from "react"; -import { Modal, Stack } from "@mantine/core"; import { useTranslation } from "react-i18next"; -import { ActionIcon } from "@app/ui/ActionIcon"; import DiamondOutlinedIcon from "@mui/icons-material/DiamondOutlined"; import type { SlideDefinition, ButtonAction, + ButtonDefinition, } from "@app/components/onboarding/onboardingFlowConfig"; import type { OnboardingRuntimeState } from "@app/components/onboarding/orchestrator/onboardingConfig"; import type { SlideConfig } from "@app/types/types"; -import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground"; -import OnboardingStepper from "@app/components/onboarding/OnboardingStepper"; -import { SlideButtons } from "@app/components/onboarding/InitialOnboardingModal/renderButtons"; +import OnboardingSlideShell, { + ShellHero, + type ShellButton, +} from "@app/components/onboarding/OnboardingSlideShell"; import LocalIcon from "@app/components/shared/LocalIcon"; -import { BASE_PATH } from "@app/constants/app"; -import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex"; -import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css"; interface OnboardingModalSlideProps { slideDefinition: SlideDefinition; @@ -36,6 +33,13 @@ interface OnboardingModalSlideProps { allowDismiss?: boolean; } +const HERO_ICON: Record = { + rocket: "rocket-launch", + shield: "verified-user-outline", + lock: "lock-outline", + analytics: "analytics", +}; + export default function OnboardingModalSlide({ slideDefinition, slideContent, @@ -47,165 +51,60 @@ export default function OnboardingModalSlide({ allowDismiss = true, }: OnboardingModalSlideProps) { const { t } = useTranslation(); - const renderHero = () => { - if (slideDefinition.hero.type === "dual-icon") { - return ( -
-
- Stirling icon -
-
- ); - } + const { licenseNotice } = runtimeState; + const flowState = { selectedRole: runtimeState.selectedRole }; - return ( -
- {slideDefinition.hero.type === "rocket" && ( - - )} - {slideDefinition.hero.type === "shield" && ( - - )} - {slideDefinition.hero.type === "lock" && ( - - )} - {slideDefinition.hero.type === "analytics" && ( - - )} - {slideDefinition.hero.type === "diamond" && ( - - )} - {slideDefinition.hero.type === "logo" && ( - Stirling logo - )} -
+ const heroType = slideDefinition.hero.type; + const hero = + heroType === "dual-icon" || heroType === "logo" ? ( + + ) : ( + + {heroType === "diamond" ? ( + + ) : HERO_ICON[heroType] ? ( + + ) : null} + ); + + const resolveLabel = (button: ButtonDefinition) => { + if ( + button.type === "button" && + slideDefinition.id === "server-license" && + button.action === "see-plans" && + licenseNotice.isOverLimit + ) { + return t("onboarding.serverLicense.upgrade", "Upgrade now →"); + } + const label = button.label ?? ""; + if (!label) return ""; + const fallback = label.split(".").pop() || label; + return t(label, fallback); }; + const buttons: ShellButton[] = slideDefinition.buttons.map((button) => ({ + key: button.key, + back: button.type === "icon", + label: resolveLabel(button), + primary: (button.variant ?? "secondary") === "primary", + accent: button.accent, + action: button.action, + disabled: button.disabledWhen?.(flowState) ?? false, + })); + return ( - onAction(action as ButtonAction)} onClose={onSkip} - closeOnClickOutside={false} - closeOnEscape={allowDismiss} - centered - size="lg" - radius="lg" - withCloseButton={false} - zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE} - styles={{ - body: { padding: 0, maxHeight: "90vh", overflow: "hidden" }, - content: { - overflow: "hidden", - border: "none", - background: "var(--bg-surface)", - maxHeight: "90vh", - }, - }} - > - -
- - {allowDismiss && ( - - - - )} -
- {renderHero()} -
-
- -
- -
- {slideContent.title} -
- -
-
- {slideContent.body} -
- -
- - {modalSlideCount > 1 && ( - - )} - -
- -
-
-
-
-
+ allowDismiss={allowDismiss} + /> ); } diff --git a/frontend/editor/src/core/components/onboarding/OnboardingSlideShell.tsx b/frontend/editor/src/core/components/onboarding/OnboardingSlideShell.tsx new file mode 100644 index 0000000000..3a8a2a57e4 --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/OnboardingSlideShell.tsx @@ -0,0 +1,226 @@ +import type { ReactNode } from "react"; +import { Modal } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { ActionIcon } from "@app/ui/ActionIcon"; +import { Button, type ButtonAccent } from "@app/ui/Button"; +import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; +import LocalIcon from "@app/components/shared/LocalIcon"; +import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex"; +import stirlingMark from "@app/assets/brand/modern-logo/logo512.png"; +import styles from "@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css"; + +/** A footer button. `action` is an opaque string handled by the caller. */ +export interface ShellButton { + key: string; + /** Chevron-left icon button (a back control) instead of a labelled button. */ + back?: boolean; + label?: string; + /** Filled primary (blue) vs. quiet text button. */ + primary?: boolean; + /** Accent override for a primary button (e.g. "premium"). */ + accent?: ButtonAccent; + action: string; + disabled?: boolean; +} + +export interface OnboardingSlideShellProps { + opened?: boolean; + /** Hero art node — use {@link ShellHero} to render the app mark or a glyph. */ + hero: ReactNode; + slideKey: string; + title: ReactNode; + body: ReactNode; + stepIndex: number; + stepCount: number; + buttons: ShellButton[]; + onAction: (action: string) => void; + onClose: () => void; + allowDismiss?: boolean; +} + +/** + * Hero art for the inset panel. `appIcon` renders the Stirling app mark + * directly; otherwise the children glyph sits inside a soft white tile. + */ +export function ShellHero({ + appIcon = false, + children, +}: { + appIcon?: boolean; + children?: ReactNode; +}) { + if (appIcon) { + return ( + Stirling + ); + } + return
{children}
; +} + +/** + * Shared onboarding slide chrome: branded header + step progress, an inset + * hero panel, left-aligned title/body, and a right-aligned action footer. + * Generic over button actions so every flow (editor, SaaS, portal) renders + * the same card. + */ +export default function OnboardingSlideShell({ + opened = true, + hero, + slideKey, + title, + body, + stepIndex, + stepCount, + buttons, + onAction, + onClose, + allowDismiss = true, +}: OnboardingSlideShellProps) { + const { t } = useTranslation(); + const showProgress = stepCount > 1; + + // Back/icon buttons anchor the left; text actions cluster on the right. + // A back control can't do anything on the first slide, so hide it there. + const backButtons = stepIndex === 0 ? [] : buttons.filter((b) => b.back); + const actionButtons = buttons.filter((b) => !b.back); + + const renderButton = (button: ShellButton) => ( + + ); + + const actions = ( +
{actionButtons.map(renderButton)}
+ ); + + return ( + +
+
+
+ + Stirling +
+
+ {showProgress && ( + + {t("onboarding.stepOf", "Step {{current}} of {{total}}", { + current: stepIndex + 1, + total: stepCount, + })} + + )} + {allowDismiss && ( + + + + )} +
+
+ + {showProgress && ( +
+ {Array.from({ length: stepCount }, (_, index) => ( + + ))} +
+ )} + +
+ +
+
+
+ {hero} +
+
+ +
+ {title} +
+ +
+ {body} + +
+ +
+ {backButtons.length === 0 ? ( +
{actions}
+ ) : ( +
+
+ {backButtons.map((button) => ( + onAction(button.action)} + variant="tertiary" + accent="neutral" + disabled={button.disabled} + aria-label={t("onboarding.buttons.back", "Back")} + > + + + ))} +
+ {actions} +
+ )} +
+
+
+ + ); +} diff --git a/frontend/editor/src/core/components/onboarding/OnboardingTour.tsx b/frontend/editor/src/core/components/onboarding/OnboardingTour.tsx index c38d9f952b..f9c66385d6 100644 --- a/frontend/editor/src/core/components/onboarding/OnboardingTour.tsx +++ b/frontend/editor/src/core/components/onboarding/OnboardingTour.tsx @@ -6,7 +6,7 @@ * when the tour is open but onboarding is inactive. */ -import React from "react"; +import React, { type ReactNode } from "react"; import { TourProvider, useTour, type StepType } from "@reactour/tour"; import { ActionIcon } from "@app/ui/ActionIcon"; import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; @@ -15,6 +15,19 @@ import CheckIcon from "@mui/icons-material/Check"; import type { TFunction } from "i18next"; import i18n from "@app/i18n"; +/** + * Renders tour copy that uses for emphasis as real React elements, + * so there is no raw-HTML injection surface (avoids dangerouslySetInnerHTML). + * Any other markup is shown as plain text. + */ +export function renderTourContent(content: string): ReactNode { + return content.split(/(.*?<\/strong>)/g).map((part, index) => { + const match = part.match(/^(.*?)<\/strong>$/); + if (match) return {match[1]}; + return {part}; + }); +} + /** * TourContent - Controls the tour visibility * Syncs the forceOpen prop with the reactour tour state. @@ -49,12 +62,14 @@ interface CloseArgs { interface OnboardingTourProps { tourSteps: StepType[]; - tourType: "admin" | "tools" | "whatsnew"; + // Open tour id (see tourRegistry.ts); "admin" gets the dark mask. + tourType: string; isRTL: boolean; t: TFunction; isOpen: boolean; onAdvance: (args: AdvanceArgs) => void; onClose: (args: CloseArgs) => void; + dimBackground?: boolean; } export default function OnboardingTour({ @@ -65,6 +80,7 @@ export default function OnboardingTour({ isOpen, onAdvance, onClose, + dimBackground = true, }: OnboardingTourProps) { if (!isOpen) return null; @@ -99,6 +115,11 @@ export default function OnboardingTour({ boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)", maxWidth: "400px", }), + maskWrapper: (base) => ({ + ...base, + // 0 = no dim: the page stays fully visible behind the popover. + opacity: dimBackground ? 0.7 : 0, + }), maskArea: (base) => ({ ...base, rx: 8, @@ -162,10 +183,9 @@ export default function OnboardingTour({ ), Content: ({ content }: { content: string }) => ( -
+
+ {renderTourContent(content)} +
), }} > diff --git a/frontend/editor/src/core/components/onboarding/StaticOnboardingSlide.tsx b/frontend/editor/src/core/components/onboarding/StaticOnboardingSlide.tsx new file mode 100644 index 0000000000..2ba340f47e --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/StaticOnboardingSlide.tsx @@ -0,0 +1,67 @@ +import OnboardingModalSlide from "@app/components/onboarding/OnboardingModalSlide"; +import { + SLIDE_DEFINITIONS, + type ButtonAction, + type ButtonDefinition, + type SlideFactoryParams, + type SlideId, +} from "@app/components/onboarding/onboardingFlowConfig"; +import type { OnboardingRuntimeState } from "@app/components/onboarding/orchestrator/onboardingConfig"; + +interface StaticOnboardingSlideProps { + slideId: SlideId; + runtimeState: OnboardingRuntimeState; + onSkip: () => void; + onAction: (action: ButtonAction) => void; + allowDismiss?: boolean; + /** Slide-specific createSlide params layered over the empty defaults. */ + params?: Partial; + /** Optional transform of the definition's buttons (e.g. drop a back button). */ + transformButtons?: (buttons: ButtonDefinition[]) => ButtonDefinition[]; +} + +/** + * Renders a single onboarding slide outside the normal step flow — the + * "interrupt" modals (analytics consent, first-login, MFA, external license + * notice). Each of these was previously an inline early-return in Onboarding + * that duplicated the same createSlide + OnboardingModalSlide boilerplate. + * + * Rendered as its own component (keyed by slideId at the call site) so the + * slide's internal hooks live in an isolated, stable scope rather than the + * parent's, which avoids hook-order churn when the active interrupt changes. + */ +export default function StaticOnboardingSlide({ + slideId, + runtimeState, + onSkip, + onAction, + allowDismiss, + params, + transformButtons, +}: StaticOnboardingSlideProps) { + const base = SLIDE_DEFINITIONS[slideId]; + const definition = transformButtons + ? { ...base, buttons: transformButtons(base.buttons) } + : base; + + const slideContent = definition.createSlide({ + osLabel: "", + osUrl: "", + selectedRole: null, + onRoleSelect: () => {}, + ...params, + }); + + return ( + + ); +} diff --git a/frontend/editor/src/core/components/onboarding/adminStepsConfig.ts b/frontend/editor/src/core/components/onboarding/adminStepsConfig.ts index 1d8083201c..cff95cb63c 100644 --- a/frontend/editor/src/core/components/onboarding/adminStepsConfig.ts +++ b/frontend/editor/src/core/components/onboarding/adminStepsConfig.ts @@ -33,6 +33,165 @@ interface CreateAdminStepsConfigArgs { actions: AdminStepActions; } +// Delay before applying glow so the target section has mounted after navigation. +const GLOW_DELAY_MS = 100; + +/** + * Declarative spec for an admin tour step. Most steps do the same thing — + * clear existing glows, navigate to a settings section, then glow a set of nav + * items — so that behaviour is expressed as data (`section` + `glow`) and the + * runner below turns it into the reactour enter/after hooks. This replaces the + * removeGlow→navigate→setTimeout→addGlow block that was previously copy-pasted + * into every step. + */ +interface AdminStepSpec { + step: AdminTourStep; + selector: string; + contentKey: string; + contentDefault: string; + position: StepType["position"]; + padding?: number; + highlightedSelectors?: string[]; + /** Navigate to this settings section on enter (clears glows first). */ + section?: string; + /** Selectors to glow shortly after navigating to `section`. */ + glow?: string[]; + /** Clear glows on enter without navigating (settings-overview step). */ + clearGlowOnEnter?: boolean; + /** Save admin/workbench state on enter (first step). */ + saveStateOnEnter?: boolean; + /** Open the config modal after this step (config-button step). */ + openConfigAfter?: boolean; + /** Scroll the settings nav to this section after this step. */ + scrollToAfter?: string; + /** Wait for the step's own selector to be present + highlightable on enter. */ + waitForSelectorOnEnter?: boolean; +} + +const NAV = { + people: [ + '[data-tour="admin-people-nav"]', + '[data-tour="admin-teams-nav"]', + '[data-tour="settings-content-area"]', + ], + adminGeneral: [ + '[data-tour="admin-adminGeneral-nav"]', + '[data-tour="admin-adminFeatures-nav"]', + '[data-tour="admin-adminEndpoints-nav"]', + '[data-tour="settings-content-area"]', + ], + adminDatabase: [ + '[data-tour="admin-adminDatabase-nav"]', + '[data-tour="settings-content-area"]', + ], + adminConnections: [ + '[data-tour="admin-adminConnections-nav"]', + '[data-tour="settings-content-area"]', + ], + adminTools: [ + '[data-tour="admin-adminAudit-nav"]', + '[data-tour="admin-adminUsage-nav"]', + '[data-tour="settings-content-area"]', + ], +} as const; + +const ADMIN_STEP_SPECS: AdminStepSpec[] = [ + { + step: AdminTourStep.WELCOME, + selector: '[data-tour="config-button"]', + contentKey: "adminOnboarding.welcome", + contentDefault: + "Welcome to the Admin Tour! Let's explore the powerful enterprise features and settings available to system administrators.", + position: "right", + saveStateOnEnter: true, + }, + { + step: AdminTourStep.CONFIG_BUTTON, + selector: '[data-tour="config-button"]', + contentKey: "adminOnboarding.configButton", + contentDefault: + "Open Settings to access all system configuration and administrative controls.", + position: "right", + openConfigAfter: true, + }, + { + step: AdminTourStep.SETTINGS_OVERVIEW, + selector: ".modal-nav", + contentKey: "adminOnboarding.settingsOverview", + contentDefault: + "This is the Settings Panel. Admin settings are organised by category for easy navigation.", + position: "right", + padding: 0, + clearGlowOnEnter: true, + }, + { + step: AdminTourStep.TEAMS_AND_USERS, + selector: '[data-tour="admin-people-nav"]', + contentKey: "adminOnboarding.teamsAndUsers", + contentDefault: + "Manage Teams and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself.", + position: "right", + section: "people", + glow: [...NAV.people], + highlightedSelectors: [...NAV.people], + }, + { + step: AdminTourStep.SYSTEM_CUSTOMIZATION, + selector: '[data-tour="admin-adminGeneral-nav"]', + contentKey: "adminOnboarding.systemCustomization", + contentDefault: + "We have extensive ways to customise the UI: System Settings let you change the app name and languages, Features allows server certificate management, and Endpoints lets you enable or disable specific tools for your users.", + position: "right", + section: "adminGeneral", + glow: [...NAV.adminGeneral], + highlightedSelectors: [...NAV.adminGeneral], + }, + { + step: AdminTourStep.DATABASE_SECTION, + selector: '[data-tour="admin-adminDatabase-nav"]', + contentKey: "adminOnboarding.databaseSection", + contentDefault: + "For advanced production environments, we have settings to allow external database hookups so you can integrate with your existing infrastructure.", + position: "right", + section: "adminDatabase", + glow: [...NAV.adminDatabase], + highlightedSelectors: [...NAV.adminDatabase], + }, + { + step: AdminTourStep.CONNECTIONS_SECTION, + selector: '[data-tour="admin-adminConnections-nav"]', + contentKey: "adminOnboarding.connectionsSection", + contentDefault: + "The Connections section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications.", + position: "right", + section: "adminConnections", + glow: [...NAV.adminConnections], + highlightedSelectors: [...NAV.adminConnections], + scrollToAfter: "adminAudit", + }, + { + step: AdminTourStep.ADMIN_TOOLS, + selector: '[data-tour="admin-adminAudit-nav"]', + contentKey: "adminOnboarding.adminTools", + contentDefault: + "Finally, we have advanced administration tools like Auditing to track system activity and Usage Analytics to monitor how your users interact with the platform.", + position: "right", + section: "adminAudit", + glow: [...NAV.adminTools], + highlightedSelectors: [...NAV.adminTools], + }, + { + step: AdminTourStep.WRAP_UP, + selector: '[data-tour="admin-help-nav"]', + contentKey: "adminOnboarding.wrapUp", + contentDefault: + "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. You can replay it anytime — just open Settings and find it here in the Tours section under Help.", + position: "right", + section: "help", + waitForSelectorOnEnter: true, + }, +]; + export function createAdminStepsConfig({ t, actions, @@ -44,183 +203,57 @@ export function createAdminStepsConfig({ scrollNavToSection, } = actions; - return { - [AdminTourStep.WELCOME]: { - selector: '[data-tour="config-button"]', - content: t( - "adminOnboarding.welcome", - "Welcome to the Admin Tour! 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", - "Open Settings to access all system configuration and administrative controls.", - ), - position: "right", - padding: 10, - actionAfter: () => { - openConfigModal(); - }, - }, - [AdminTourStep.SETTINGS_OVERVIEW]: { - selector: ".modal-nav", - content: t( - "adminOnboarding.settingsOverview", - "This is the Settings Panel. 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 Teams 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: System Settings let you change the app name and languages, Features allows server certificate management, and Endpoints 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 external database hookups 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 Connections 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 Auditing to track system activity and Usage Analytics 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="admin-help-nav"]', - 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. You can replay it anytime — just open Settings and find it here in the Tours section under Help.", - ), - position: "right", - padding: 10, - action: async () => { - removeAllGlows(); - navigateToSection("help"); - await waitForElement('[data-tour="admin-help-nav"]', 5000); - await waitForHighlightable('[data-tour="admin-help-nav"]', 5000); - }, - }, + const build = (spec: AdminStepSpec): StepType => { + const step: StepType = { + selector: spec.selector, + content: t(spec.contentKey, spec.contentDefault), + position: spec.position, + padding: spec.padding ?? 10, + }; + if (spec.highlightedSelectors) { + step.highlightedSelectors = spec.highlightedSelectors; + } + + const hasEnter = + spec.saveStateOnEnter || + spec.clearGlowOnEnter || + !!spec.section || + spec.waitForSelectorOnEnter; + if (hasEnter) { + step.action = async () => { + if (spec.saveStateOnEnter) saveAdminState(); + if (spec.clearGlowOnEnter) removeAllGlows(); + if (spec.section) { + removeAllGlows(); + navigateToSection(spec.section); + if (spec.glow) { + const glow = spec.glow; + setTimeout(() => addGlowToElements(glow), GLOW_DELAY_MS); + } + } + if (spec.waitForSelectorOnEnter) { + await waitForElement(spec.selector, 5000); + await waitForHighlightable(spec.selector, 5000); + } + }; + } + + const hasAfter = spec.openConfigAfter || !!spec.scrollToAfter; + if (hasAfter) { + step.actionAfter = async () => { + if (spec.openConfigAfter) openConfigModal(); + if (spec.scrollToAfter) await scrollNavToSection(spec.scrollToAfter); + }; + } + + return step; }; + + return ADMIN_STEP_SPECS.reduce( + (acc, spec) => { + acc[spec.step] = build(spec); + return acc; + }, + {} as Record, + ); } diff --git a/frontend/editor/src/core/components/onboarding/onboardingFlowConfig.ts b/frontend/editor/src/core/components/onboarding/onboardingFlowConfig.ts index 6eb843f4a5..167caac6a4 100644 --- a/frontend/editor/src/core/components/onboarding/onboardingFlowConfig.ts +++ b/frontend/editor/src/core/components/onboarding/onboardingFlowConfig.ts @@ -1,4 +1,5 @@ import WelcomeSlide from "@app/components/onboarding/slides/WelcomeSlide"; +import ProcessorIntroSlide from "@app/components/onboarding/slides/ProcessorIntroSlide"; import DesktopInstallSlide from "@app/components/onboarding/slides/DesktopInstallSlide"; import SecurityCheckSlide from "@app/components/onboarding/slides/SecurityCheckSlide"; import PlanOverviewSlide from "@app/components/onboarding/slides/PlanOverviewSlide"; @@ -7,12 +8,20 @@ import FirstLoginSlide from "@app/components/onboarding/slides/FirstLoginSlide"; import TourOverviewSlide from "@app/components/onboarding/slides/TourOverviewSlide"; import AnalyticsChoiceSlide from "@app/components/onboarding/slides/AnalyticsChoiceSlide"; import MFASetupSlide from "@app/components/onboarding/slides/MFASetupSlide"; -import { SlideConfig, LicenseNotice } from "@app/types/types"; -import type { ButtonAccent } from "@app/ui/Button"; +import { LicenseNotice } from "@app/types/types"; +import type { + OSOption, + ButtonDefinition as ButtonDefinitionBase, + HeroDefinition as HeroDefinitionBase, + SlideDefinition as SlideDefinitionBase, +} from "@app/components/onboarding/onboardingSlideTypes"; + +export type { OSOption }; export type SlideId = | "first-login" | "welcome" + | "processor-intro" | "desktop-install" | "security-check" | "admin-overview" @@ -40,6 +49,7 @@ export type ButtonAction = | "launch-admin" | "launch-tools" | "launch-auto" + | "open-processor" | "see-plans" | "skip-to-license" | "skip-tour" @@ -50,12 +60,6 @@ export interface FlowState { selectedRole: "admin" | "user" | null; } -export interface OSOption { - label: string; - url: string; - value: string; -} - export interface SlideFactoryParams { osLabel: string; osUrl: string; @@ -74,29 +78,17 @@ export interface SlideFactoryParams { onMfaSetupComplete?: () => void; } -export interface HeroDefinition { - type: HeroType; -} +export type HeroDefinition = HeroDefinitionBase; -export interface ButtonDefinition { - key: string; - type: "button" | "icon"; - label?: string; - icon?: "chevron-left"; - variant?: "primary" | "secondary" | "default"; - /** Accent for the shared Button; defaults to neutral. */ - accent?: ButtonAccent; - group: "left" | "right"; - action: ButtonAction; - disabledWhen?: (state: FlowState) => boolean; -} +export type ButtonDefinition = ButtonDefinitionBase; -export interface SlideDefinition { - id: SlideId; - createSlide: (params: SlideFactoryParams) => SlideConfig; - hero: HeroDefinition; - buttons: ButtonDefinition[]; -} +export type SlideDefinition = SlideDefinitionBase< + SlideId, + ButtonAction, + FlowState, + HeroType, + SlideFactoryParams +>; export const SLIDE_DEFINITIONS: Record = { "first-login": { @@ -129,6 +121,36 @@ export const SLIDE_DEFINITIONS: Record = { }, ], }, + "processor-intro": { + id: "processor-intro", + createSlide: () => ProcessorIntroSlide(), + hero: { type: "diamond" }, + buttons: [ + { + key: "processor-back", + type: "icon", + icon: "chevron-left", + group: "left", + action: "prev", + }, + { + key: "processor-skip", + type: "button", + label: "onboarding.buttons.skipForNow", + variant: "secondary", + group: "left", + action: "next", + }, + { + key: "processor-open", + type: "button", + label: "onboarding.processorIntro.cta", + variant: "primary", + group: "right", + action: "open-processor", + }, + ], + }, "desktop-install": { id: "desktop-install", createSlide: ({ osLabel, osUrl, osOptions, onDownloadUrlChange }) => diff --git a/frontend/editor/src/core/components/onboarding/onboardingSlideTypes.ts b/frontend/editor/src/core/components/onboarding/onboardingSlideTypes.ts new file mode 100644 index 0000000000..23fe6cc6c9 --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/onboardingSlideTypes.ts @@ -0,0 +1,65 @@ +import type { SlideConfig } from "@app/types/types"; +import type { ButtonAccent } from "@app/ui/Button"; + +/** + * Shared shape for onboarding slide flows. Both the core/editor flow and the + * SaaS flow describe the same thing — a sequence of slides, each with a hero, + * body content, and a row of buttons — so the structure lives here once and is + * parameterised per flow by its own action/hero/state unions. Previously these + * interfaces were copy-pasted into each flow config. + */ + +export interface OSOption { + label: string; + url: string; + value: string; +} + +export interface ButtonDefinition { + key: string; + type: "button" | "icon"; + label?: string; + icon?: "chevron-left"; + variant?: "primary" | "secondary" | "default"; + /** Accent for the shared Button; defaults to neutral. */ + accent?: ButtonAccent; + group: "left" | "right"; + action: Action; + disabledWhen?: (state: State) => boolean; +} + +export interface HeroDefinition { + type: Hero; +} + +export interface SlideDefinition< + Id extends string, + Action extends string, + State, + Hero extends string, + Params, +> { + id: Id; + createSlide: (params: Params) => SlideConfig; + hero: HeroDefinition; + buttons: ButtonDefinition[]; +} + +/** A single entry in a conditionally-resolved flow. */ +export interface FlowStep { + id: Id; + /** Included in the resolved flow only when this returns true. */ + when: (ctx: Ctx) => boolean; +} + +/** + * Resolves an ordered flow to the ids whose `when` predicate holds for `ctx`. + * This is the one operation both flows share: "the flow is the steps that apply + * right now, in order". + */ +export function resolveFlowIds( + steps: FlowStep[], + ctx: Ctx, +): Id[] { + return steps.filter((step) => step.when(ctx)).map((step) => step.id); +} diff --git a/frontend/editor/src/core/components/onboarding/orchestrator/onboardingConfig.ts b/frontend/editor/src/core/components/onboarding/orchestrator/onboardingConfig.ts index 6e10ec97f3..9b8ba965c8 100644 --- a/frontend/editor/src/core/components/onboarding/orchestrator/onboardingConfig.ts +++ b/frontend/editor/src/core/components/onboarding/orchestrator/onboardingConfig.ts @@ -1,6 +1,7 @@ export type OnboardingStepId = | "first-login" | "welcome" + | "processor-intro" | "desktop-install" | "security-check" | "admin-overview" @@ -15,7 +16,8 @@ export type OnboardingStepType = "modal-slide" | "tool-prompt"; export interface OnboardingRuntimeState { selectedRole: "admin" | "user" | null; tourRequested: boolean; - tourType: "admin" | "tools" | "whatsnew"; + // Open key into the tour registry (see tourRegistry.ts). + tourType: string; isDesktopApp: boolean; desktopSlideEnabled: boolean; analyticsNotConfigured: boolean; @@ -44,6 +46,7 @@ export interface OnboardingStep { slideId?: | "first-login" | "welcome" + | "processor-intro" | "desktop-install" | "security-check" | "admin-overview" @@ -88,6 +91,13 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [ // Desktop has its own onboarding modal (DesktopOnboardingModal) condition: (ctx) => !ctx.isDesktopApp, }, + { + id: "processor-intro", + type: "modal-slide", + slideId: "processor-intro", + // Admins can manage policies in the portal/processor; regular users can't. + condition: (ctx) => ctx.effectiveIsAdmin, + }, { id: "admin-overview", type: "modal-slide", @@ -100,17 +110,6 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [ slideId: "desktop-install", condition: (ctx) => !ctx.isDesktopApp && ctx.desktopSlideEnabled, }, - { - id: "security-check", - type: "modal-slide", - slideId: "security-check", - condition: () => false, - }, - { - id: "tool-layout", - type: "tool-prompt", - condition: () => false, - }, { id: "tour-overview", type: "modal-slide", diff --git a/frontend/editor/src/core/components/onboarding/orchestrator/onboardingStorage.ts b/frontend/editor/src/core/components/onboarding/orchestrator/onboardingStorage.ts index 73f45cb1ea..084761849d 100644 --- a/frontend/editor/src/core/components/onboarding/orchestrator/onboardingStorage.ts +++ b/frontend/editor/src/core/components/onboarding/orchestrator/onboardingStorage.ts @@ -2,6 +2,85 @@ const STORAGE_PREFIX = "onboarding"; const TOURS_TOOLTIP_KEY = `${STORAGE_PREFIX}::tours-tooltip-shown`; const ONBOARDING_COMPLETED_KEY = `${STORAGE_PREFIX}::completed`; +// Per-flow persistence lives under a single namespace so completion state +// composes across build flavors and, in future, checklist-style flows that +// track individual step completion (see setStepDone / getFlowProgress). +const flowSeenKey = (flowId: string) => + `${STORAGE_PREFIX}::flow::${flowId}::seen`; +const flowProgressKey = (flowId: string) => + `${STORAGE_PREFIX}::flow::${flowId}::progress`; + +function readJson(key: string, fallback: T): T { + if (typeof window === "undefined") return fallback; + try { + const raw = localStorage.getItem(key); + return raw ? (JSON.parse(raw) as T) : fallback; + } catch { + return fallback; + } +} + +function writeJson(key: string, value: unknown): void { + if (typeof window === "undefined") return; + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch (error) { + console.error(`[onboardingStorage] Error writing ${key}:`, error); + } +} + +/** Whether a named flow (e.g. "saas", "portal") has been seen/dismissed. */ +export function hasSeenFlow(flowId: string): boolean { + if (typeof window === "undefined") return false; + try { + return localStorage.getItem(flowSeenKey(flowId)) === "true"; + } catch { + return false; + } +} + +/** Marks a named flow as seen so it does not reappear. */ +export function markFlowSeen(flowId: string): void { + if (typeof window === "undefined") return; + try { + localStorage.setItem(flowSeenKey(flowId), "true"); + } catch (error) { + console.error(`[onboardingStorage] Error marking flow "${flowId}":`, error); + } +} + +/** Completed step ids for a checklist-style flow, in completion order. */ +export function getFlowProgress(flowId: string): string[] { + const value = readJson(flowProgressKey(flowId), []); + return Array.isArray(value) ? value : []; +} + +/** Whether a single step within a flow has been completed. */ +export function isStepDone(flowId: string, stepId: string): boolean { + return getFlowProgress(flowId).includes(stepId); +} + +/** Records a single step within a flow as done (idempotent). */ +export function setStepDone(flowId: string, stepId: string): void { + const progress = getFlowProgress(flowId); + if (progress.includes(stepId)) return; + writeJson(flowProgressKey(flowId), [...progress, stepId]); +} + +/** Clears both the seen flag and step progress for a flow. */ +export function resetFlow(flowId: string): void { + if (typeof window === "undefined") return; + try { + localStorage.removeItem(flowSeenKey(flowId)); + localStorage.removeItem(flowProgressKey(flowId)); + } catch (error) { + console.error( + `[onboardingStorage] Error resetting flow "${flowId}":`, + error, + ); + } +} + export function isOnboardingCompleted(): boolean { if (typeof window === "undefined") return false; try { diff --git a/frontend/editor/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts b/frontend/editor/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts index 44052636d1..d0ec8a66b5 100644 --- a/frontend/editor/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts +++ b/frontend/editor/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts @@ -41,13 +41,10 @@ function getInitialRuntimeState( try { const tourRequested = sessionStorage.getItem(SESSION_TOUR_REQUESTED) === "true"; - const sessionTourType = sessionStorage.getItem(SESSION_TOUR_TYPE); + // Any stored tour id is accepted (validated against the registry at render); + // fall back to the default tour type when absent. const tourType = - sessionTourType === "admin" || - sessionTourType === "tools" || - sessionTourType === "whatsnew" - ? sessionTourType - : "whatsnew"; + sessionStorage.getItem(SESSION_TOUR_TYPE) ?? baseState.tourType; const selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as | "admin" | "user" @@ -183,6 +180,7 @@ export function useOnboardingOrchestrator( ); const [isPaused, setIsPaused] = useState(false); const [isInitialized, setIsInitialized] = useState(false); + const [manuallyStarted, setManuallyStarted] = useState(false); const [currentStepIndex, setCurrentStepIndex] = useState(-1); const migrationDone = useRef(false); const initialIndexSet = useRef(false); @@ -289,23 +287,8 @@ export function useOnboardingOrchestrator( useEffect(() => { if (configLoading || !adminStatusResolved) return; - // If there are no steps to show, mark initialized/completed baseline - if (activeFlow.length === 0) { - setCurrentStepIndex(0); - initialIndexSet.current = true; - return; - } - - // If onboarding has been completed, don't show it - if (isOnboardingCompleted()) { - setCurrentStepIndex(activeFlow.length); - initialIndexSet.current = true; - return; - } - - // Start from the beginning if (!initialIndexSet.current) { - setCurrentStepIndex(0); + setCurrentStepIndex(activeFlow.length); initialIndexSet.current = true; } }, [activeFlow, configLoading, adminStatusResolved]); @@ -326,6 +309,7 @@ export function useOnboardingOrchestrator( !isPaused && !isComplete && isInitialized && + manuallyStarted && currentStep !== null; const isLoading = configLoading || @@ -389,6 +373,7 @@ export function useOnboardingOrchestrator( if (index !== -1) { setCurrentStepIndex(index); setIsPaused(false); + setManuallyStarted(true); } }, [activeFlow], diff --git a/frontend/editor/src/core/components/onboarding/slides/DesktopInstallTitle.tsx b/frontend/editor/src/core/components/onboarding/slides/DesktopInstallTitle.tsx index 97329db5d7..efb95b95bb 100644 --- a/frontend/editor/src/core/components/onboarding/slides/DesktopInstallTitle.tsx +++ b/frontend/editor/src/core/components/onboarding/slides/DesktopInstallTitle.tsx @@ -17,6 +17,41 @@ interface DesktopInstallTitleProps { onDownloadUrlChange?: (url: string) => void; } +/** Brand marks (simple-icons paths) so each option shows its real OS logo. */ +const OS_ICON_PATHS: Record = { + apple: + "M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701", + windows: + "M0 3.449L9.75 2.1v9.451H0m10.949-9.602L24 0v11.4H10.949M0 12.6h9.75v9.451L0 20.699M10.949 12.6H24V24l-12.9-1.801", + linux: + "M14.62 8.35c-.18.11-.4.28-.66.35-.24.08-.5.16-.72.16-.4 0-.72-.13-.98-.28-.16-.09-.29-.19-.4-.28l-.08-.06a.29.29 0 0 0-.34.02.28.28 0 0 0-.05.4c.02.02.16.16.4.3.24.16.6.32 1.05.32.34 0 .66-.08.94-.18.28-.1.5-.22.68-.34.42-.28.68-.6.68-.6a.28.28 0 0 0-.06-.4.29.29 0 0 0-.4.06s-.2.26-.53.45zM9.36 6.6c.35 0 .63.32.63.72s-.28.72-.63.72c-.35 0-.63-.32-.63-.72s.28-.72.63-.72zm5.3 0c.35 0 .63.32.63.72s-.28.72-.63.72c-.35 0-.63-.32-.63-.72s.28-.72.63-.72zM12 0C6.9 0 4.28 3.98 4.34 7.66c.06 3.68-.9 4.98-1.72 6.28C1.8 15.24.9 16.4.9 18.1c0 .96.4 1.6 1 2 .6.4 1.36.5 2.1.6.74.1 1.46.2 2 .5.54.3.9.8 1.7.9.4.06.86 0 1.3-.2.44-.2.86-.54 1.16-1.06h1.68c.3.52.72.86 1.16 1.06.44.2.9.26 1.3.2.8-.1 1.16-.6 1.7-.9.54-.3 1.26-.4 2-.5.74-.1 1.5-.2 2.1-.6.6-.4 1-1.04 1-2 0-1.7-.9-2.86-1.72-4.16-.82-1.3-1.78-2.6-1.72-6.28C19.72 3.98 17.1 0 12 0z", +}; + +/** Map an OS option to its brand-icon key from its value/label. */ +function osIconKey(option: OSOption): string | null { + const text = `${option.value} ${option.label}`.toLowerCase(); + if (/(mac|apple|osx|darwin)/.test(text)) return "apple"; + if (/win/.test(text)) return "windows"; + if (/linux/.test(text)) return "linux"; + return null; +} + +function OsIcon({ os }: { os: string }) { + const d = OS_ICON_PATHS[os]; + if (!d) return null; + return ( + + ); +} + export const DesktopInstallTitle: React.FC = ({ osLabel, osUrl, @@ -51,7 +86,7 @@ export const DesktopInstallTitle: React.FC = ({ // If only one option or no options, don't show dropdown if (osOptions.length <= 1) { - return
{title}
; + return
{title}
; } return ( @@ -59,13 +94,12 @@ export const DesktopInstallTitle: React.FC = ({ style={{ display: "flex", alignItems: "center", - justifyContent: "center", gap: "0.5rem", width: "100%", }} > {title} - + = ({ {osOptions.map((option) => { const isSelected = option.url === selectedOsUrl; + const iconKey = osIconKey(option); return ( handleOsSelect(option)} + leftSection={iconKey ? : undefined} style={{ backgroundColor: isSelected - ? "light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))" + ? "var(--bg-muted, #f1f5f9)" : "transparent", - color: isSelected - ? "light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))" - : "inherit", + color: "var(--onboarding-title, #0f172a)", + fontWeight: isSelected ? 600 : 500, }} > {option.label} diff --git a/frontend/editor/src/core/components/onboarding/slides/ProcessorIntroSlide.tsx b/frontend/editor/src/core/components/onboarding/slides/ProcessorIntroSlide.tsx new file mode 100644 index 0000000000..ed910ca48a --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/slides/ProcessorIntroSlide.tsx @@ -0,0 +1,30 @@ +import { Trans } from "react-i18next"; +import { SlideConfig } from "@app/types/types"; +import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig"; + +const ProcessorIntroBody = () => ( + + }} + defaults="Stirling now runs Policies — automated rules that classify, secure, and process every document as it arrives. Set them up and monitor runs in the Processor." + /> + +); + +export default function ProcessorIntroSlide(): SlideConfig { + return { + key: "processor-intro", + title: ( + + ), + body: , + background: { + gradientStops: ["#2563EB", "#7C3AED"], + circles: UNIFIED_CIRCLE_CONFIG, + }, + }; +} diff --git a/frontend/editor/src/core/components/onboarding/tourRegistry.ts b/frontend/editor/src/core/components/onboarding/tourRegistry.ts new file mode 100644 index 0000000000..8213e5006e --- /dev/null +++ b/frontend/editor/src/core/components/onboarding/tourRegistry.ts @@ -0,0 +1,105 @@ +import type { StepType } from "@reactour/tour"; +import type { TFunction } from "i18next"; +import type { useTourOrchestration } from "@app/contexts/TourOrchestrationContext"; +import type { useAdminTourOrchestration } from "@app/contexts/AdminTourOrchestrationContext"; +import { createAdminStepsConfig } from "@app/components/onboarding/adminStepsConfig"; +import { createUserStepsConfig } from "@app/components/onboarding/userStepsConfig"; +import { createWhatsNewStepsConfig } from "@app/components/onboarding/whatsNewStepsConfig"; + +type WorkbenchTourActions = ReturnType; +type AdminTourActions = ReturnType; + +/** + * Everything a tour's step builder might need, resolved once by the onboarding + * component from the two orchestration contexts. Each tour picks what it uses. + */ +export interface TourBuildContext { + t: TFunction; + workbench: WorkbenchTourActions; + admin: AdminTourActions; + openFilesModal: () => void; + closeFilesModal: () => void; +} + +export interface TourDefinition { + id: string; + build: (ctx: TourBuildContext) => StepType[]; +} + +/** + * Registry of guided tours. Adding a tour is a single entry here — no changes + * to the onboarding component's rendering. Tour ids are open strings so future + * builds/features can register their own without editing a central union. + */ +export const TOUR_REGISTRY: Record = { + admin: { + id: "admin", + build: ({ t, admin }) => + Object.values( + createAdminStepsConfig({ + t, + actions: { + saveAdminState: admin.saveAdminState, + openConfigModal: admin.openConfigModal, + navigateToSection: admin.navigateToSection, + scrollNavToSection: admin.scrollNavToSection, + }, + }), + ), + }, + tools: { + id: "tools", + build: ({ t, workbench, admin, openFilesModal, closeFilesModal }) => + Object.values( + createUserStepsConfig({ + t, + actions: { + saveWorkbenchState: workbench.saveWorkbenchState, + closeFilesModal, + backToAllTools: workbench.backToAllTools, + selectCropTool: workbench.selectCropTool, + loadSampleFile: workbench.loadSampleFile, + switchToActiveFiles: workbench.switchToActiveFiles, + pinFile: workbench.pinFile, + revealFileCardHoverMenu: workbench.revealFileCardHoverMenu, + modifyCropSettings: workbench.modifyCropSettings, + executeTool: workbench.executeTool, + openFilesModal, + openSettingsHelpSection: () => admin.navigateToSection("help"), + }, + }), + ), + }, + whatsnew: { + id: "whatsnew", + build: ({ t, workbench, openFilesModal, closeFilesModal }) => + Object.values( + createWhatsNewStepsConfig({ + t, + actions: { + saveWorkbenchState: workbench.saveWorkbenchState, + closeFilesModal, + backToAllTools: workbench.backToAllTools, + openFilesModal, + loadSampleFile: workbench.loadSampleFile, + switchToViewer: workbench.switchToViewer, + switchToPageEditor: workbench.switchToPageEditor, + switchToActiveFiles: workbench.switchToActiveFiles, + }, + }), + ), + }, +}; + +/** Default tour when a requested id is unknown or unset. */ +export const DEFAULT_TOUR_TYPE = "whatsnew"; + +/** Resolves a tour's steps, falling back to the default tour for unknown ids. */ +export function getTourSteps( + tourType: string, + ctx: TourBuildContext, +): StepType[] { + const definition = + TOUR_REGISTRY[tourType] ?? TOUR_REGISTRY[DEFAULT_TOUR_TYPE]; + return definition.build(ctx); +} diff --git a/frontend/editor/src/core/components/onboarding/userStepsConfig.ts b/frontend/editor/src/core/components/onboarding/userStepsConfig.ts index e8a52803da..85281e712e 100644 --- a/frontend/editor/src/core/components/onboarding/userStepsConfig.ts +++ b/frontend/editor/src/core/components/onboarding/userStepsConfig.ts @@ -106,7 +106,7 @@ export function createUserStepsConfig({ actionAfter: () => openFilesModal(), }, [TourStep.FILE_SOURCES]: { - selector: '[data-tour="file-sources"]', + selector: '[data-tour="files-modal"]', 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.", @@ -114,8 +114,8 @@ export function createUserStepsConfig({ position: "right", padding: 0, action: async () => { - await waitForElement('[data-tour="file-sources"]', 5000); - await waitForHighlightable('[data-tour="file-sources"]', 5000); + await waitForElement('[data-tour="files-modal"]', 5000); + await waitForHighlightable('[data-tour="files-modal"]', 5000); }, actionAfter: () => { loadSampleFile(); @@ -135,7 +135,7 @@ export function createUserStepsConfig({ 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.", + "The Active Files view shows all of the PDFs you have loaded into the tool.", ), position: "center", padding: 0, @@ -145,7 +145,7 @@ export function createUserStepsConfig({ 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.", + "Files on the workbench are selected for processing. You can select multiple files for batch operations using the left files sidebar.", ), position: "top", padding: 10, @@ -200,17 +200,17 @@ export function createUserStepsConfig({ actionAfter: () => pinFile(), }, [TourStep.WRAP_UP]: { - selector: '[data-tour="admin-help-nav"]', + selector: '[data-tour="settings-modal"]', content: t( "onboarding.wrapUp", "You're all set! You can replay this tour anytime — just open Settings and find it here in the Tours section under Help.", ), - position: "right", - padding: 10, + position: "center", + padding: 0, action: async () => { openSettingsHelpSection(); - await waitForElement('[data-tour="admin-help-nav"]', 5000); - await waitForHighlightable('[data-tour="admin-help-nav"]', 5000); + await waitForElement('[data-tour="settings-modal"]', 5000); + await waitForHighlightable('[data-tour="settings-modal"]', 5000); }, }, }; diff --git a/frontend/editor/src/core/components/shared/AppConfigModal.tsx b/frontend/editor/src/core/components/shared/AppConfigModal.tsx index 485403c512..83f7342a74 100644 --- a/frontend/editor/src/core/components/shared/AppConfigModal.tsx +++ b/frontend/editor/src/core/components/shared/AppConfigModal.tsx @@ -266,7 +266,7 @@ const AppConfigModalInner: React.FC = ({ styles={{ content: { overflowY: "hidden", overscrollBehavior: "none" } }} removeScrollProps={{ shards: [COOKIE_CONSENT_SCROLL_SHARD] }} > -
+
{/* Left navigation */}
( onConfirm={handleConfirmSidebarDelete} /> + {/* Getting-started checklist, floating above the footer (SaaS only). */} + + {/* Bottom bar: user name + settings */} {

{t("landing.workbenchEmptyStateHero", "Drop a PDF anywhere")}

-

- {t( - "landing.heroSubtitle", - "Drop in or add an existing PDF to get started.", - )} -

- void handleNativeUploadClick()} diff --git a/frontend/editor/src/core/components/shared/LoadingFallback.tsx b/frontend/editor/src/core/components/shared/LoadingFallback.tsx index dced852150..c861fd04b0 100644 --- a/frontend/editor/src/core/components/shared/LoadingFallback.tsx +++ b/frontend/editor/src/core/components/shared/LoadingFallback.tsx @@ -10,7 +10,9 @@ export function LoadingFallback() { alignItems: "center", height: "100vh", fontSize: "18px", - color: "#666", + // Theme-aware so the splash follows light/dark instead of forcing white. + backgroundColor: "var(--mantine-color-body)", + color: "var(--mantine-color-text)", }} > Loading... diff --git a/frontend/editor/src/core/components/shared/SidebarChecklistSlot.tsx b/frontend/editor/src/core/components/shared/SidebarChecklistSlot.tsx new file mode 100644 index 0000000000..d9844123d6 --- /dev/null +++ b/frontend/editor/src/core/components/shared/SidebarChecklistSlot.tsx @@ -0,0 +1,13 @@ +export interface SidebarChecklistSlotProps { + /** Whether the sidebar is collapsed to its narrow rail. */ + collapsed?: boolean; +} + +/** + * Extension point for a getting-started checklist that floats above the + * sidebar footer. Core renders nothing; builds that offer onboarding (SaaS) + * shadow this file to provide the real checklist. + */ +export function SidebarChecklistSlot(_props: SidebarChecklistSlotProps) { + return null; +} diff --git a/frontend/editor/src/core/constants/events.ts b/frontend/editor/src/core/constants/events.ts index aa93ebcfaf..f653e987b8 100644 --- a/frontend/editor/src/core/constants/events.ts +++ b/frontend/editor/src/core/constants/events.ts @@ -29,7 +29,10 @@ export interface UpgradeBannerAlertPayload { freeTierLimit?: number; } -export type TourType = "admin" | "tools" | "whatsnew"; +// Open string keyed into the tour registry (see tourRegistry.ts). Kept as a +// named alias so intent is clear at call sites; known ids are "admin", "tools", +// "whatsnew" but builds/features may register their own. +export type TourType = string; export interface StartTourPayload { tourType: TourType; diff --git a/frontend/editor/src/core/pages/HomePage.tsx b/frontend/editor/src/core/pages/HomePage.tsx index c1b4f572bc..cd0a838421 100644 --- a/frontend/editor/src/core/pages/HomePage.tsx +++ b/frontend/editor/src/core/pages/HomePage.tsx @@ -107,6 +107,19 @@ export default function HomePage() { setConfigModalOpen(isSettings); }, [location.pathname]); + useEffect(() => { + const handler = () => setConfigModalOpen(true); + window.addEventListener("appConfig:open", handler); + return () => window.removeEventListener("appConfig:open", handler); + }, []); + + const handleCloseConfig = useCallback(() => { + setConfigModalOpen(false); + if (location.pathname.startsWith("/settings")) { + navigate("/", { replace: true }); + } + }, [location.pathname, navigate]); + const { activeFiles } = useFileContext(); const navigationState = useNavigationState(); const { actions } = useNavigationActions(); @@ -477,7 +490,7 @@ export default function HomePage() { setConfigModalOpen(false)} + onClose={handleCloseConfig} />
) : ( @@ -526,7 +539,7 @@ export default function HomePage() { setConfigModalOpen(false)} + onClose={handleCloseConfig} /> )} diff --git a/frontend/editor/src/core/tests/stubbed/main-dashboard.spec.ts b/frontend/editor/src/core/tests/stubbed/main-dashboard.spec.ts index db715220d3..c66f392fa1 100644 --- a/frontend/editor/src/core/tests/stubbed/main-dashboard.spec.ts +++ b/frontend/editor/src/core/tests/stubbed/main-dashboard.spec.ts @@ -1,5 +1,4 @@ import { test, expect } from "@app/tests/helpers/stub-test-base"; -import { mockAppApis, seedCookieConsent } from "@app/tests/helpers/api-stubs"; import { openSettings } from "@app/tests/helpers/ui-helpers"; test.describe("2. Main Dashboard / Home Page", () => { @@ -113,40 +112,4 @@ test.describe("2. Main Dashboard / Home Page", () => { }); }); }); - - test.describe("2.5 Dashboard - Welcome Dialog for fresh users", () => { - test("should show welcome dialog when onboarding flags are unset", async ({ - browser, - }) => { - // Fresh context — no localStorage flags, so the onboarding modal should appear. - const context = await browser.newContext({ - viewport: { width: 1920, height: 1080 }, - }); - const page = await context.newPage(); - - await seedCookieConsent(page); - await mockAppApis(page); - await page.goto("/"); - - const welcomeDialog = page.getByText(/welcome/i).first(); - await expect(welcomeDialog).toBeVisible({ timeout: 10000 }); - - for (let i = 0; i < 5; i++) { - const hasOverlay = await page - .locator(".mantine-Modal-overlay, .mantine-Overlay-root") - .first() - .isVisible() - .catch(() => false); - if (!hasOverlay) break; - await page.keyboard.press("Escape"); - await page.waitForTimeout(500); - } - - await expect(page.getByPlaceholder(/search/i).first()).toBeVisible({ - timeout: 10000, - }); - - await context.close(); - }); - }); }); diff --git a/frontend/editor/src/core/utils/settingsNavigation.ts b/frontend/editor/src/core/utils/settingsNavigation.ts index fddd9ab5bc..a4ca4248dd 100644 --- a/frontend/editor/src/core/utils/settingsNavigation.ts +++ b/frontend/editor/src/core/utils/settingsNavigation.ts @@ -8,6 +8,13 @@ export function navigateToSettings(section: NavKey) { window.dispatchEvent(new PopStateEvent("popstate")); } +export function openSettings(section: NavKey) { + window.history.pushState({}, "", withBasePath(`/settings/${section}`)); + window.dispatchEvent( + new CustomEvent("appConfig:open", { detail: { section } }), + ); +} + /** URL for a settings section (subpath-aware). */ export function getSettingsUrl(section: NavKey): string { return withBasePath(`/settings/${section}`); diff --git a/frontend/editor/src/desktop/components/DesktopSaasOnboardingBootstrap.tsx b/frontend/editor/src/desktop/components/DesktopSaasOnboardingBootstrap.tsx index eef035bbd0..d667b20714 100644 --- a/frontend/editor/src/desktop/components/DesktopSaasOnboardingBootstrap.tsx +++ b/frontend/editor/src/desktop/components/DesktopSaasOnboardingBootstrap.tsx @@ -1,74 +1,19 @@ -import { useEffect, useState } from "react"; -import { useAuth } from "@app/auth/UseSession"; -import SaasOnboardingModal from "@app/components/onboarding/SaasOnboardingModal"; - /** * Desktop bootstrap for the SaaS product onboarding. * - * Mirrors saas's {@code OnboardingBootstrap}: once a SaaS user has signed in we - * show the shared cloud {@link SaasOnboardingModal} (free-editor pitch → usage - * meter → team), reusing the exact same flow as the web app. The closing - * "download desktop" slide is dropped via {@code hideDesktopInstall} — this IS - * the desktop app, so pitching its own download makes no sense. + * First-load auto-display is disabled: the shared cloud {@link SaasOnboardingModal} + * no longer appears automatically on first sign-in. This mirrors the web SaaS + * {@code OnboardingBootstrap}, which no longer auto-opens either. The modal + * component is retained for explicit/manual triggering; nothing opens it here. * - * Differences from the saas bootstrap: - * - The desktop {@code useAuth} (proprietary) exposes no pro/wallet fields; the - * cloud flow reads the live wallet itself to decide which slides to show, so - * we just wait for a non-anonymous signed-in user. - * - Gated on {@code connectionMode === "saas"} so it never fires in local or - * self-hosted mode (where there is no SaaS wallet/team to onboard). - * - * Shown once per device, gated by localStorage (same key the saas web flow uses - * so a user who onboarded on the web isn't re-onboarded — they are independent - * stores, but the key/intent is shared). + * The props signature is preserved so the AppProviders wiring is unaffected. */ -const STORAGE_KEY = "saas_onboarding_seen"; - interface DesktopSaasOnboardingBootstrapProps { connectionMode: "saas" | "selfhosted" | "local" | null; } -export function DesktopSaasOnboardingBootstrap({ - connectionMode, -}: DesktopSaasOnboardingBootstrapProps) { - const { user, loading } = useAuth(); - const [showModal, setShowModal] = useState(false); - - const isSignedInSaasUser = - connectionMode === "saas" && - !loading && - !!user && - user.is_anonymous !== true; - - useEffect(() => { - if (!isSignedInSaasUser) return; - let seen = false; - try { - seen = localStorage.getItem(STORAGE_KEY) === "true"; - } catch { - // localStorage unavailable — fail open and show onboarding. - } - if (!seen) { - setShowModal(true); - } - }, [isSignedInSaasUser]); - - const handleClose = () => { - try { - localStorage.setItem(STORAGE_KEY, "true"); - } catch { - // localStorage unavailable — best-effort; the in-memory flag still hides it. - } - setShowModal(false); - }; - - if (!showModal) return null; - - return ( - - ); +export function DesktopSaasOnboardingBootstrap( + _props: DesktopSaasOnboardingBootstrapProps, +) { + return null; } diff --git a/frontend/editor/src/portal/components/LoginScreen.tsx b/frontend/editor/src/portal/components/LoginScreen.tsx index 7abf518a0c..f16553d56d 100644 --- a/frontend/editor/src/portal/components/LoginScreen.tsx +++ b/frontend/editor/src/portal/components/LoginScreen.tsx @@ -1,19 +1,23 @@ -import { useEffect, useMemo } from "react"; +import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { AuthShell } from "@app/auth/ui/AuthShell"; import LoginRightCarousel from "@app/auth/ui/LoginRightCarousel"; import { buildDefaultLoginSlides } from "@app/auth/ui/loginSlides"; import SpringLoginForm from "@app/auth/ui/SpringLoginForm"; import { useSpringLogin } from "@app/auth/ui/useSpringLogin"; +import { withBasePath } from "@app/constants/app"; import "@app/auth/ui/auth-theme.css"; import "@app/auth/ui/auth.css"; import loginHeader from "@app/assets/brand/modern-logo/LoginLightModeHeader.svg"; /** - * Full-screen login shown by the portal's auth gate. Renders the same screen as - * the editor: the shared AuthShell + carousel, with the form body and Spring - * auth wiring from @app/auth/ui (SpringLoginForm + useSpringLogin). The gate - * handles "already logged in", so this only needs to collect credentials. + * Full-screen login shown by the portal's auth gate. Renders the shared + * AuthShell + carousel with the Spring form/auth wiring from @app/auth/ui. + * + * It follows the user's light/dark theme (AuthShell is theme-aware — the same + * screen the editor login uses; passing both logo variants keeps the header + * readable in either mode). The gate handles "already logged in", so this only + * needs to collect credentials. */ export function LoginScreen() { const { t } = useTranslation(); @@ -23,16 +27,6 @@ export function LoginScreen() { [t], ); - // Auth pages render in light mode (the shared screen uses light-only tokens). - useEffect(() => { - const html = document.documentElement; - const previous = html.getAttribute("data-mantine-color-scheme"); - html.setAttribute("data-mantine-color-scheme", "light"); - return () => { - if (previous) html.setAttribute("data-mantine-color-scheme", previous); - }; - }, []); - return ( } > - + ); } diff --git a/frontend/editor/src/saas/components/OnboardingBootstrap.tsx b/frontend/editor/src/saas/components/OnboardingBootstrap.tsx index 27ae92b16f..7eccaa2305 100644 --- a/frontend/editor/src/saas/components/OnboardingBootstrap.tsx +++ b/frontend/editor/src/saas/components/OnboardingBootstrap.tsx @@ -1,35 +1,21 @@ -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { usePreferences } from "@app/contexts/PreferencesContext"; import { useOnboarding } from "@app/contexts/OnboardingContext"; -import { useAuth } from "@app/auth/UseSession"; -import SaasOnboardingModal from "@app/components/onboarding/SaasOnboardingModal"; -const STORAGE_KEY = "saas_onboarding_seen"; const ONBOARDING_SESSION_BLOCK_KEY = "stirling-onboarding-session-active"; /** - * SaaS-only bootstrap to clear deferred tour requests, mark tool panel prompt as completed, - * and show SaaS-specific onboarding on first login. + * SaaS-only bootstrap to clear deferred tour requests and mark the tool panel + * prompt / core intro onboarding as completed. + * + * First-load auto-display is disabled: the SaaS onboarding modal no longer + * appears on first login. The modal component (SaasOnboardingModal) is retained + * for explicit/manual triggering, but nothing opens it automatically here. */ export default function OnboardingBootstrap() { const { preferences, updatePreference } = usePreferences(); const { clearPendingTourRequest, setStartAfterToolModeSelection } = useOnboarding(); - const { user, loading } = useAuth(); - const [showModal, setShowModal] = useState(false); - - // Show the onboarding modal once on first login, after the user has loaded. - useEffect(() => { - const hasSeenOnboarding = localStorage.getItem(STORAGE_KEY) === "true"; - if (user && !hasSeenOnboarding && !loading && !showModal) { - setShowModal(true); - } - }, [user, loading, showModal]); - - const handleClose = () => { - localStorage.setItem(STORAGE_KEY, "true"); - setShowModal(false); - }; // Keep existing logic to disable core onboarding flags useEffect(() => { @@ -69,8 +55,5 @@ export default function OnboardingBootstrap() { setStartAfterToolModeSelection, ]); - // Only render modal when it should be shown to avoid running hooks unnecessarily - return showModal ? ( - - ) : null; + return null; } diff --git a/frontend/editor/src/saas/components/onboarding/OnboardingChecklist.module.css b/frontend/editor/src/saas/components/onboarding/OnboardingChecklist.module.css new file mode 100644 index 0000000000..344743473a --- /dev/null +++ b/frontend/editor/src/saas/components/onboarding/OnboardingChecklist.module.css @@ -0,0 +1,168 @@ +.card { + margin: 0.5rem; + padding: 0.625rem 0.75rem 0.6875rem; + background: var(--mantine-color-body); + border: 1px solid + color-mix(in srgb, var(--mantine-color-default-border) 45%, transparent); + border-radius: 0.625rem; + box-shadow: none; +} + +.titleGroup { + display: flex; + align-items: center; + gap: 0.375rem; + min-width: 0; +} + +.logo { + width: 1.125rem; + height: 1.125rem; + flex-shrink: 0; + border-radius: 0.25rem; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + cursor: pointer; + user-select: none; +} + +.title { + font-size: 0.8125rem; + font-weight: 600; + color: var(--mantine-color-text); + line-height: 1.2; +} + +.headerRight { + display: flex; + align-items: center; + gap: 0.125rem; + flex-shrink: 0; +} + +.progressCount { + font-size: 0.75rem; + color: var(--mantine-color-dimmed); + font-variant-numeric: tabular-nums; +} + +.chevron { + font-size: 0.95rem !important; + color: var(--mantine-color-dimmed); +} + +.closeButton { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px; + border: none; + background: transparent; + color: var(--mantine-color-dimmed); + border-radius: 0.375rem; + cursor: pointer; +} + +.closeButton:hover { + background: var(--mantine-color-default-hover); + color: var(--mantine-color-text); +} + +.closeIcon { + font-size: 0.95rem !important; +} + +.completeIcon { + font-size: 1.05rem !important; + color: var(--mantine-color-blue-6); +} + +.progressTrack { + margin-top: 0.5rem; + height: 4px; + border-radius: 999px; + background: var(--mantine-color-default-border); + overflow: hidden; +} + +.progressFill { + height: 100%; + border-radius: 999px; + background: var(--mantine-color-blue-6); + transition: width 0.25s ease; +} + +.items { + display: flex; + flex-direction: column; + margin-top: 0.5rem; +} + +.item { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.375rem 0.25rem; + border: none; + background: transparent; + border-radius: 0.375rem; + text-align: left; + cursor: pointer; + width: 100%; +} + +.item:hover { + background: var(--mantine-color-default-hover); +} + +.itemIcon { + display: inline-flex; + flex-shrink: 0; + margin-top: 1px; +} + +.checkDone { + font-size: 1.05rem !important; + color: var(--mantine-color-blue-6); +} + +.checkTodo { + font-size: 1.05rem !important; + color: var(--mantine-color-default-border); +} + +.itemText { + display: flex; + flex-direction: column; + gap: 0.0625rem; + min-width: 0; +} + +.itemTitle { + font-size: 0.8125rem; + font-weight: 500; + color: var(--mantine-color-text); + line-height: 1.25; +} + +.itemTitleDone { + text-decoration: line-through; + color: var(--mantine-color-dimmed); + font-weight: 400; +} + +.itemDescription { + font-size: 0.75rem; + color: var(--mantine-color-dimmed); + line-height: 1.25; +} + +.itemDescriptionDone { + text-decoration: line-through; + opacity: 0.75; +} diff --git a/frontend/editor/src/saas/components/onboarding/OnboardingChecklist.tsx b/frontend/editor/src/saas/components/onboarding/OnboardingChecklist.tsx new file mode 100644 index 0000000000..6f2eeade9a --- /dev/null +++ b/frontend/editor/src/saas/components/onboarding/OnboardingChecklist.tsx @@ -0,0 +1,293 @@ +import { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import CheckCircleIcon from "@mui/icons-material/CheckCircle"; +import RadioButtonUncheckedIcon from "@mui/icons-material/RadioButtonUnchecked"; +import CloseIcon from "@mui/icons-material/Close"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import ExpandLessIcon from "@mui/icons-material/ExpandLess"; +import { useAuth } from "@app/auth/UseSession"; +import SaasOnboardingModal from "@app/components/onboarding/SaasOnboardingModal"; +import StaticOnboardingSlide from "@app/components/onboarding/StaticOnboardingSlide"; +import { DEFAULT_RUNTIME_STATE } from "@app/components/onboarding/orchestrator/onboardingConfig"; +import { + getFlowProgress, + hasSeenFlow, + markFlowSeen, + setStepDone, +} from "@app/components/onboarding/orchestrator/onboardingStorage"; +import { openAppSettings } from "@app/utils/appSettings"; +import { requestStartTour } from "@app/constants/events"; +import apiClient from "@app/services/apiClient"; +import stirlingMark from "@app/assets/brand/modern-logo/logo512.png"; +import styles from "@app/components/onboarding/OnboardingChecklist.module.css"; + +const FLOW_ID = "saas-checklist"; +const STEP_DOWNLOAD_DESKTOP = "download-desktop"; +const STEP_INVITE_TEAM = "invite-team"; +const STEP_TAKE_TOUR = "take-tour"; +const STEP_SHARE_ANALYTICS = "share-analytics"; + +interface ChecklistItem { + id: string; + titleKey: string; + titleFallback: string; + descriptionKey: string; + descriptionFallback: string; + onClick: () => void; +} + +/** + * SaaS-only getting-started checklist that floats above the sidebar footer for + * new users. Progress is persisted per step via the shared onboarding store, so + * completed items stay ticked across reloads. Dismissing it (the X) hides it + * permanently for the user. + */ +export function OnboardingChecklist() { + const { t } = useTranslation(); + const { isAnonymous, loading } = useAuth(); + + const [dismissed, setDismissed] = useState(() => hasSeenFlow(FLOW_ID)); + const [done, setDone] = useState(() => getFlowProgress(FLOW_ID)); + const [expanded, setExpanded] = useState(true); + const [downloadOpen, setDownloadOpen] = useState(false); + const [analyticsOpen, setAnalyticsOpen] = useState(false); + + const markDone = useCallback((stepId: string) => { + setStepDone(FLOW_ID, stepId); + setDone((prev) => (prev.includes(stepId) ? prev : [...prev, stepId])); + }, []); + + const handleInviteTeam = useCallback(() => { + // Open the settings modal on the Teams section without touching the URL + // (event-driven open + navigate; no /settings/teams pushState). + openAppSettings("teams"); + markDone(STEP_INVITE_TEAM); + }, [markDone]); + + const handleTakeTour = useCallback(() => { + // Always the user (tools) walkthrough, regardless of admin/user role. The + // editor's onboarding listens for this event and drives the tour overlay. + requestStartTour("tools"); + markDone(STEP_TAKE_TOUR); + }, [markDone]); + + const items: ChecklistItem[] = useMemo( + () => [ + { + id: STEP_DOWNLOAD_DESKTOP, + titleKey: "onboarding.checklist.downloadDesktop.title", + titleFallback: "Download Stirling for Desktop", + descriptionKey: "onboarding.checklist.downloadDesktop.description", + descriptionFallback: "Run Stirling natively on your machine", + onClick: () => setDownloadOpen(true), + }, + { + id: STEP_INVITE_TEAM, + titleKey: "onboarding.checklist.inviteTeam.title", + titleFallback: "Invite team members", + descriptionKey: "onboarding.checklist.inviteTeam.description", + descriptionFallback: "Collaborate with your team", + onClick: handleInviteTeam, + }, + { + id: STEP_TAKE_TOUR, + titleKey: "onboarding.checklist.takeTour.title", + titleFallback: "Take the tour", + descriptionKey: "onboarding.checklist.takeTour.description", + descriptionFallback: "See how Stirling works in a quick walkthrough", + onClick: handleTakeTour, + }, + { + id: STEP_SHARE_ANALYTICS, + titleKey: "onboarding.checklist.shareAnalytics.title", + titleFallback: "Share anonymous usage data", + descriptionKey: "onboarding.checklist.shareAnalytics.description", + descriptionFallback: "Help improve Stirling", + onClick: () => setAnalyticsOpen(true), + }, + ], + [handleInviteTeam, handleTakeTour], + ); + + const doneCount = items.filter((item) => done.includes(item.id)).length; + const total = items.length; + const allDone = total > 0 && doneCount === total; + + const handleDismiss = useCallback(() => { + markFlowSeen(FLOW_ID); + setDismissed(true); + }, []); + + // Both "skip" and "download" in the reused slide close the modal, and either + // one should complete the task. + const handleDownloadClose = useCallback(() => { + markDone(STEP_DOWNLOAD_DESKTOP); + setDownloadOpen(false); + }, [markDone]); + + const closeAnalytics = useCallback(() => { + markDone(STEP_SHARE_ANALYTICS); + setAnalyticsOpen(false); + }, [markDone]); + + const handleAnalyticsAction = useCallback( + (action: string) => { + if (action === "enable-analytics" || action === "disable-analytics") { + const formData = new FormData(); + formData.append( + "enabled", + action === "enable-analytics" ? "true" : "false", + ); + void apiClient + .post("/api/v1/settings/update-enable-analytics", formData) + .catch((error) => { + console.error( + "[OnboardingChecklist] analytics update failed", + error, + ); + }); + } + closeAnalytics(); + }, + [closeAnalytics], + ); + + if (loading || isAnonymous || dismissed) { + return null; + } + + return ( + <> +
+
setExpanded((v) => !v)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setExpanded((v) => !v); + } + }} + > + + + + {t("onboarding.checklist.title", "Set up Stirling PDF")} + + + + + {doneCount} / {total} + + {expanded ? ( + + ) : ( + + )} + { + e.stopPropagation(); + handleDismiss(); + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + e.stopPropagation(); + handleDismiss(); + } + }} + > + {allDone ? ( + + ) : ( + + )} + + +
+ +
+
+
+ + {expanded && ( +
+ {items.map((item) => { + const isDone = done.includes(item.id); + return ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + item.onClick(); + } + }} + > + + {isDone ? ( + + ) : ( + + )} + + + + {t(item.titleKey, item.titleFallback)} + + + {t(item.descriptionKey, item.descriptionFallback)} + + +
+ ); + })} +
+ )} +
+ + + + {analyticsOpen && ( + + )} + + ); +} diff --git a/frontend/editor/src/saas/components/onboarding/OnboardingTour.tsx b/frontend/editor/src/saas/components/onboarding/OnboardingTour.tsx index 00d65af669..05d68f9dd2 100644 --- a/frontend/editor/src/saas/components/onboarding/OnboardingTour.tsx +++ b/frontend/editor/src/saas/components/onboarding/OnboardingTour.tsx @@ -1,7 +1,118 @@ /** - * SaaS stub — core tour system is suppressed in SaaS. - * SaaS uses SaasOnboardingModal instead. + * SaaS tour runner. + * + * The core onboarding orchestrator (which normally drives the guided tour) is + * not mounted in SaaS — SaaS uses SaasOnboardingModal for onboarding instead. + * So this component listens for the shared start-tour event (e.g. the + * getting-started checklist's "Take the tour") and drives the shared reactour + * presentation directly. + * + * Only the user "tools" walkthrough is offered in SaaS; there is no admin tour + * here. The admin orchestration context is still resolved because the tools + * tour's step builder uses it to open the settings Help section. */ + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { type StepType } from "@reactour/tour"; +import ReactourTour, { + type AdvanceArgs, + type CloseArgs, +} from "@core/components/onboarding/OnboardingTour"; +import { getTourSteps } from "@app/components/onboarding/tourRegistry"; +import { useTourRequest } from "@app/components/onboarding/useOnboardingEffects"; +import { useTourOrchestration } from "@app/contexts/TourOrchestrationContext"; +import { useAdminTourOrchestration } from "@app/contexts/AdminTourOrchestrationContext"; +import { useFilesModalContext } from "@app/contexts/FilesModalContext"; +import { dispatchTourState } from "@app/constants/events"; +import { removeAllGlows } from "@app/components/onboarding/tourGlow"; +import "@core/components/onboarding/OnboardingTour.css"; + export default function OnboardingTour() { - return null; + const { t } = useTranslation(); + const workbench = useTourOrchestration(); + const admin = useAdminTourOrchestration(); + const { openFilesModal, closeFilesModal } = useFilesModalContext(); + const { tourRequested, requestedTourType, clearTourRequest } = + useTourRequest(); + + const [isOpen, setIsOpen] = useState(false); + const [tourType, setTourType] = useState("tools"); + + const isRTL = + typeof document !== "undefined" + ? document.documentElement.dir === "rtl" + : false; + + // Let the rest of the app know a tour is running (e.g. to hide cookie consent). + useEffect(() => dispatchTourState(isOpen), [isOpen]); + + // Open on request (the checklist dispatches "tools"). + useEffect(() => { + if (tourRequested) { + setTourType(requestedTourType); + setIsOpen(true); + clearTourRequest(); + } + }, [tourRequested, requestedTourType, clearTourRequest]); + + useEffect(() => { + if (!isOpen) removeAllGlows(); + return () => removeAllGlows(); + }, [isOpen]); + + const tourSteps = useMemo( + () => + getTourSteps(tourType, { + t, + workbench, + admin, + openFilesModal, + closeFilesModal, + }), + [tourType, t, workbench, admin, openFilesModal, closeFilesModal], + ); + + const finishTour = useCallback(() => { + setIsOpen(false); + void workbench.restoreWorkbenchState(); + }, [workbench]); + + const handleAdvance = useCallback( + (args: AdvanceArgs) => { + const { + setCurrentStep, + currentStep, + steps, + setIsOpen: setReactourOpen, + } = args; + if (steps && currentStep === steps.length - 1) { + setReactourOpen(false); + finishTour(); + } else if (steps) { + setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1)); + } + }, + [finishTour], + ); + + const handleClose = useCallback( + (args: CloseArgs) => { + args.setIsOpen(false); + finishTour(); + }, + [finishTour], + ); + + return ( + + ); } diff --git a/frontend/editor/src/saas/components/shared/AppConfigModal.tsx b/frontend/editor/src/saas/components/shared/AppConfigModal.tsx index 7d36b40ee4..7b579f3c43 100644 --- a/frontend/editor/src/saas/components/shared/AppConfigModal.tsx +++ b/frontend/editor/src/saas/components/shared/AppConfigModal.tsx @@ -3,12 +3,14 @@ import { Modal, Text } from "@mantine/core"; import { Button } from "@app/ui/Button"; import { ActionIcon } from "@app/ui/ActionIcon"; import { useMediaQuery } from "@mantine/hooks"; +import { useLocation } from "react-router-dom"; import { useAuth } from "@app/auth/UseSession"; import { isUserAnonymous } from "@app/auth/supabase"; import { useTranslation } from "react-i18next"; import LocalIcon from "@app/components/shared/LocalIcon"; import Overview from "@app/components/shared/config/configSections/Overview"; import { createSaasConfigNavSections } from "@app/components/shared/config/saasConfigNavSections"; +import { consumePendingSettingsNav } from "@app/utils/appSettings"; import { NavKey, type ConfigNavSection, @@ -46,6 +48,15 @@ const AppConfigModal: React.FC = ({ const [confirmOpen, setConfirmOpen] = useState(false); const [active, setActive] = useState("overview"); const [notice, setNotice] = useState(null); + const location = useLocation(); + + // The modal mounts lazily on first open, so a synchronous `appConfig:navigate` + // dispatched by the opener can arrive before the listener below is attached. + // Consume any section stashed by openAppSettings on mount to land on it. + useEffect(() => { + const pending = consumePendingSettingsNav(); + if (pending) setActive(pending); + }, []); // Check if user can access billing features (non-anonymous users only) const isAnonymous = user ? isUserAnonymous(user) : false; @@ -76,13 +87,13 @@ const AppConfigModal: React.FC = ({ setActive(initialSection); return; } - const match = stripBasePath(window.location.pathname).match( + const match = stripBasePath(location.pathname).match( /^\/settings\/([^/?#]+)/, ); if (match) { setActive(match[1] as NavKey); } - }, [opened, initialSection]); + }, [opened, initialSection, location.pathname]); // Listen for notice updates (e.g., "Not enough credits..." next to Plan title) useEffect(() => { @@ -188,7 +199,7 @@ const AppConfigModal: React.FC = ({ closeOnEscape={!overlayActive} closeOnClickOutside={!overlayActive} > -
+
{/* Left navigation */}
= ({ return (
setActive(item.key)} className={`modal-nav-item ${isMobile ? "mobile" : ""}`} style={{ diff --git a/frontend/editor/src/saas/components/shared/SidebarChecklistSlot.tsx b/frontend/editor/src/saas/components/shared/SidebarChecklistSlot.tsx new file mode 100644 index 0000000000..84dea6ccde --- /dev/null +++ b/frontend/editor/src/saas/components/shared/SidebarChecklistSlot.tsx @@ -0,0 +1,13 @@ +import { type SidebarChecklistSlotProps } from "@core/components/shared/SidebarChecklistSlot"; +export { type SidebarChecklistSlotProps }; + +import { OnboardingChecklist } from "@app/components/onboarding/OnboardingChecklist"; + +/** + * SaaS getting-started checklist, floating above the sidebar footer. Hidden + * when the sidebar is collapsed to its narrow rail. + */ +export function SidebarChecklistSlot({ collapsed }: SidebarChecklistSlotProps) { + if (collapsed) return null; + return ; +} diff --git a/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx b/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx index d3165c77a7..34a4972ce1 100644 --- a/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx +++ b/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx @@ -10,6 +10,7 @@ import GeneralWithLoginLanding from "@app/components/shared/config/GeneralWithLo import PasswordSecurity from "@app/components/shared/config/configSections/PasswordSecurity"; import ApiKeys from "@app/components/shared/config/configSections/ApiKeys"; import McpSection from "@app/components/shared/config/configSections/McpSection"; +import HelpSection from "@app/components/shared/config/configSections/HelpSection"; import LegalSection from "@app/components/shared/config/configSections/LegalSection"; import { createCloudBillingSection, @@ -22,6 +23,8 @@ interface CreateSaasConfigNavSectionsOptions { isDev?: boolean; isAnonymous?: boolean; t: TFunction<"translation", undefined>; + /** Close the settings modal — the Help tours need it to start the tour. */ + onRequestClose?: () => void; } function ensurePreferencesSection( @@ -141,6 +144,37 @@ function appendMcpSection( ); } +function appendHelpSection( + sections: ConfigNavSection[], + t: TFunction<"translation", undefined>, + onRequestClose: () => void, +): ConfigNavSection[] { + const hasHelp = sections.some((section) => + section.items.some((item) => item.key === "help"), + ); + + if (hasHelp) { + return sections; + } + + return [ + ...sections, + { + title: t("settings.help.title", "Help"), + items: [ + { + key: "help" as const, + label: t("settings.help.label", "Tours"), + icon: "help-rounded", + component: ( + + ), + }, + ], + }, + ]; +} + // Legal links (privacy policy, terms, etc.). Shown to anonymous users too — // it's public information. function appendLegalSection( @@ -174,7 +208,12 @@ function appendLegalSection( export function createSaasConfigNavSections( Overview: OverviewComponent, onLogoutClick: () => void, - { isDev = false, isAnonymous = false, t }: CreateSaasConfigNavSectionsOptions, + { + isDev = false, + isAnonymous = false, + t, + onRequestClose = () => {}, + }: CreateSaasConfigNavSectionsOptions, ): ConfigNavSection[] { const baseSections = createCoreConfigNavSections(false, false, false); @@ -230,6 +269,7 @@ export function createSaasConfigNavSections( sections = appendBillingSection(sections, t); } + sections = appendHelpSection(sections, t, onRequestClose); sections = appendLegalSection(sections, t); if (isDev) { diff --git a/frontend/editor/src/saas/utils/appSettings.ts b/frontend/editor/src/saas/utils/appSettings.ts index 83f41940a4..989e671a0a 100644 --- a/frontend/editor/src/saas/utils/appSettings.ts +++ b/frontend/editor/src/saas/utils/appSettings.ts @@ -3,14 +3,25 @@ import type { NavKey } from "@app/components/shared/config/types"; +let pendingNavKey: NavKey | null = null; + +/** Read and clear the section a caller asked to open the modal on (if any). */ +export function consumePendingSettingsNav(): NavKey | null { + const key = pendingNavKey; + pendingNavKey = null; + return key; +} + export function openAppSettings(targetKey?: NavKey, notice?: string) { try { const detail: { key?: NavKey; notice?: string } = {}; if (targetKey) detail.key = targetKey; if (notice) detail.notice = notice; + // Stash the target so a not-yet-mounted (lazy) modal starts on it. + if (targetKey) pendingNavKey = targetKey; // Ask the UI to open the App Config modal window.dispatchEvent(new CustomEvent("appConfig:open", { detail })); - // If a specific section is requested, navigate there once modal mounts + // Navigate there too — handles the case where the modal is already mounted. if (targetKey) { window.dispatchEvent( new CustomEvent("appConfig:navigate", { detail: { key: targetKey } }),