mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
Feature/onboarding updates for policies and portal (#6926)
This commit is contained in:
@@ -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,
|
||||
|
||||
+183
-4
@@ -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);
|
||||
}
|
||||
|
||||
+15
-17
@@ -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 {
|
||||
|
||||
+7
-22
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 } }),
|
||||
|
||||
Reference in New Issue
Block a user