Feature/onboarding updates for policies and portal (#6926)

This commit is contained in:
EthanHealy01
2026-07-11 12:41:48 +01:00
committed by GitHub
parent 142544c9af
commit d23318cfa6
44 changed files with 2308 additions and 932 deletions
@@ -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 <strong>Active Files</strong> view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process."
activeFiles = "The <strong>Active Files</strong> view shows all of the PDFs you have loaded into the tool."
allTools = "This is the <strong>Tools</strong> 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 <strong>Files</strong> 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 <strong>Pin</strong> button if you'd rather your fi
results = "After the tool has finished running, the <strong>Review</strong> step will show a preview of the results in this panel, and allow you to undo the operation or download the file. "
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 <strong>Crop</strong> tool to demonstrate how to use one of the tools."
stepOf = "Step {{current}} of {{total}}"
toolInterface = "This is the <strong>Crop</strong> tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet."
workbench = "This is the <strong>Workbench</strong> - the main area where you view and edit your PDFs."
wrapUp = "You're all set! You can replay this tour anytime - just open <strong>Settings</strong> and find it here in the <strong>Tours</strong> 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 <strong>Policies</strong> — automated rules that classify, secure, and process every document as it arrives. Set them up and monitor runs in the <strong>Processor</strong>."
cta = "Check out the Processor"
title = "Check out the Stirling Processor"
[onboarding.saas.freeEditor]
freeLine = "The editor is now <free>completely free</free>."
premium = "We've added loads of new features, including <strong>Policies</strong> and <strong>Agent Chat</strong>."
@@ -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 (
<div className={styles.heroIconsContainer}>
<div className={styles.iconWrapper}>
<img
src={`${BASE_PATH}/modern-logo/logo512.png`}
alt="Stirling icon"
className={styles.downloadIcon}
/>
</div>
</div>
);
}
if (slideDefinition.hero.type === "logo") {
return (
<img
src={`${BASE_PATH}/modern-logo/logo512.png`}
alt="Stirling logo"
className={styles.standaloneIcon}
/>
);
}
return (
<div className={styles.heroLogoCircle}>
{slideDefinition.hero.type === "bolt" && (
<BoltRoundedIcon sx={{ fontSize: 64, color: "#000000" }} />
)}
{slideDefinition.hero.type === "team" && (
<GroupAddRoundedIcon sx={{ fontSize: 56, color: "#000000" }} />
)}
</div>
const heroType = slideDefinition.hero.type;
const hero =
heroType === "dual-icon" || heroType === "logo" ? (
<ShellHero appIcon />
) : (
<ShellHero>
{heroType === "bolt" && <BoltRoundedIcon sx={{ fontSize: 30 }} />}
{heroType === "team" && <GroupAddRoundedIcon sx={{ fontSize: 30 }} />}
</ShellHero>
);
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 (
<Modal
<OnboardingSlideShell
opened={props.opened}
hero={hero}
slideKey={currentSlide.key}
title={currentSlide.title}
body={currentSlide.body}
stepIndex={currentStep}
stepCount={totalSteps}
buttons={buttons}
onAction={(action) => 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",
},
}}
>
<Stack
gap={0}
className={styles.modalContent}
style={{
height: "100%",
maxHeight: "90vh",
display: "flex",
flexDirection: "column",
}}
>
<div className={styles.heroWrapper} style={{ flexShrink: 0 }}>
<AnimatedSlideBackground
gradientStops={currentSlide.background.gradientStops}
circles={currentSlide.background.circles}
isActive
slideKey={currentSlide.key}
/>
<div className={styles.heroLogo} key={`logo-${currentSlide.key}`}>
{renderHero()}
</div>
</div>
<div
className={styles.modalBody}
style={{
flex: 1,
overflowY: "auto",
overflowX: "hidden",
WebkitOverflowScrolling: "touch",
}}
>
<Stack gap={16}>
<div
key={`title-${currentSlide.key}`}
className={`${styles.title} ${styles.titleText}`}
>
{currentSlide.title}
</div>
<div className={styles.bodyText}>
<div
key={`body-${currentSlide.key}`}
className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}
>
{currentSlide.body}
</div>
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
</div>
<OnboardingStepper
totalSteps={totalSteps}
activeStep={currentStep}
/>
<div className={styles.buttonContainer}>
{renderButtons({
slideDefinition,
flowState,
onAction: handleButtonAction,
t,
})}
</div>
</Stack>
</div>
</Stack>
</Modal>
/>
);
}
@@ -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<SlideId, SaasFlowInputs>[] = [
{ 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);
}
@@ -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<string, never>;
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<HeroType>;
export interface ButtonDefinition {
key: string;
type: "button" | "icon";
label?: string;
icon?: "chevron-left";
variant?: "primary" | "secondary" | "default";
group: "left" | "right";
action: ButtonAction;
disabledWhen?: (state: FlowState) => boolean;
}
export type ButtonDefinition = ButtonDefinitionBase<ButtonAction, FlowState>;
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<SlideId, SlideDefinition> = {
DesktopInstallSlide({ osLabel, osUrl, osOptions, onDownloadUrlChange }),
hero: { type: "dual-icon" },
buttons: [
{ ...BACK_BUTTON, key: "desktop-back" },
{
key: "desktop-skip",
type: "button",
@@ -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);
@@ -198,6 +198,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
}}
>
<div
data-tour="files-modal"
style={{
position: "relative",
height: modalHeight,
@@ -330,20 +330,199 @@
.welcomeTitleContainer {
display: flex;
align-items: center;
justify-content: center;
justify-content: flex-start;
gap: 12px;
}
.v2Badge {
background: #dbefff;
color: #2a4bff;
padding: 4px 12px;
padding: 3px 9px;
border-radius: 6px;
font-size: 14px;
font-size: 12px;
font-weight: 600;
}
/* Icon styles */
.heroIcon {
color: #000000;
color: var(--onboarding-title, #0f172a);
}
/* ===================================================================== */
/* Redesigned modal card — branded header, progress, hero panel, footer. */
/* ===================================================================== */
.card {
background: var(--bg-surface);
display: flex;
flex-direction: column;
}
/* ---- header: brand (left) + step pill / close (right) ---- */
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px 12px;
}
.brand {
display: flex;
align-items: center;
gap: 9px;
}
.brandLogo {
width: 26px;
height: 26px;
border-radius: 7px;
object-fit: contain;
}
.wordmark {
font-size: 17px;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--onboarding-title, #0f172a);
}
.headerRight {
display: flex;
align-items: center;
gap: 8px;
}
.stepPill {
font-size: 12px;
font-weight: 600;
color: var(--onboarding-body, #64748b);
background: var(--bg-muted, #f1f5f9);
padding: 5px 11px;
border-radius: 9999px;
white-space: nowrap;
}
/* ---- segmented progress bar ---- */
.progressTrack {
display: flex;
gap: 6px;
padding: 0 20px 12px;
}
.progressSeg {
flex: 1;
height: 5px;
border-radius: 9999px;
background: var(--onboarding-step-inactive, #e2e8f0);
transition: background 0.2s ease;
}
.progressSegDone {
background: var(--color-blue, #3b82f6);
}
.divider {
height: 1px;
background: var(--border-subtle, rgba(15, 23, 42, 0.08));
}
/* ---- content ---- */
.content {
padding: 18px 20px 20px;
display: flex;
flex-direction: column;
gap: 14px;
overflow-y: auto;
max-height: calc(90vh - 120px);
}
/* ---- inset hero panel ---- */
.heroPanel {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 128px;
border-radius: 14px;
overflow: hidden;
background: linear-gradient(155deg, #eef1fb 0%, #f6f4fc 55%, #fbf4f7 100%);
}
.heroArt {
position: relative;
z-index: 1;
animation: heroLogoScale 0.25s ease forwards;
}
.heroAppIcon {
width: 62px;
height: 62px;
border-radius: 15px;
object-fit: contain;
filter: drop-shadow(0 10px 20px rgba(15, 23, 42, 0.16));
}
.heroTile {
width: 62px;
height: 62px;
border-radius: 15px;
background: #ffffff;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.14);
}
/* ---- title + body (left-aligned, clean) ---- */
.titleNew {
font-size: 19px;
font-weight: 600;
letter-spacing: -0.01em;
line-height: 1.3;
color: var(--onboarding-title, #0f172a);
text-align: left;
}
.bodyNew {
font-size: 14px;
line-height: 1.5;
color: var(--onboarding-body, #475569);
text-align: left;
}
/* ---- footer actions ---- */
.footer {
margin-top: 4px;
}
.footerGroup {
display: flex;
align-items: center;
gap: 8px;
}
.footerEnd {
display: flex;
justify-content: flex-end;
}
.footerBetween {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
/* ---- dark theme surface tuning ---- */
:global([data-mantine-color-scheme="dark"]) .heroPanel {
background: linear-gradient(155deg, #1a2236 0%, #171e30 55%, #201a28 100%);
}
:global([data-mantine-color-scheme="dark"]) .heroTile {
background: #0f1626;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.4);
}
:global([data-mantine-color-scheme="dark"]) .divider {
background: rgba(255, 255, 255, 0.08);
}
@@ -28,11 +28,13 @@ export function SlideButtons({
onAction,
}: SlideButtonsProps) {
const { t } = useTranslation();
const leftButtons = slideDefinition.buttons.filter(
(btn) => 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}
</Button>
);
};
if (leftButtons.length === 0) {
return <Group justify="flex-end">{rightButtons.map(renderButton)}</Group>;
}
const actions = <Group gap={8}>{actionButtons.map(renderButton)}</Group>;
if (rightButtons.length === 0) {
return <Group justify="flex-start">{leftButtons.map(renderButton)}</Group>;
if (backButtons.length === 0) {
return <Group justify="flex-end">{actions}</Group>;
}
return (
<Group justify="space-between">
<Group gap={12}>{leftButtons.map(renderButton)}</Group>
<Group gap={12}>{rightButtons.map(renderButton)}</Group>
<Group justify="space-between" wrap="nowrap">
<Group gap={8}>{backButtons.map(renderButton)}</Group>
{actions}
</Group>
);
}
@@ -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<StepType[]>(
() =>
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<StepType[]>(() => {
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 (
<OnboardingModalSlide
slideDefinition={slideDefinition}
slideContent={slideContent}
<StaticOnboardingSlide
key="analytics-choice"
slideId="analytics-choice"
runtimeState={runtimeState}
modalSlideCount={1}
currentModalSlideIndex={0}
params={{ analyticsError, analyticsLoading }}
onSkip={() => {}} // 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 (
<OnboardingModalSlide
slideDefinition={baseSlideDefinition}
slideContent={slideContent}
<StaticOnboardingSlide
key="first-login"
slideId="first-login"
runtimeState={runtimeState}
modalSlideCount={1}
currentModalSlideIndex={0}
params={{
firstLoginUsername: runtimeState.firstLoginUsername,
onPasswordChanged: handlePasswordChanged,
usingDefaultCredentials: runtimeState.usingDefaultCredentials,
}}
onSkip={() => {}}
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 (
<OnboardingModalSlide
slideDefinition={baseSlideDefinition}
slideContent={slideContent}
<StaticOnboardingSlide
key="mfa-setup"
slideId="mfa-setup"
runtimeState={runtimeState}
modalSlideCount={1}
currentModalSlideIndex={0}
params={{ onMfaSetupComplete: handleMfaSetupComplete }}
onSkip={() => {}}
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 (
<OnboardingModalSlide
slideDefinition={slideDefinition}
slideContent={slideContent}
<StaticOnboardingSlide
key="server-license"
slideId="server-license"
// Remove back button for the external license notice.
transformButtons={(buttons) =>
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") {
@@ -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<SlideFactoryParams>;
runtime?: Partial<OnboardingRuntimeState>;
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 (
<OnboardingModalSlide
slideDefinition={definition}
slideContent={slideContent}
runtimeState={runtimeState}
modalSlideCount={stepCount}
currentModalSlideIndex={stepIndex}
onSkip={() => {}}
onAction={() => {}}
allowDismiss={allowDismiss}
/>
);
}
const meta: Meta<typeof SlideStage> = {
title: "Onboarding/Modal Slides",
component: SlideStage,
parameters: { layout: "fullscreen" },
};
export default meta;
type Story = StoryObj<typeof SlideStage>;
/** "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" } };
@@ -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<string, string> = {
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 (
<div className={styles.heroIconsContainer}>
<div className={styles.iconWrapper}>
<img
src={`${BASE_PATH}/modern-logo/logo512.png`}
alt="Stirling icon"
className={styles.downloadIcon}
/>
</div>
</div>
);
}
const { licenseNotice } = runtimeState;
const flowState = { selectedRole: runtimeState.selectedRole };
return (
<div className={styles.heroLogoCircle}>
{slideDefinition.hero.type === "rocket" && (
<LocalIcon
icon="rocket-launch"
width={64}
height={64}
className={styles.heroIcon}
/>
)}
{slideDefinition.hero.type === "shield" && (
<LocalIcon
icon="verified-user-outline"
width={64}
height={64}
className={styles.heroIcon}
/>
)}
{slideDefinition.hero.type === "lock" && (
<LocalIcon
icon="lock-outline"
width={64}
height={64}
className={styles.heroIcon}
/>
)}
{slideDefinition.hero.type === "analytics" && (
<LocalIcon
icon="analytics"
width={64}
height={64}
className={styles.heroIcon}
/>
)}
{slideDefinition.hero.type === "diamond" && (
<DiamondOutlinedIcon sx={{ fontSize: 64, color: "#000000" }} />
)}
{slideDefinition.hero.type === "logo" && (
<img
src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`}
alt="Stirling logo"
/>
)}
</div>
const heroType = slideDefinition.hero.type;
const hero =
heroType === "dual-icon" || heroType === "logo" ? (
<ShellHero appIcon />
) : (
<ShellHero>
{heroType === "diamond" ? (
<DiamondOutlinedIcon sx={{ fontSize: 30 }} />
) : HERO_ICON[heroType] ? (
<LocalIcon icon={HERO_ICON[heroType]} width={30} height={30} />
) : null}
</ShellHero>
);
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 (
<Modal
opened={true}
<OnboardingSlideShell
hero={hero}
slideKey={slideContent.key}
title={slideContent.title}
body={slideContent.body}
stepIndex={currentModalSlideIndex}
stepCount={modalSlideCount}
buttons={buttons}
onAction={(action) => 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",
},
}}
>
<Stack gap={0} className={styles.modalContent}>
<div className={styles.heroWrapper}>
<AnimatedSlideBackground
gradientStops={slideContent.background.gradientStops}
circles={slideContent.background.circles}
isActive
slideKey={slideContent.key}
/>
{allowDismiss && (
<ActionIcon
onClick={onSkip}
variant="tertiary"
size="lg"
aria-label={t("common.close", "Close")}
style={{
position: "absolute",
top: 16,
right: 16,
backgroundColor: "rgba(255, 255, 255, 0.2)",
color: "white",
backdropFilter: "blur(4px)",
zIndex: 10,
}}
>
<LocalIcon
icon="close-rounded"
width="1.25rem"
height="1.25rem"
/>
</ActionIcon>
)}
<div className={styles.heroLogo} key={`logo-${slideContent.key}`}>
{renderHero()}
</div>
</div>
<div
className={styles.modalBody}
style={{ overflowY: "auto", maxHeight: "calc(90vh - 220px)" }}
>
<Stack gap={16}>
<div
key={`title-${slideContent.key}`}
className={`${styles.title} ${styles.titleText}`}
>
{slideContent.title}
</div>
<div className={styles.bodyText}>
<div
key={`body-${slideContent.key}`}
className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}
>
{slideContent.body}
</div>
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
</div>
{modalSlideCount > 1 && (
<OnboardingStepper
totalSteps={modalSlideCount}
activeStep={currentModalSlideIndex}
/>
)}
<div className={styles.buttonContainer}>
<SlideButtons
slideDefinition={slideDefinition}
licenseNotice={runtimeState.licenseNotice}
flowState={{ selectedRole: runtimeState.selectedRole }}
onAction={onAction}
/>
</div>
</Stack>
</div>
</Stack>
</Modal>
allowDismiss={allowDismiss}
/>
);
}
@@ -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 (
<img src={stirlingMark} alt="Stirling" className={styles.heroAppIcon} />
);
}
return <div className={styles.heroTile}>{children}</div>;
}
/**
* 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) => (
<Button
key={button.key}
onClick={() => onAction(button.action)}
disabled={button.disabled}
variant={button.primary ? "primary" : "quiet"}
accent={button.accent ?? (button.primary ? "default" : "neutral")}
>
{button.label}
</Button>
);
const actions = (
<div className={styles.footerGroup}>{actionButtons.map(renderButton)}</div>
);
return (
<Modal
opened={opened}
onClose={onClose}
closeOnClickOutside={false}
closeOnEscape={allowDismiss}
centered
size="lg"
radius={20}
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",
},
}}
>
<div className={styles.card}>
<header className={styles.header}>
<div className={styles.brand}>
<img
src={stirlingMark}
alt=""
aria-hidden="true"
className={styles.brandLogo}
/>
<span className={styles.wordmark}>Stirling</span>
</div>
<div className={styles.headerRight}>
{showProgress && (
<span className={styles.stepPill}>
{t("onboarding.stepOf", "Step {{current}} of {{total}}", {
current: stepIndex + 1,
total: stepCount,
})}
</span>
)}
{allowDismiss && (
<ActionIcon
onClick={onClose}
variant="tertiary"
accent="neutral"
size="md"
aria-label={t("common.close", "Close")}
>
<LocalIcon
icon="close-rounded"
width="1.1rem"
height="1.1rem"
/>
</ActionIcon>
)}
</div>
</header>
{showProgress && (
<div
className={styles.progressTrack}
role="progressbar"
aria-valuenow={stepIndex + 1}
aria-valuemin={1}
aria-valuemax={stepCount}
>
{Array.from({ length: stepCount }, (_, index) => (
<span
key={index}
className={`${styles.progressSeg} ${
index <= stepIndex ? styles.progressSegDone : ""
}`}
/>
))}
</div>
)}
<div className={styles.divider} />
<div className={styles.content}>
<div className={styles.heroPanel}>
<div className={styles.heroArt} key={`hero-${slideKey}`}>
{hero}
</div>
</div>
<div key={`title-${slideKey}`} className={styles.titleNew}>
{title}
</div>
<div key={`body-${slideKey}`} className={styles.bodyNew}>
{body}
<style>{`.${styles.bodyNew} strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
</div>
<div className={styles.footer}>
{backButtons.length === 0 ? (
<div className={styles.footerEnd}>{actions}</div>
) : (
<div className={styles.footerBetween}>
<div className={styles.footerGroup}>
{backButtons.map((button) => (
<ActionIcon
key={button.key}
onClick={() => onAction(button.action)}
variant="tertiary"
accent="neutral"
disabled={button.disabled}
aria-label={t("onboarding.buttons.back", "Back")}
>
<ChevronLeftIcon fontSize="small" />
</ActionIcon>
))}
</div>
{actions}
</div>
)}
</div>
</div>
</div>
</Modal>
);
}
@@ -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 <strong> 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>.*?<\/strong>)/g).map((part, index) => {
const match = part.match(/^<strong>(.*?)<\/strong>$/);
if (match) return <strong key={index}>{match[1]}</strong>;
return <React.Fragment key={index}>{part}</React.Fragment>;
});
}
/**
* 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({
</ActionIcon>
),
Content: ({ content }: { content: string }) => (
<div
style={{ paddingRight: "16px" }}
dangerouslySetInnerHTML={{ __html: content }}
/>
<div style={{ paddingRight: "16px" }}>
{renderTourContent(content)}
</div>
),
}}
>
@@ -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<SlideFactoryParams>;
/** 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 (
<OnboardingModalSlide
slideDefinition={definition}
slideContent={slideContent}
runtimeState={runtimeState}
modalSlideCount={1}
currentModalSlideIndex={0}
onSkip={onSkip}
onAction={onAction}
allowDismiss={allowDismiss}
/>
);
}
@@ -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 <strong>Admin Tour</strong>! 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 <strong>Settings</strong> 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 <strong>Settings Panel</strong>. 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 <strong>Teams</strong> and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself.",
position: "right",
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: <strong>System Settings</strong> let you change the app name and languages, <strong>Features</strong> allows server certificate management, and <strong>Endpoints</strong> lets you enable or disable specific tools for your users.",
position: "right",
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 <strong>external database hookups</strong> 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 <strong>Connections</strong> section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications.",
position: "right",
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 <strong>Auditing</strong> to track system activity and <strong>Usage Analytics</strong> 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 <strong>Settings</strong> and find it here in the <strong>Tours</strong> 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 <strong>Admin Tour</strong>! Let's explore the powerful enterprise features and settings available to system administrators.",
),
position: "right",
padding: 10,
action: () => {
saveAdminState();
},
},
[AdminTourStep.CONFIG_BUTTON]: {
selector: '[data-tour="config-button"]',
content: t(
"adminOnboarding.configButton",
"Open <strong>Settings</strong> 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 <strong>Settings Panel</strong>. Admin settings are organised by category for easy navigation.",
),
position: "right",
padding: 0,
action: () => {
removeAllGlows();
},
},
[AdminTourStep.TEAMS_AND_USERS]: {
selector: '[data-tour="admin-people-nav"]',
highlightedSelectors: [
'[data-tour="admin-people-nav"]',
'[data-tour="admin-teams-nav"]',
'[data-tour="settings-content-area"]',
],
content: t(
"adminOnboarding.teamsAndUsers",
"Manage <strong>Teams</strong> and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself.",
),
position: "right",
padding: 10,
action: () => {
removeAllGlows();
navigateToSection("people");
setTimeout(() => {
addGlowToElements([
'[data-tour="admin-people-nav"]',
'[data-tour="admin-teams-nav"]',
'[data-tour="settings-content-area"]',
]);
}, 100);
},
},
[AdminTourStep.SYSTEM_CUSTOMIZATION]: {
selector: '[data-tour="admin-adminGeneral-nav"]',
highlightedSelectors: [
'[data-tour="admin-adminGeneral-nav"]',
'[data-tour="admin-adminFeatures-nav"]',
'[data-tour="admin-adminEndpoints-nav"]',
'[data-tour="settings-content-area"]',
],
content: t(
"adminOnboarding.systemCustomization",
"We have extensive ways to customise the UI: <strong>System Settings</strong> let you change the app name and languages, <strong>Features</strong> allows server certificate management, and <strong>Endpoints</strong> lets you enable or disable specific tools for your users.",
),
position: "right",
padding: 10,
action: () => {
removeAllGlows();
navigateToSection("adminGeneral");
setTimeout(() => {
addGlowToElements([
'[data-tour="admin-adminGeneral-nav"]',
'[data-tour="admin-adminFeatures-nav"]',
'[data-tour="admin-adminEndpoints-nav"]',
'[data-tour="settings-content-area"]',
]);
}, 100);
},
},
[AdminTourStep.DATABASE_SECTION]: {
selector: '[data-tour="admin-adminDatabase-nav"]',
highlightedSelectors: [
'[data-tour="admin-adminDatabase-nav"]',
'[data-tour="settings-content-area"]',
],
content: t(
"adminOnboarding.databaseSection",
"For advanced production environments, we have settings to allow <strong>external database hookups</strong> so you can integrate with your existing infrastructure.",
),
position: "right",
padding: 10,
action: () => {
removeAllGlows();
navigateToSection("adminDatabase");
setTimeout(() => {
addGlowToElements([
'[data-tour="admin-adminDatabase-nav"]',
'[data-tour="settings-content-area"]',
]);
}, 100);
},
},
[AdminTourStep.CONNECTIONS_SECTION]: {
selector: '[data-tour="admin-adminConnections-nav"]',
highlightedSelectors: [
'[data-tour="admin-adminConnections-nav"]',
'[data-tour="settings-content-area"]',
],
content: t(
"adminOnboarding.connectionsSection",
"The <strong>Connections</strong> section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications.",
),
position: "right",
padding: 10,
action: () => {
removeAllGlows();
navigateToSection("adminConnections");
setTimeout(() => {
addGlowToElements([
'[data-tour="admin-adminConnections-nav"]',
'[data-tour="settings-content-area"]',
]);
}, 100);
},
actionAfter: async () => {
await scrollNavToSection("adminAudit");
},
},
[AdminTourStep.ADMIN_TOOLS]: {
selector: '[data-tour="admin-adminAudit-nav"]',
highlightedSelectors: [
'[data-tour="admin-adminAudit-nav"]',
'[data-tour="admin-adminUsage-nav"]',
'[data-tour="settings-content-area"]',
],
content: t(
"adminOnboarding.adminTools",
"Finally, we have advanced administration tools like <strong>Auditing</strong> to track system activity and <strong>Usage Analytics</strong> to monitor how your users interact with the platform.",
),
position: "right",
padding: 10,
action: () => {
removeAllGlows();
navigateToSection("adminAudit");
setTimeout(() => {
addGlowToElements([
'[data-tour="admin-adminAudit-nav"]',
'[data-tour="admin-adminUsage-nav"]',
'[data-tour="settings-content-area"]',
]);
}, 100);
},
},
[AdminTourStep.WRAP_UP]: {
selector: '[data-tour="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 <strong>Settings</strong> and find it here in the <strong>Tours</strong> 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<AdminTourStep, StepType>,
);
}
@@ -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<HeroType>;
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<ButtonAction, FlowState>;
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<SlideId, SlideDefinition> = {
"first-login": {
@@ -129,6 +121,36 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
},
],
},
"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 }) =>
@@ -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<Action extends string, State> {
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<Hero extends string> {
type: Hero;
}
export interface SlideDefinition<
Id extends string,
Action extends string,
State,
Hero extends string,
Params,
> {
id: Id;
createSlide: (params: Params) => SlideConfig;
hero: HeroDefinition<Hero>;
buttons: ButtonDefinition<Action, State>[];
}
/** A single entry in a conditionally-resolved flow. */
export interface FlowStep<Id extends string, Ctx> {
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<Id extends string, Ctx>(
steps: FlowStep<Id, Ctx>[],
ctx: Ctx,
): Id[] {
return steps.filter((step) => step.when(ctx)).map((step) => step.id);
}
@@ -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",
@@ -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<T>(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<string[]>(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 {
@@ -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],
@@ -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<string, string> = {
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 (
<svg
width={16}
height={16}
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d={d} />
</svg>
);
}
export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
osLabel,
osUrl,
@@ -51,7 +86,7 @@ export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
// If only one option or no options, don't show dropdown
if (osOptions.length <= 1) {
return <div style={{ textAlign: "center", width: "100%" }}>{title}</div>;
return <div style={{ width: "100%" }}>{title}</div>;
}
return (
@@ -59,13 +94,12 @@ export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "0.5rem",
width: "100%",
}}
>
<span style={{ whiteSpace: "nowrap" }}>{title}</span>
<Menu position="bottom" offset={5} zIndex={10000}>
<Menu position="bottom-start" offset={5} zIndex={10000}>
<Menu.Target>
<ActionIcon
variant="tertiary"
@@ -87,17 +121,18 @@ export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
<Menu.Dropdown>
{osOptions.map((option) => {
const isSelected = option.url === selectedOsUrl;
const iconKey = osIconKey(option);
return (
<Menu.Item
key={option.url}
onClick={() => handleOsSelect(option)}
leftSection={iconKey ? <OsIcon os={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}
@@ -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 = () => (
<span>
<Trans
i18nKey="onboarding.processorIntro.body"
components={{ strong: <strong /> }}
defaults="Stirling now runs <strong>Policies</strong> — automated rules that classify, secure, and process every document as it arrives. Set them up and monitor runs in the <strong>Processor</strong>."
/>
</span>
);
export default function ProcessorIntroSlide(): SlideConfig {
return {
key: "processor-intro",
title: (
<Trans
i18nKey="onboarding.processorIntro.title"
defaults="Check out the Stirling Processor"
/>
),
body: <ProcessorIntroBody />,
background: {
gradientStops: ["#2563EB", "#7C3AED"],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
}
@@ -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<typeof useTourOrchestration>;
type AdminTourActions = ReturnType<typeof useAdminTourOrchestration>;
/**
* 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<string, TourDefinition> = {
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);
}
@@ -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 <strong>Active Files</strong> view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process.",
"The <strong>Active Files</strong> 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 <strong>Settings</strong> and find it here in the <strong>Tours</strong> 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);
},
},
};
@@ -266,7 +266,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
styles={{ content: { overflowY: "hidden", overscrollBehavior: "none" } }}
removeScrollProps={{ shards: [COOKIE_CONSENT_SCROLL_SHARD] }}
>
<div className="modal-container">
<div className="modal-container" data-tour="settings-modal">
{/* Left navigation */}
<div
className={`modal-nav ${isMobile ? "mobile" : ""}`}
@@ -56,6 +56,7 @@ import BulkUploadToServerModal from "@app/components/shared/BulkUploadToServerMo
import { getFileOrigin } from "@app/components/filesPage/fileOrigin";
import { VersionHistoryModal } from "@app/components/filesPage/VersionHistoryModal";
import { DeleteFilesDialog } from "@app/components/filesPage/DeleteFilesDialog";
import { SidebarChecklistSlot } from "@app/components/shared/SidebarChecklistSlot";
import {
deleteServerFile,
type DeleteScope,
@@ -1321,6 +1322,9 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
onConfirm={handleConfirmSidebarDelete}
/>
{/* Getting-started checklist, floating above the footer (SaaS only). */}
<SidebarChecklistSlot collapsed={collapsed} />
{/* Bottom bar: user name + settings */}
<Tooltip
label={
@@ -5,7 +5,7 @@
/* ── Hero text ───────────────────────────────────────────── */
.landing-title {
margin: 1.75rem 0 0.5rem;
margin: 1.75rem 0 1.5rem;
text-align: center;
font-size: 2rem;
font-weight: 700;
@@ -82,13 +82,6 @@ const LandingPage = () => {
<h3 className="landing-title">
{t("landing.workbenchEmptyStateHero", "Drop a PDF anywhere")}
</h3>
<p className="landing-subtitle">
{t(
"landing.heroSubtitle",
"Drop in or add an existing PDF to get started.",
)}
</p>
<LandingActions
fileInputRef={fileInputRef}
onUploadClick={() => void handleNativeUploadClick()}
@@ -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...
@@ -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;
}
+4 -1
View File
@@ -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;
+15 -2
View File
@@ -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() {
<FileManager selectedTool={selectedTool} />
<AppConfigModal
opened={configModalOpen}
onClose={() => setConfigModalOpen(false)}
onClose={handleCloseConfig}
/>
</div>
) : (
@@ -526,7 +539,7 @@ export default function HomePage() {
<FileManager selectedTool={selectedTool} />
<AppConfigModal
opened={configModalOpen}
onClose={() => setConfigModalOpen(false)}
onClose={handleCloseConfig}
/>
</Group>
)}
@@ -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();
});
});
});
@@ -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}`);
@@ -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 (
<SaasOnboardingModal
opened={showModal}
onClose={handleClose}
hideDesktopInstall
/>
);
export function DesktopSaasOnboardingBootstrap(
_props: DesktopSaasOnboardingBootstrapProps,
) {
return null;
}
@@ -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 (
<AuthShell
rightPanel={
@@ -43,7 +37,11 @@ export function LoginScreen() {
/>
}
>
<SpringLoginForm state={login} logoSrc={loginHeader} />
<SpringLoginForm
state={login}
logoSrc={loginHeader}
logoDarkSrc={withBasePath("/modern-logo/LoginDarkModeHeader.svg")}
/>
</AuthShell>
);
}
@@ -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 ? (
<SaasOnboardingModal opened={showModal} onClose={handleClose} />
) : null;
return null;
}
@@ -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;
}
@@ -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<string[]>(() => 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 (
<>
<div className={styles.card} data-testid="onboarding-checklist">
<div
className={styles.header}
role="button"
tabIndex={0}
onClick={() => setExpanded((v) => !v)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setExpanded((v) => !v);
}
}}
>
<span className={styles.titleGroup}>
<img
src={stirlingMark}
alt=""
aria-hidden="true"
className={styles.logo}
/>
<span className={styles.title}>
{t("onboarding.checklist.title", "Set up Stirling PDF")}
</span>
</span>
<span className={styles.headerRight}>
<span className={styles.progressCount}>
{doneCount} / {total}
</span>
{expanded ? (
<ExpandLessIcon className={styles.chevron} />
) : (
<ExpandMoreIcon className={styles.chevron} />
)}
<span
className={styles.closeButton}
role="button"
tabIndex={0}
aria-label={t("onboarding.checklist.dismiss", "Dismiss")}
onClick={(e) => {
e.stopPropagation();
handleDismiss();
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
handleDismiss();
}
}}
>
{allDone ? (
<CheckCircleIcon className={styles.completeIcon} />
) : (
<CloseIcon className={styles.closeIcon} />
)}
</span>
</span>
</div>
<div className={styles.progressTrack}>
<div
className={styles.progressFill}
style={{ width: `${total ? (doneCount / total) * 100 : 0}%` }}
/>
</div>
{expanded && (
<div className={styles.items}>
{items.map((item) => {
const isDone = done.includes(item.id);
return (
<div
key={item.id}
className={styles.item}
role="button"
tabIndex={0}
onClick={item.onClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
item.onClick();
}
}}
>
<span className={styles.itemIcon}>
{isDone ? (
<CheckCircleIcon className={styles.checkDone} />
) : (
<RadioButtonUncheckedIcon className={styles.checkTodo} />
)}
</span>
<span className={styles.itemText}>
<span
className={`${styles.itemTitle} ${
isDone ? styles.itemTitleDone : ""
}`}
>
{t(item.titleKey, item.titleFallback)}
</span>
<span
className={`${styles.itemDescription} ${
isDone ? styles.itemDescriptionDone : ""
}`}
>
{t(item.descriptionKey, item.descriptionFallback)}
</span>
</span>
</div>
);
})}
</div>
)}
</div>
<SaasOnboardingModal
opened={downloadOpen}
onClose={handleDownloadClose}
slideIds={["desktop-install"]}
/>
{analyticsOpen && (
<StaticOnboardingSlide
key="analytics-choice"
slideId="analytics-choice"
runtimeState={DEFAULT_RUNTIME_STATE}
allowDismiss
onSkip={closeAnalytics}
onAction={handleAnalyticsAction}
/>
)}
</>
);
}
@@ -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<string>("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<StepType[]>(
() =>
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 (
<ReactourTour
isOpen={isOpen}
tourSteps={tourSteps}
tourType={tourType}
isRTL={isRTL}
t={t}
onAdvance={handleAdvance}
onClose={handleClose}
/>
);
}
@@ -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<AppConfigModalProps> = ({
const [confirmOpen, setConfirmOpen] = useState(false);
const [active, setActive] = useState<NavKey>("overview");
const [notice, setNotice] = useState<string | null>(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<AppConfigModalProps> = ({
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<AppConfigModalProps> = ({
closeOnEscape={!overlayActive}
closeOnClickOutside={!overlayActive}
>
<div className="modal-container">
<div className="modal-container" data-tour="settings-modal">
{/* Left navigation */}
<div
className={`modal-nav ${isMobile ? "mobile" : ""}`}
@@ -220,6 +231,7 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({
return (
<div
key={item.key}
data-tour={`admin-${item.key}-nav`}
onClick={() => setActive(item.key)}
className={`modal-nav-item ${isMobile ? "mobile" : ""}`}
style={{
@@ -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 <OnboardingChecklist />;
}
@@ -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: (
<HelpSection isAdmin={false} onRequestClose={onRequestClose} />
),
},
],
},
];
}
// 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) {
+12 -1
View File
@@ -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 } }),