mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 03:20:46 +02:00
Feature/onboarding slides (#4955)
# Description of Changes - Added onboarding slides/upgrade banner conditions for all the following cases - 'licensed' - 'no-login-user-under-limit-no-license' - 'no-login-admin-under-limit-no-license' - 'no-login-user-over-limit-no-license' - 'no-login-admin-over-limit-no-license' - 'login-user-under-limit-no-license' - 'login-admin-under-limit-no-license' - 'login-user-over-limit-no-license' - 'login-admin-over-limit-no-license'; --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: Anthony Stirling <[email protected]> Co-authored-by: Connor Yoh <[email protected]>
This commit is contained in:
co-authored by
Anthony Stirling
Connor Yoh
parent
80f2980755
commit
a8db2fda18
+277
@@ -0,0 +1,277 @@
|
||||
.heroWrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.heroLogo {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
animation: heroLogoEnter 0.25s ease forwards;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.heroLogoCircle {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 50%;
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.18);
|
||||
animation: heroLogoScale 0.25s ease forwards;
|
||||
}
|
||||
|
||||
.heroLogoCircle img {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
animation: heroLogoRotate 0.25s ease forwards;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.standaloneIcon {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
object-fit: contain;
|
||||
animation: heroLogoScale 0.25s ease forwards;
|
||||
}
|
||||
|
||||
.securitySlideContent {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.securityCard {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
background: var(--bg-surface, #ffffff);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.securityAlertRow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
color: var(--onboarding-body, #1f2937);
|
||||
}
|
||||
|
||||
.heroIconsContainer {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
animation: heroLogoEnter 0.25s ease forwards;
|
||||
position: relative;
|
||||
top: 1rem;
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
padding: 4px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.iconButton:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.iconButton:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.iconButton:hover {
|
||||
transform: scale(1.05);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.iconButton:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.iconButtonSelected {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.downloadIcon {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
object-fit: contain;
|
||||
animation: heroLogoScale 0.25s ease forwards;
|
||||
}
|
||||
|
||||
.iconLabel {
|
||||
font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
text-align: center;
|
||||
animation: heroLogoEnter 0.25s ease forwards;
|
||||
}
|
||||
|
||||
.title {
|
||||
text-align: center;
|
||||
opacity: 0;
|
||||
transform: translateX(24px);
|
||||
animation: bodySlideIn 0.25s ease forwards;
|
||||
}
|
||||
|
||||
.bodyCopy {
|
||||
opacity: 0;
|
||||
transform: translateX(24px);
|
||||
animation: bodySlideIn 0.25s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes heroLogoEnter {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes heroLogoScale {
|
||||
from {
|
||||
transform: scale(0.6);
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes heroLogoRotate {
|
||||
from {
|
||||
transform: rotate(-90deg) scale(0.9);
|
||||
}
|
||||
to {
|
||||
transform: rotate(0deg) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bodySlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(24px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Dev overlay styles */
|
||||
.devOverlay {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 10px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.devButton {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
color: #fff;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.devButtonActive {
|
||||
opacity: 1;
|
||||
border: 1px solid rgba(255, 255, 255, 0.9);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #1F2933;
|
||||
box-shadow: 0 0 8px rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
/* Modal content styles */
|
||||
.modalContent {
|
||||
background: var(--bg-surface);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.modalBody {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* Title styles */
|
||||
.titleText {
|
||||
font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 22px;
|
||||
color: var(--onboarding-title);
|
||||
}
|
||||
|
||||
/* Body text styles */
|
||||
.bodyText {
|
||||
font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
color: var(--onboarding-body);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.bodyCopyInner {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Button margin */
|
||||
.buttonContainer {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Welcome slide V2 badge */
|
||||
.welcomeTitleContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.v2Badge {
|
||||
background: #DBEFFF;
|
||||
color: #2A4BFF;
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Icon styles */
|
||||
.heroIcon {
|
||||
color: #000000;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { FLOW_SEQUENCES, type SlideId } from '@app/components/onboarding/onboardingFlowConfig';
|
||||
|
||||
export type FlowType = 'login-admin' | 'login-user' | 'no-login' | 'no-login-admin';
|
||||
|
||||
export interface FlowConfig {
|
||||
type: FlowType;
|
||||
ids: SlideId[];
|
||||
}
|
||||
|
||||
export function resolveFlow(enableLogin: boolean, isAdmin: boolean, selfReportedAdmin: boolean): FlowConfig {
|
||||
if (!enableLogin) {
|
||||
return selfReportedAdmin
|
||||
? {
|
||||
type: 'no-login-admin',
|
||||
ids: [...FLOW_SEQUENCES.noLoginBase, ...FLOW_SEQUENCES.noLoginAdmin],
|
||||
}
|
||||
: {
|
||||
type: 'no-login',
|
||||
ids: FLOW_SEQUENCES.noLoginBase,
|
||||
};
|
||||
}
|
||||
|
||||
return isAdmin
|
||||
? {
|
||||
type: 'login-admin',
|
||||
ids: FLOW_SEQUENCES.loginAdmin,
|
||||
}
|
||||
: {
|
||||
type: 'login-user',
|
||||
ids: FLOW_SEQUENCES.loginUser,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import React from 'react';
|
||||
import { Modal, Stack } from '@mantine/core';
|
||||
import DiamondOutlinedIcon from '@mui/icons-material/DiamondOutlined';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground';
|
||||
import OnboardingStepper from '@app/components/onboarding/OnboardingStepper';
|
||||
import { renderButtons } from '@app/components/onboarding/InitialOnboardingModal/renderButtons';
|
||||
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
|
||||
import type { InitialOnboardingModalProps } from '@app/components/onboarding/InitialOnboardingModal/types';
|
||||
import { useInitialOnboardingState } from '@app/components/onboarding/InitialOnboardingModal/useInitialOnboardingState';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
|
||||
|
||||
export default function InitialOnboardingModal(props: InitialOnboardingModalProps) {
|
||||
const flow = useInitialOnboardingState(props);
|
||||
|
||||
if (!flow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
state,
|
||||
totalSteps,
|
||||
currentSlide,
|
||||
slideDefinition,
|
||||
licenseNotice,
|
||||
flowState,
|
||||
closeAndMarkSeen,
|
||||
handleButtonAction,
|
||||
} = flow;
|
||||
|
||||
const renderHero = () => {
|
||||
if (slideDefinition.hero.type === 'dual-icon') {
|
||||
return (
|
||||
<div className={styles.heroIconsContainer}>
|
||||
<div className={styles.iconWrapper}>
|
||||
<img src={`${BASE_PATH}/branding/StirlingLogoLegacy.svg`} alt="Stirling icon" className={styles.downloadIcon} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.heroLogoCircle}>
|
||||
{slideDefinition.hero.type === 'rocket' && (
|
||||
<LocalIcon icon="rocket-launch" width={64} height={64} className={styles.heroIcon} />
|
||||
)}
|
||||
{slideDefinition.hero.type === 'shield' && (
|
||||
<LocalIcon icon="verified-user-outline" width={64} height={64} className={styles.heroIcon} />
|
||||
)}
|
||||
{slideDefinition.hero.type === 'diamond' && <DiamondOutlinedIcon sx={{ fontSize: 64, color: '#000000' }} />}
|
||||
{slideDefinition.hero.type === 'logo' && (
|
||||
<img src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`} alt="Stirling logo" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={props.opened}
|
||||
onClose={closeAndMarkSeen}
|
||||
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)' },
|
||||
}}
|
||||
>
|
||||
<Stack gap={0} className={styles.modalContent}>
|
||||
<div className={styles.heroWrapper}>
|
||||
<AnimatedSlideBackground
|
||||
gradientStops={currentSlide.background.gradientStops}
|
||||
circles={currentSlide.background.circles}
|
||||
isActive
|
||||
slideKey={currentSlide.key}
|
||||
/>
|
||||
<div className={styles.heroLogo} key={`logo-${currentSlide.key}`}>
|
||||
{renderHero()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.modalBody}>
|
||||
<Stack gap={16}>
|
||||
<div
|
||||
key={`title-${currentSlide.key}`}
|
||||
className={`${styles.title} ${styles.titleText}`}
|
||||
>
|
||||
{currentSlide.title}
|
||||
</div>
|
||||
|
||||
<div className={styles.bodyText}>
|
||||
<div key={`body-${currentSlide.key}`} className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
|
||||
{currentSlide.body}
|
||||
</div>
|
||||
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
|
||||
</div>
|
||||
|
||||
<OnboardingStepper totalSteps={totalSteps} activeStep={state.step} />
|
||||
|
||||
<div className={styles.buttonContainer}>
|
||||
{renderButtons({
|
||||
slideDefinition,
|
||||
licenseNotice,
|
||||
flowState,
|
||||
onAction: handleButtonAction,
|
||||
})}
|
||||
</div>
|
||||
</Stack>
|
||||
</div>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import React from 'react';
|
||||
import { Button, Group, ActionIcon } from '@mantine/core';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ButtonDefinition, type FlowState } from '@app/components/onboarding/onboardingFlowConfig';
|
||||
import type { LicenseNotice } from '@app/types/types';
|
||||
import type { ButtonAction } from '@app/components/onboarding/onboardingFlowConfig';
|
||||
|
||||
interface RenderButtonsProps {
|
||||
slideDefinition: {
|
||||
buttons: ButtonDefinition[];
|
||||
id: string;
|
||||
};
|
||||
licenseNotice: LicenseNotice;
|
||||
flowState: FlowState;
|
||||
onAction: (action: ButtonAction) => void;
|
||||
}
|
||||
|
||||
export function renderButtons({ slideDefinition, licenseNotice, flowState, onAction }: RenderButtonsProps) {
|
||||
const { t } = useTranslation();
|
||||
const leftButtons = slideDefinition.buttons.filter((btn) => btn.group === 'left');
|
||||
const rightButtons = slideDefinition.buttons.filter((btn) => btn.group === 'right');
|
||||
|
||||
const buttonStyles = (variant: ButtonDefinition['variant']) =>
|
||||
variant === 'primary'
|
||||
? {
|
||||
root: {
|
||||
background: 'var(--onboarding-primary-button-bg)',
|
||||
color: 'var(--onboarding-primary-button-text)',
|
||||
},
|
||||
}
|
||||
: {
|
||||
root: {
|
||||
background: 'var(--onboarding-secondary-button-bg)',
|
||||
border: '1px solid var(--onboarding-secondary-button-border)',
|
||||
color: 'var(--onboarding-secondary-button-text)',
|
||||
},
|
||||
};
|
||||
|
||||
const resolveButtonLabel = (button: ButtonDefinition) => {
|
||||
// Special case: override "See Plans" with "Upgrade now" when over limit
|
||||
if (
|
||||
button.type === 'button' &&
|
||||
slideDefinition.id === 'server-license' &&
|
||||
button.action === 'see-plans' &&
|
||||
licenseNotice.isOverLimit
|
||||
) {
|
||||
return t('onboarding.serverLicense.upgrade', 'Upgrade now →');
|
||||
}
|
||||
|
||||
// Translate the label (it's a translation key)
|
||||
const label = button.label ?? '';
|
||||
if (!label) return '';
|
||||
|
||||
// Extract fallback text from translation key (e.g., 'onboarding.buttons.next' -> 'Next')
|
||||
const fallback = label.split('.').pop() || label;
|
||||
return t(label, fallback);
|
||||
};
|
||||
|
||||
const renderButton = (button: ButtonDefinition) => {
|
||||
const disabled = button.disabledWhen?.(flowState) ?? false;
|
||||
|
||||
if (button.type === 'icon') {
|
||||
return (
|
||||
<ActionIcon
|
||||
key={button.key}
|
||||
onClick={() => onAction(button.action)}
|
||||
radius="md"
|
||||
size={40}
|
||||
disabled={disabled}
|
||||
styles={{
|
||||
root: {
|
||||
background: 'var(--onboarding-secondary-button-bg)',
|
||||
border: '1px solid var(--onboarding-secondary-button-border)',
|
||||
color: 'var(--onboarding-secondary-button-text)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{button.icon === 'chevron-left' && <ChevronLeftIcon fontSize="small" />}
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
|
||||
const variant = button.variant ?? 'secondary';
|
||||
const label = resolveButtonLabel(button);
|
||||
|
||||
return (
|
||||
<Button key={button.key} onClick={() => onAction(button.action)} disabled={disabled} styles={buttonStyles(variant)}>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
if (leftButtons.length === 0) {
|
||||
return <Group justify="flex-end">{rightButtons.map(renderButton)}</Group>;
|
||||
}
|
||||
|
||||
if (rightButtons.length === 0) {
|
||||
return <Group justify="flex-start">{leftButtons.map(renderButton)}</Group>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Group justify="space-between">
|
||||
<Group gap={12}>{leftButtons.map(renderButton)}</Group>
|
||||
<Group gap={12}>{rightButtons.map(renderButton)}</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { LicenseNotice } from '@app/types/types';
|
||||
|
||||
export interface InitialOnboardingModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onRequestServerLicense?: (options?: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean }) => void;
|
||||
onLicenseNoticeUpdate?: (licenseNotice: LicenseNotice) => void;
|
||||
}
|
||||
|
||||
export interface OnboardingState {
|
||||
step: number;
|
||||
selectedRole: 'admin' | 'user' | null;
|
||||
selfReportedAdmin: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_STATE: OnboardingState = {
|
||||
step: 0,
|
||||
selectedRole: null,
|
||||
selfReportedAdmin: false,
|
||||
};
|
||||
|
||||
+381
@@ -0,0 +1,381 @@
|
||||
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
|
||||
import { usePreferences } from '@app/contexts/PreferencesContext';
|
||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
||||
import { useOs } from '@app/hooks/useOs';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
SLIDE_DEFINITIONS,
|
||||
type ButtonAction,
|
||||
type FlowState,
|
||||
type SlideId,
|
||||
} from '@app/components/onboarding/onboardingFlowConfig';
|
||||
import type { LicenseNotice } from '@app/types/types';
|
||||
import { resolveFlow } from '@app/components/onboarding/InitialOnboardingModal/flowResolver';
|
||||
import { useServerExperience } from '@app/hooks/useServerExperience';
|
||||
import { DEFAULT_STATE, type InitialOnboardingModalProps, type OnboardingState } from '@app/components/onboarding/InitialOnboardingModal/types';
|
||||
import { DOWNLOAD_URLS } from '@app/constants/downloads';
|
||||
|
||||
interface UseInitialOnboardingStateResult {
|
||||
state: OnboardingState;
|
||||
totalSteps: number;
|
||||
slideDefinition: (typeof SLIDE_DEFINITIONS)[SlideId];
|
||||
currentSlide: ReturnType<(typeof SLIDE_DEFINITIONS)[SlideId]['createSlide']>;
|
||||
licenseNotice: LicenseNotice;
|
||||
flowState: FlowState;
|
||||
closeAndMarkSeen: () => void;
|
||||
handleButtonAction: (action: ButtonAction) => void;
|
||||
}
|
||||
|
||||
export function useInitialOnboardingState({
|
||||
opened,
|
||||
onClose,
|
||||
onRequestServerLicense,
|
||||
onLicenseNoticeUpdate,
|
||||
}: InitialOnboardingModalProps): UseInitialOnboardingStateResult | null {
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
const { startTour } = useOnboarding();
|
||||
const {
|
||||
loginEnabled: loginEnabledFromServer,
|
||||
configIsAdmin,
|
||||
totalUsers: serverTotalUsers,
|
||||
userCountResolved: serverUserCountResolved,
|
||||
freeTierLimit,
|
||||
hasPaidLicense,
|
||||
scenarioKey,
|
||||
setSelfReportedAdmin,
|
||||
isNewServer,
|
||||
} = useServerExperience();
|
||||
const osType = useOs();
|
||||
const navigate = useNavigate();
|
||||
const selectedDownloadUrlRef = useRef<string>('');
|
||||
|
||||
const [state, setState] = useState<OnboardingState>(DEFAULT_STATE);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setState(DEFAULT_STATE);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
resetState();
|
||||
}
|
||||
}, [opened, resetState]);
|
||||
|
||||
const handleRoleSelect = useCallback(
|
||||
(role: 'admin' | 'user' | null) => {
|
||||
const isAdminSelection = role === 'admin';
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
selectedRole: role,
|
||||
selfReportedAdmin: isAdminSelection,
|
||||
}));
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
if (isAdminSelection) {
|
||||
window.localStorage.setItem('stirling-self-reported-admin', 'true');
|
||||
} else {
|
||||
window.localStorage.removeItem('stirling-self-reported-admin');
|
||||
}
|
||||
}
|
||||
|
||||
setSelfReportedAdmin(isAdminSelection);
|
||||
},
|
||||
[setSelfReportedAdmin],
|
||||
);
|
||||
|
||||
const closeAndMarkSeen = useCallback(() => {
|
||||
if (!preferences.hasSeenIntroOnboarding) {
|
||||
updatePreference('hasSeenIntroOnboarding', true);
|
||||
}
|
||||
onClose();
|
||||
}, [onClose, preferences.hasSeenIntroOnboarding, updatePreference]);
|
||||
|
||||
const isAdmin = configIsAdmin;
|
||||
const enableLogin = loginEnabledFromServer;
|
||||
|
||||
const effectiveEnableLogin = enableLogin;
|
||||
const effectiveIsAdmin = isAdmin;
|
||||
const shouldAssumeAdminForNewServer = Boolean(isNewServer) && !effectiveEnableLogin;
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldAssumeAdminForNewServer && !state.selfReportedAdmin) {
|
||||
handleRoleSelect('admin');
|
||||
}
|
||||
}, [handleRoleSelect, shouldAssumeAdminForNewServer, state.selfReportedAdmin]);
|
||||
|
||||
const shouldUseServerCount =
|
||||
(effectiveEnableLogin && effectiveIsAdmin) || !effectiveEnableLogin;
|
||||
const licenseUserCountFromServer =
|
||||
shouldUseServerCount && serverUserCountResolved ? serverTotalUsers : null;
|
||||
|
||||
const effectiveLicenseUserCount = licenseUserCountFromServer ?? null;
|
||||
|
||||
const os = useMemo(() => {
|
||||
switch (osType) {
|
||||
case 'windows':
|
||||
return { label: 'Windows', url: DOWNLOAD_URLS.WINDOWS };
|
||||
case 'mac-apple':
|
||||
return { label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON };
|
||||
case 'mac-intel':
|
||||
return { label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL };
|
||||
case 'linux-x64':
|
||||
case 'linux-arm64':
|
||||
return { label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS };
|
||||
default:
|
||||
return { label: '', url: '' };
|
||||
}
|
||||
}, [osType]);
|
||||
|
||||
const osOptions = useMemo(() => {
|
||||
const options = [
|
||||
{ label: 'Windows', url: DOWNLOAD_URLS.WINDOWS, value: 'windows' },
|
||||
{ label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON, value: 'mac-apple' },
|
||||
{ label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL, value: 'mac-intel' },
|
||||
{ label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS, value: 'linux' },
|
||||
];
|
||||
return options.filter(opt => opt.url);
|
||||
}, []);
|
||||
|
||||
const resolvedFlow = useMemo(
|
||||
() => resolveFlow(effectiveEnableLogin, effectiveIsAdmin, state.selfReportedAdmin),
|
||||
[effectiveEnableLogin, effectiveIsAdmin, state.selfReportedAdmin],
|
||||
);
|
||||
const shouldSkipSecurityCheck = shouldAssumeAdminForNewServer;
|
||||
const flowSlideIds = useMemo(
|
||||
() =>
|
||||
shouldSkipSecurityCheck
|
||||
? resolvedFlow.ids.filter((id) => id !== 'security-check')
|
||||
: resolvedFlow.ids,
|
||||
[resolvedFlow.ids, shouldSkipSecurityCheck],
|
||||
);
|
||||
const flowType = resolvedFlow.type;
|
||||
const totalSteps = flowSlideIds.length;
|
||||
const maxIndex = Math.max(totalSteps - 1, 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.step >= flowSlideIds.length) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
step: Math.max(flowSlideIds.length - 1, 0),
|
||||
}));
|
||||
}
|
||||
}, [flowSlideIds.length, state.step]);
|
||||
|
||||
const currentSlideId = flowSlideIds[state.step] ?? flowSlideIds[flowSlideIds.length - 1];
|
||||
const slideDefinition = SLIDE_DEFINITIONS[currentSlideId];
|
||||
|
||||
if (!slideDefinition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scenarioProvidesInfo =
|
||||
scenarioKey && scenarioKey !== 'unknown' && scenarioKey !== 'licensed';
|
||||
const scenarioIndicatesAdmin = scenarioProvidesInfo
|
||||
? scenarioKey!.includes('admin')
|
||||
: state.selfReportedAdmin || effectiveIsAdmin;
|
||||
const scenarioIndicatesOverLimit = scenarioProvidesInfo
|
||||
? scenarioKey!.includes('over-limit')
|
||||
: effectiveLicenseUserCount != null && effectiveLicenseUserCount > freeTierLimit;
|
||||
const scenarioRequiresLicense =
|
||||
scenarioKey === 'licensed' ? false : scenarioKey === 'unknown' ? !hasPaidLicense : true;
|
||||
|
||||
const shouldShowServerLicenseInfo = scenarioIndicatesAdmin && scenarioRequiresLicense;
|
||||
|
||||
const licenseNotice = useMemo<LicenseNotice>(
|
||||
() => ({
|
||||
totalUsers: effectiveLicenseUserCount,
|
||||
freeTierLimit,
|
||||
isOverLimit: scenarioIndicatesOverLimit,
|
||||
requiresLicense: shouldShowServerLicenseInfo,
|
||||
}),
|
||||
[
|
||||
effectiveLicenseUserCount,
|
||||
freeTierLimit,
|
||||
scenarioIndicatesOverLimit,
|
||||
shouldShowServerLicenseInfo,
|
||||
],
|
||||
);
|
||||
|
||||
const requestServerLicenseIfNeeded = useCallback(
|
||||
(options?: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean }) => {
|
||||
if (!shouldShowServerLicenseInfo) {
|
||||
return;
|
||||
}
|
||||
onRequestServerLicense?.(options);
|
||||
},
|
||||
[onRequestServerLicense, shouldShowServerLicenseInfo],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onLicenseNoticeUpdate?.(licenseNotice);
|
||||
}, [licenseNotice, onLicenseNoticeUpdate]);
|
||||
|
||||
// Initialize ref with default URL
|
||||
useEffect(() => {
|
||||
if (!selectedDownloadUrlRef.current && os.url) {
|
||||
selectedDownloadUrlRef.current = os.url;
|
||||
}
|
||||
}, [os.url]);
|
||||
|
||||
const handleDownloadUrlChange = useCallback((url: string) => {
|
||||
selectedDownloadUrlRef.current = url;
|
||||
}, []);
|
||||
|
||||
const currentSlide = slideDefinition.createSlide({
|
||||
osLabel: os.label,
|
||||
osUrl: os.url,
|
||||
osOptions,
|
||||
onDownloadUrlChange: handleDownloadUrlChange,
|
||||
selectedRole: state.selectedRole,
|
||||
onRoleSelect: handleRoleSelect,
|
||||
licenseNotice,
|
||||
loginEnabled: effectiveEnableLogin,
|
||||
});
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
step: Math.min(prev.step + 1, maxIndex),
|
||||
}));
|
||||
}, [maxIndex]);
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
step: Math.max(prev.step - 1, 0),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const launchTour = useCallback(
|
||||
(mode: 'admin' | 'tools', options?: { closeOnboardingSlides?: boolean }) => {
|
||||
if (options?.closeOnboardingSlides) {
|
||||
closeAndMarkSeen();
|
||||
}
|
||||
|
||||
startTour(mode, {
|
||||
source: 'initial-onboarding-modal',
|
||||
metadata: {
|
||||
hasCompletedOnboarding: preferences.hasCompletedOnboarding,
|
||||
toolPanelModePromptSeen: preferences.toolPanelModePromptSeen,
|
||||
selfReportedAdmin: state.selfReportedAdmin,
|
||||
},
|
||||
});
|
||||
},
|
||||
[closeAndMarkSeen, preferences.hasCompletedOnboarding, preferences.toolPanelModePromptSeen, startTour, state.selfReportedAdmin],
|
||||
);
|
||||
|
||||
const handleButtonAction = useCallback(
|
||||
(action: ButtonAction) => {
|
||||
const currentSlideIdLocal = currentSlideId;
|
||||
const shouldAutoLaunchLoginUserTour =
|
||||
flowType === 'login-user' && currentSlideIdLocal === 'desktop-install';
|
||||
|
||||
switch (action) {
|
||||
case 'next':
|
||||
if (shouldAutoLaunchLoginUserTour) {
|
||||
launchTour('tools', { closeOnboardingSlides: true });
|
||||
return;
|
||||
}
|
||||
goNext();
|
||||
return;
|
||||
case 'prev':
|
||||
goPrev();
|
||||
return;
|
||||
case 'close':
|
||||
closeAndMarkSeen();
|
||||
return;
|
||||
case 'download-selected': {
|
||||
const downloadUrl = selectedDownloadUrlRef.current || os.url || currentSlide.downloadUrl;
|
||||
if (downloadUrl) {
|
||||
window.open(downloadUrl, '_blank', 'noopener');
|
||||
}
|
||||
if (shouldAutoLaunchLoginUserTour) {
|
||||
launchTour('tools', { closeOnboardingSlides: true });
|
||||
return;
|
||||
}
|
||||
goNext();
|
||||
return;
|
||||
}
|
||||
case 'complete-close':
|
||||
updatePreference('hasCompletedOnboarding', true);
|
||||
closeAndMarkSeen();
|
||||
return;
|
||||
case 'security-next':
|
||||
if (!state.selectedRole) {
|
||||
return;
|
||||
}
|
||||
if (state.selectedRole === 'admin') {
|
||||
goNext();
|
||||
} else {
|
||||
launchTour('tools', { closeOnboardingSlides: true });
|
||||
}
|
||||
return;
|
||||
case 'launch-admin':
|
||||
requestServerLicenseIfNeeded({
|
||||
deferUntilTourComplete: true,
|
||||
selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin,
|
||||
});
|
||||
launchTour('admin', { closeOnboardingSlides: true });
|
||||
return;
|
||||
case 'launch-tools':
|
||||
launchTour('tools', { closeOnboardingSlides: true });
|
||||
return;
|
||||
case 'launch-auto': {
|
||||
const launchMode = state.selfReportedAdmin || effectiveIsAdmin ? 'admin' : 'tools';
|
||||
if (launchMode === 'admin') {
|
||||
requestServerLicenseIfNeeded({
|
||||
deferUntilTourComplete: true,
|
||||
selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin,
|
||||
});
|
||||
}
|
||||
launchTour(launchMode, { closeOnboardingSlides: true });
|
||||
return;
|
||||
}
|
||||
case 'skip-to-license':
|
||||
updatePreference('hasCompletedOnboarding', true);
|
||||
requestServerLicenseIfNeeded({
|
||||
deferUntilTourComplete: false,
|
||||
selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin,
|
||||
});
|
||||
closeAndMarkSeen();
|
||||
return;
|
||||
case 'see-plans':
|
||||
closeAndMarkSeen();
|
||||
navigate('/settings/adminPlan');
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
},
|
||||
[
|
||||
closeAndMarkSeen,
|
||||
currentSlide,
|
||||
effectiveIsAdmin,
|
||||
flowType,
|
||||
goNext,
|
||||
goPrev,
|
||||
launchTour,
|
||||
navigate,
|
||||
requestServerLicenseIfNeeded,
|
||||
onRequestServerLicense,
|
||||
os.url,
|
||||
state.selectedRole,
|
||||
state.selfReportedAdmin,
|
||||
updatePreference,
|
||||
],
|
||||
);
|
||||
|
||||
const flowState: FlowState = { selectedRole: state.selectedRole };
|
||||
|
||||
return {
|
||||
state,
|
||||
totalSteps,
|
||||
slideDefinition,
|
||||
currentSlide,
|
||||
licenseNotice,
|
||||
flowState,
|
||||
closeAndMarkSeen,
|
||||
handleButtonAction,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
|
||||
interface OnboardingStepperProps {
|
||||
totalSteps: number;
|
||||
activeStep: number; // 0-indexed
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a progress indicator where the active step is a pill and others are dots.
|
||||
* Colors come from theme.css variables.
|
||||
*/
|
||||
export function OnboardingStepper({ totalSteps, activeStep, className }: OnboardingStepperProps) {
|
||||
const items = Array.from({ length: totalSteps }, (_, index) => index);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{items.map((index) => {
|
||||
const isActive = index === activeStep;
|
||||
const baseStyles: React.CSSProperties = {
|
||||
background: isActive
|
||||
? 'var(--onboarding-step-active)'
|
||||
: 'var(--onboarding-step-inactive)',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
...baseStyles,
|
||||
width: isActive ? 44 : 8,
|
||||
height: 8,
|
||||
borderRadius: 9999,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OnboardingStepper;
|
||||
|
||||
|
||||
@@ -1,105 +1,29 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { TourProvider, useTour, type StepType } from '@reactour/tour';
|
||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import { TourProvider, type StepType } from '@reactour/tour';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CloseButton, ActionIcon } from '@mantine/core';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import InitialOnboardingModal from '@app/components/onboarding/InitialOnboardingModal';
|
||||
import ServerLicenseModal from '@app/components/onboarding/ServerLicenseModal';
|
||||
import '@app/components/onboarding/OnboardingTour.css';
|
||||
import ToolPanelModePrompt from '@app/components/tools/ToolPanelModePrompt';
|
||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||
import { useTourOrchestration } from '@app/contexts/TourOrchestrationContext';
|
||||
import { useAdminTourOrchestration } from '@app/contexts/AdminTourOrchestrationContext';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
import { useOnboardingFlow } from '@app/components/onboarding/hooks/useOnboardingFlow';
|
||||
import { createUserStepsConfig } from '@app/components/onboarding/userStepsConfig';
|
||||
import { createAdminStepsConfig } from '@app/components/onboarding/adminStepsConfig';
|
||||
import { removeAllGlows } from '@app/components/onboarding/tourGlow';
|
||||
import TourContent from '@app/components/onboarding/TourContent';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import TourWelcomeModal from '@app/components/onboarding/TourWelcomeModal';
|
||||
import '@app/components/onboarding/OnboardingTour.css';
|
||||
import i18n from "@app/i18n";
|
||||
|
||||
// Enum case order defines order steps will appear
|
||||
enum TourStep {
|
||||
ALL_TOOLS,
|
||||
SELECT_CROP_TOOL,
|
||||
TOOL_INTERFACE,
|
||||
FILES_BUTTON,
|
||||
FILE_SOURCES,
|
||||
WORKBENCH,
|
||||
VIEW_SWITCHER,
|
||||
VIEWER,
|
||||
PAGE_EDITOR,
|
||||
ACTIVE_FILES,
|
||||
FILE_CHECKBOX,
|
||||
SELECT_CONTROLS,
|
||||
CROP_SETTINGS,
|
||||
RUN_BUTTON,
|
||||
RESULTS,
|
||||
FILE_REPLACEMENT,
|
||||
PIN_BUTTON,
|
||||
WRAP_UP,
|
||||
}
|
||||
|
||||
enum AdminTourStep {
|
||||
WELCOME,
|
||||
CONFIG_BUTTON,
|
||||
SETTINGS_OVERVIEW,
|
||||
TEAMS_AND_USERS,
|
||||
SYSTEM_CUSTOMIZATION,
|
||||
DATABASE_SECTION,
|
||||
CONNECTIONS_SECTION,
|
||||
ADMIN_TOOLS,
|
||||
WRAP_UP,
|
||||
}
|
||||
|
||||
function TourContent() {
|
||||
const { isOpen } = useOnboarding();
|
||||
const { setIsOpen, setCurrentStep } = useTour();
|
||||
const previousIsOpenRef = React.useRef(isOpen);
|
||||
|
||||
// Sync tour open state with context and reset to step 0 when reopening
|
||||
React.useEffect(() => {
|
||||
const wasClosedNowOpen = !previousIsOpenRef.current && isOpen;
|
||||
previousIsOpenRef.current = isOpen;
|
||||
|
||||
if (wasClosedNowOpen) {
|
||||
// Tour is being opened (Help button pressed), reset to first step
|
||||
setCurrentStep(0);
|
||||
}
|
||||
setIsOpen(isOpen);
|
||||
}, [isOpen, setIsOpen, setCurrentStep]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function OnboardingTour() {
|
||||
const { t } = useTranslation();
|
||||
const { completeTour, showWelcomeModal, setShowWelcomeModal, startTour, tourType, isOpen } = useOnboarding();
|
||||
const flow = useOnboardingFlow();
|
||||
const { openFilesModal, closeFilesModal } = useFilesModalContext();
|
||||
const isRTL = typeof document !== 'undefined' ? document.documentElement.dir === 'rtl' : false;
|
||||
|
||||
// Helper to add glow to multiple elements
|
||||
const addGlowToElements = (selectors: string[]) => {
|
||||
selectors.forEach(selector => {
|
||||
const element = document.querySelector(selector);
|
||||
if (element) {
|
||||
if (selector === '[data-tour="settings-content-area"]') {
|
||||
element.classList.add('tour-content-glow');
|
||||
} else {
|
||||
element.classList.add('tour-nav-glow');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Helper to remove all glows
|
||||
const removeAllGlows = () => {
|
||||
document.querySelectorAll('.tour-content-glow').forEach(el => el.classList.remove('tour-content-glow'));
|
||||
document.querySelectorAll('.tour-nav-glow').forEach(el => el.classList.remove('tour-nav-glow'));
|
||||
};
|
||||
|
||||
// Cleanup glows when tour closes
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) {
|
||||
removeAllGlows();
|
||||
}
|
||||
return () => removeAllGlows();
|
||||
}, [isOpen]);
|
||||
const {
|
||||
saveWorkbenchState,
|
||||
restoreWorkbenchState,
|
||||
@@ -122,258 +46,78 @@ export default function OnboardingTour() {
|
||||
scrollNavToSection,
|
||||
} = useAdminTourOrchestration();
|
||||
|
||||
// Define steps as object keyed by enum - TypeScript ensures all keys are present
|
||||
const stepsConfig: Record<TourStep, StepType> = useMemo(() => ({
|
||||
[TourStep.ALL_TOOLS]: {
|
||||
selector: '[data-tour="tool-panel"]',
|
||||
content: t('onboarding.allTools', 'This is the <strong>Tools</strong> panel, where you can browse and select from all available PDF tools.'),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
action: () => {
|
||||
saveWorkbenchState();
|
||||
closeFilesModal();
|
||||
backToAllTools();
|
||||
},
|
||||
},
|
||||
[TourStep.SELECT_CROP_TOOL]: {
|
||||
selector: '[data-tour="tool-button-crop"]',
|
||||
content: t('onboarding.selectCropTool', "Let's select the <strong>Crop</strong> tool to demonstrate how to use one of the tools."),
|
||||
position: 'right',
|
||||
padding: 0,
|
||||
actionAfter: () => selectCropTool(),
|
||||
},
|
||||
[TourStep.TOOL_INTERFACE]: {
|
||||
selector: '[data-tour="tool-panel"]',
|
||||
content: t('onboarding.toolInterface', "This is the <strong>Crop</strong> tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet."),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
},
|
||||
[TourStep.FILES_BUTTON]: {
|
||||
selector: '[data-tour="files-button"]',
|
||||
content: t('onboarding.filesButton', "The <strong>Files</strong> button on the Quick Access bar allows you to upload PDFs to use the tools on."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: () => openFilesModal(),
|
||||
},
|
||||
[TourStep.FILE_SOURCES]: {
|
||||
selector: '[data-tour="file-sources"]',
|
||||
content: t('onboarding.fileSources', "You can upload new files or access recent files from here. For the tour, we'll just use a sample file."),
|
||||
position: 'right',
|
||||
padding: 0,
|
||||
actionAfter: () => {
|
||||
loadSampleFile();
|
||||
closeFilesModal();
|
||||
}
|
||||
},
|
||||
[TourStep.WORKBENCH]: {
|
||||
selector: '[data-tour="workbench"]',
|
||||
content: t('onboarding.workbench', 'This is the <strong>Workbench</strong> - the main area where you view and edit your PDFs.'),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
},
|
||||
[TourStep.VIEW_SWITCHER]: {
|
||||
selector: '[data-tour="view-switcher"]',
|
||||
content: t('onboarding.viewSwitcher', 'Use these controls to select how you want to view your PDFs.'),
|
||||
position: 'bottom',
|
||||
padding: 0,
|
||||
},
|
||||
[TourStep.VIEWER]: {
|
||||
selector: '[data-tour="workbench"]',
|
||||
content: t('onboarding.viewer', "The <strong>Viewer</strong> lets you read and annotate your PDFs."),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
action: () => switchToViewer(),
|
||||
},
|
||||
[TourStep.PAGE_EDITOR]: {
|
||||
selector: '[data-tour="workbench"]',
|
||||
content: t('onboarding.pageEditor', "The <strong>Page Editor</strong> allows you to do various operations on the pages within your PDFs, such as reordering, rotating and deleting."),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
action: () => switchToPageEditor(),
|
||||
},
|
||||
[TourStep.ACTIVE_FILES]: {
|
||||
selector: '[data-tour="workbench"]',
|
||||
content: t('onboarding.activeFiles', "The <strong>Active Files</strong> view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process."),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
action: () => switchToActiveFiles(),
|
||||
},
|
||||
[TourStep.FILE_CHECKBOX]: {
|
||||
selector: '[data-tour="file-card-checkbox"]',
|
||||
content: t('onboarding.fileCheckbox', "Clicking one of the files selects it for processing. You can select multiple files for batch operations."),
|
||||
position: 'top',
|
||||
padding: 10,
|
||||
},
|
||||
[TourStep.SELECT_CONTROLS]: {
|
||||
selector: '[data-tour="right-rail-controls"]',
|
||||
highlightedSelectors: ['[data-tour="right-rail-controls"]', '[data-tour="right-rail-settings"]'],
|
||||
content: t('onboarding.selectControls', "The <strong>Right Rail</strong> contains buttons to quickly select/deselect all of your active PDFs, along with buttons to change the app's theme or language."),
|
||||
position: 'left',
|
||||
padding: 5,
|
||||
action: () => selectFirstFile(),
|
||||
},
|
||||
[TourStep.CROP_SETTINGS]: {
|
||||
selector: '[data-tour="crop-settings"]',
|
||||
content: t('onboarding.cropSettings', "Now that we've selected the file we want crop, we can configure the <strong>Crop</strong> tool to choose the area that we want to crop the PDF to."),
|
||||
position: 'left',
|
||||
padding: 10,
|
||||
action: () => modifyCropSettings(),
|
||||
},
|
||||
[TourStep.RUN_BUTTON]: {
|
||||
selector: '[data-tour="run-button"]',
|
||||
content: t('onboarding.runButton', "Once the tool has been configured, this button allows you to run the tool on all the selected PDFs."),
|
||||
position: 'top',
|
||||
padding: 10,
|
||||
actionAfter: () => executeTool(),
|
||||
},
|
||||
[TourStep.RESULTS]: {
|
||||
selector: '[data-tour="tool-panel"]',
|
||||
content: t('onboarding.results', "After the tool has finished running, the <strong>Review</strong> step will show a preview of the results in this panel, and allow you to undo the operation or download the file. "),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
},
|
||||
[TourStep.FILE_REPLACEMENT]: {
|
||||
selector: '[data-tour="file-card-checkbox"]',
|
||||
content: t('onboarding.fileReplacement', "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools."),
|
||||
position: 'left',
|
||||
padding: 10,
|
||||
},
|
||||
[TourStep.PIN_BUTTON]: {
|
||||
selector: '[data-tour="file-card-pin"]',
|
||||
content: t('onboarding.pinButton', "You can use the <strong>Pin</strong> button if you'd rather your files stay active after running tools on them."),
|
||||
position: 'left',
|
||||
padding: 10,
|
||||
action: () => pinFile(),
|
||||
},
|
||||
[TourStep.WRAP_UP]: {
|
||||
selector: '[data-tour="help-button"]',
|
||||
content: t('onboarding.wrapUp', "You're all set! You've learnt about the main areas of the app and how to use them. Click the <strong>Help</strong> button whenever you like to see this tour again."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
},
|
||||
}), [t]);
|
||||
const isRTL = typeof document !== 'undefined' ? document.documentElement.dir === 'rtl' : false;
|
||||
|
||||
// Define admin tour steps
|
||||
const adminStepsConfig: Record<AdminTourStep, StepType> = useMemo(() => ({
|
||||
[AdminTourStep.WELCOME]: {
|
||||
selector: '[data-tour="config-button"]',
|
||||
content: t('adminOnboarding.welcome', "Welcome to the <strong>Admin Tour</strong>! Let's explore the powerful enterprise features and settings available to system administrators."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: () => {
|
||||
saveAdminState();
|
||||
},
|
||||
},
|
||||
[AdminTourStep.CONFIG_BUTTON]: {
|
||||
selector: '[data-tour="config-button"]',
|
||||
content: t('adminOnboarding.configButton', "Click the <strong>Config</strong> button to access all system settings and administrative controls."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
actionAfter: () => {
|
||||
openConfigModal();
|
||||
},
|
||||
},
|
||||
[AdminTourStep.SETTINGS_OVERVIEW]: {
|
||||
selector: '.modal-nav',
|
||||
content: t('adminOnboarding.settingsOverview', "This is the <strong>Settings Panel</strong>. Admin settings are organised by category for easy navigation."),
|
||||
position: 'right',
|
||||
padding: 0,
|
||||
action: () => {
|
||||
removeAllGlows();
|
||||
},
|
||||
},
|
||||
[AdminTourStep.TEAMS_AND_USERS]: {
|
||||
selector: '[data-tour="admin-people-nav"]',
|
||||
highlightedSelectors: ['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]'],
|
||||
content: t('adminOnboarding.teamsAndUsers', "Manage <strong>Teams</strong> and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: () => {
|
||||
removeAllGlows();
|
||||
navigateToSection('people');
|
||||
setTimeout(() => {
|
||||
addGlowToElements(['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]']);
|
||||
}, 100);
|
||||
},
|
||||
},
|
||||
[AdminTourStep.SYSTEM_CUSTOMIZATION]: {
|
||||
selector: '[data-tour="admin-adminGeneral-nav"]',
|
||||
highlightedSelectors: ['[data-tour="admin-adminGeneral-nav"]', '[data-tour="admin-adminFeatures-nav"]', '[data-tour="admin-adminEndpoints-nav"]', '[data-tour="settings-content-area"]'],
|
||||
content: t('adminOnboarding.systemCustomization', "We have extensive ways to customise the UI: <strong>System Settings</strong> let you change the app name and languages, <strong>Features</strong> allows server certificate management, and <strong>Endpoints</strong> lets you enable or disable specific tools for your users."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: () => {
|
||||
removeAllGlows();
|
||||
navigateToSection('adminGeneral');
|
||||
setTimeout(() => {
|
||||
addGlowToElements(['[data-tour="admin-adminGeneral-nav"]', '[data-tour="admin-adminFeatures-nav"]', '[data-tour="admin-adminEndpoints-nav"]', '[data-tour="settings-content-area"]']);
|
||||
}, 100);
|
||||
},
|
||||
},
|
||||
[AdminTourStep.DATABASE_SECTION]: {
|
||||
selector: '[data-tour="admin-adminDatabase-nav"]',
|
||||
highlightedSelectors: ['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]'],
|
||||
content: t('adminOnboarding.databaseSection', "For advanced production environments, we have settings to allow <strong>external database hookups</strong> so you can integrate with your existing infrastructure."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: () => {
|
||||
removeAllGlows();
|
||||
navigateToSection('adminDatabase');
|
||||
setTimeout(() => {
|
||||
addGlowToElements(['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]']);
|
||||
}, 100);
|
||||
},
|
||||
},
|
||||
[AdminTourStep.CONNECTIONS_SECTION]: {
|
||||
selector: '[data-tour="admin-adminConnections-nav"]',
|
||||
highlightedSelectors: ['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]'],
|
||||
content: t('adminOnboarding.connectionsSection', "The <strong>Connections</strong> section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: () => {
|
||||
removeAllGlows();
|
||||
navigateToSection('adminConnections');
|
||||
setTimeout(() => {
|
||||
addGlowToElements(['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]']);
|
||||
}, 100);
|
||||
},
|
||||
actionAfter: async () => {
|
||||
// Scroll for the NEXT step before it shows
|
||||
await scrollNavToSection('adminAudit');
|
||||
},
|
||||
},
|
||||
[AdminTourStep.ADMIN_TOOLS]: {
|
||||
selector: '[data-tour="admin-adminAudit-nav"]',
|
||||
highlightedSelectors: ['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]'],
|
||||
content: t('adminOnboarding.adminTools', "Finally, we have advanced administration tools like <strong>Auditing</strong> to track system activity and <strong>Usage Analytics</strong> to monitor how your users interact with the platform."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: () => {
|
||||
// Just navigate, scroll already happened in previous step
|
||||
removeAllGlows();
|
||||
navigateToSection('adminAudit');
|
||||
setTimeout(() => {
|
||||
addGlowToElements(['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]']);
|
||||
}, 100);
|
||||
},
|
||||
},
|
||||
[AdminTourStep.WRAP_UP]: {
|
||||
selector: '[data-tour="help-button"]',
|
||||
content: t('adminOnboarding.wrapUp', "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. Access this tour anytime from the <strong>Help</strong> menu."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: () => {
|
||||
removeAllGlows();
|
||||
},
|
||||
},
|
||||
}), [t]);
|
||||
useEffect(() => {
|
||||
if (!flow.isTourOpen) {
|
||||
removeAllGlows();
|
||||
}
|
||||
return () => removeAllGlows();
|
||||
}, [flow.isTourOpen]);
|
||||
|
||||
// Select steps based on tour type
|
||||
const steps = tourType === 'admin'
|
||||
? Object.values(adminStepsConfig)
|
||||
: Object.values(stepsConfig);
|
||||
const userStepsConfig = useMemo(
|
||||
() =>
|
||||
createUserStepsConfig({
|
||||
t,
|
||||
actions: {
|
||||
saveWorkbenchState,
|
||||
closeFilesModal,
|
||||
backToAllTools,
|
||||
selectCropTool,
|
||||
loadSampleFile,
|
||||
switchToViewer,
|
||||
switchToPageEditor,
|
||||
switchToActiveFiles,
|
||||
selectFirstFile,
|
||||
pinFile,
|
||||
modifyCropSettings,
|
||||
executeTool,
|
||||
openFilesModal,
|
||||
},
|
||||
}),
|
||||
[
|
||||
t,
|
||||
backToAllTools,
|
||||
closeFilesModal,
|
||||
executeTool,
|
||||
loadSampleFile,
|
||||
modifyCropSettings,
|
||||
openFilesModal,
|
||||
pinFile,
|
||||
saveWorkbenchState,
|
||||
selectCropTool,
|
||||
selectFirstFile,
|
||||
switchToActiveFiles,
|
||||
switchToPageEditor,
|
||||
switchToViewer,
|
||||
],
|
||||
);
|
||||
|
||||
const advanceTour = ({ setCurrentStep, currentStep, steps, setIsOpen }: {
|
||||
const adminStepsConfig = useMemo(
|
||||
() =>
|
||||
createAdminStepsConfig({
|
||||
t,
|
||||
actions: {
|
||||
saveAdminState,
|
||||
openConfigModal,
|
||||
navigateToSection,
|
||||
scrollNavToSection,
|
||||
},
|
||||
}),
|
||||
[navigateToSection, openConfigModal, saveAdminState, scrollNavToSection, t],
|
||||
);
|
||||
|
||||
const steps = useMemo<StepType[]>(() => {
|
||||
const config = flow.tourType === 'admin' ? adminStepsConfig : userStepsConfig;
|
||||
return Object.values(config);
|
||||
}, [adminStepsConfig, flow.tourType, userStepsConfig]);
|
||||
|
||||
const advanceTour = ({
|
||||
setCurrentStep,
|
||||
currentStep,
|
||||
steps,
|
||||
setIsOpen,
|
||||
}: {
|
||||
setCurrentStep: (value: number | ((prev: number) => number)) => void;
|
||||
currentStep: number;
|
||||
steps?: StepType[];
|
||||
@@ -381,12 +125,12 @@ export default function OnboardingTour() {
|
||||
}) => {
|
||||
if (steps && currentStep === steps.length - 1) {
|
||||
setIsOpen(false);
|
||||
if (tourType === 'admin') {
|
||||
if (flow.tourType === 'admin') {
|
||||
restoreAdminState();
|
||||
} else {
|
||||
restoreWorkbenchState();
|
||||
}
|
||||
completeTour();
|
||||
flow.handleTourCompletion();
|
||||
} else if (steps) {
|
||||
setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1));
|
||||
}
|
||||
@@ -394,34 +138,22 @@ export default function OnboardingTour() {
|
||||
|
||||
const handleCloseTour = ({ setIsOpen }: { setIsOpen: (value: boolean) => void }) => {
|
||||
setIsOpen(false);
|
||||
if (tourType === 'admin') {
|
||||
if (flow.tourType === 'admin') {
|
||||
restoreAdminState();
|
||||
} else {
|
||||
restoreWorkbenchState();
|
||||
}
|
||||
completeTour();
|
||||
flow.handleTourCompletion();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TourWelcomeModal
|
||||
opened={showWelcomeModal}
|
||||
onStartTour={() => {
|
||||
setShowWelcomeModal(false);
|
||||
startTour();
|
||||
}}
|
||||
onMaybeLater={() => {
|
||||
setShowWelcomeModal(false);
|
||||
}}
|
||||
onDontShowAgain={() => {
|
||||
setShowWelcomeModal(false);
|
||||
completeTour();
|
||||
}}
|
||||
/>
|
||||
<InitialOnboardingModal {...flow.initialModalProps} />
|
||||
<ToolPanelModePrompt onComplete={flow.handleToolPromptComplete} />
|
||||
<TourProvider
|
||||
key={`${tourType}-${i18n.language}`}
|
||||
key={`${flow.tourType}-${i18n.language}`}
|
||||
steps={steps}
|
||||
maskClassName={tourType === 'admin' ? 'admin-tour-mask' : undefined}
|
||||
maskClassName={flow.maskClassName}
|
||||
onClickClose={handleCloseTour}
|
||||
onClickMask={advanceTour}
|
||||
onClickHighlighted={(e, clickProps) => {
|
||||
@@ -429,13 +161,10 @@ export default function OnboardingTour() {
|
||||
advanceTour(clickProps);
|
||||
}}
|
||||
keyboardHandler={(e, clickProps, status) => {
|
||||
// Handle right arrow key to advance tour
|
||||
if (e.key === 'ArrowRight' && !status?.isRightDisabled && clickProps) {
|
||||
e.preventDefault();
|
||||
advanceTour(clickProps);
|
||||
}
|
||||
// Handle escape key to close tour
|
||||
else if (e.key === 'Escape' && !status?.isEscDisabled && clickProps) {
|
||||
} else if (e.key === 'Escape' && !status?.isEscDisabled && clickProps) {
|
||||
e.preventDefault();
|
||||
handleCloseTour(clickProps);
|
||||
}
|
||||
@@ -487,22 +216,16 @@ export default function OnboardingTour() {
|
||||
}}
|
||||
components={{
|
||||
Close: ({ onClick }) => (
|
||||
<CloseButton
|
||||
onClick={onClick}
|
||||
size="md"
|
||||
style={{ position: 'absolute', top: '8px', right: '8px' }}
|
||||
/>
|
||||
<CloseButton onClick={onClick} size="md" style={{ position: 'absolute', top: '8px', right: '8px' }} />
|
||||
),
|
||||
Content: ({ content } : {content: string}) => (
|
||||
<div
|
||||
style={{ paddingRight: '16px' /* Ensure text doesn't overlap with close button */ }}
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
/>
|
||||
Content: ({ content }: { content: string }) => (
|
||||
<div style={{ paddingRight: '16px' }} dangerouslySetInnerHTML={{ __html: content }} />
|
||||
),
|
||||
}}
|
||||
>
|
||||
<TourContent />
|
||||
</TourProvider>
|
||||
<ServerLicenseModal {...flow.serverLicenseModalProps} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import React from 'react';
|
||||
import { Modal, Button, Group, Stack } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground';
|
||||
import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide';
|
||||
import { LicenseNotice } from '@app/types/types';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
|
||||
|
||||
interface ServerLicenseModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSeePlans?: () => void;
|
||||
licenseNotice: LicenseNotice;
|
||||
}
|
||||
|
||||
export default function ServerLicenseModal({
|
||||
opened,
|
||||
onClose,
|
||||
onSeePlans,
|
||||
licenseNotice,
|
||||
}: ServerLicenseModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const slide = React.useMemo(() => ServerLicenseSlide({ licenseNotice }), [licenseNotice]);
|
||||
const primaryLabel = licenseNotice.isOverLimit
|
||||
? t('onboarding.serverLicense.upgrade', 'Upgrade now →')
|
||||
: t('onboarding.serverLicense.seePlans', 'See Plans →');
|
||||
const secondaryLabel = t('onboarding.serverLicense.skip', 'Skip for now');
|
||||
|
||||
const handleSeePlans = () => {
|
||||
onSeePlans?.();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const secondaryStyles = {
|
||||
root: {
|
||||
background: 'var(--onboarding-secondary-button-bg)',
|
||||
border: '1px solid var(--onboarding-secondary-button-border)',
|
||||
color: 'var(--onboarding-secondary-button-text)',
|
||||
},
|
||||
};
|
||||
|
||||
const primaryStyles = {
|
||||
root: {
|
||||
background: 'var(--onboarding-primary-button-bg)',
|
||||
color: 'var(--onboarding-primary-button-text)',
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
centered
|
||||
size="lg"
|
||||
radius="lg"
|
||||
withCloseButton={false}
|
||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||
styles={{
|
||||
body: { padding: 0 },
|
||||
content: { overflow: 'hidden', border: 'none', background: 'var(--bg-surface)' },
|
||||
}}
|
||||
>
|
||||
<Stack gap={0}>
|
||||
<div className={styles.heroWrapper}>
|
||||
<AnimatedSlideBackground
|
||||
gradientStops={slide.background.gradientStops}
|
||||
circles={slide.background.circles}
|
||||
isActive
|
||||
slideKey={slide.key}
|
||||
/>
|
||||
<div className={styles.heroLogo}>
|
||||
<div className={styles.heroLogoCircle}>
|
||||
<img src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`} alt="Stirling logo" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: 24 }}>
|
||||
<Stack gap={16}>
|
||||
<div
|
||||
className={styles.title}
|
||||
style={{
|
||||
fontFamily: 'Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif',
|
||||
fontWeight: 600,
|
||||
fontSize: 22,
|
||||
color: 'var(--onboarding-title)',
|
||||
}}
|
||||
>
|
||||
{slide.title}
|
||||
</div>
|
||||
<div
|
||||
className={styles.bodyCopy}
|
||||
style={{
|
||||
fontFamily: 'Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif',
|
||||
fontSize: 16,
|
||||
color: 'var(--onboarding-body)',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{slide.body}
|
||||
</div>
|
||||
<Group justify="space-between">
|
||||
<Button styles={secondaryStyles} onClick={onClose}>
|
||||
{secondaryLabel}
|
||||
</Button>
|
||||
<Button styles={primaryStyles} onClick={handleSeePlans}>
|
||||
{primaryLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</div>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { useTour } from '@reactour/tour';
|
||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
||||
|
||||
export default function TourContent() {
|
||||
const { isOpen } = useOnboarding();
|
||||
const { setIsOpen, setCurrentStep } = useTour();
|
||||
const previousIsOpenRef = React.useRef(isOpen);
|
||||
|
||||
React.useEffect(() => {
|
||||
const wasClosedNowOpen = !previousIsOpenRef.current && isOpen;
|
||||
previousIsOpenRef.current = isOpen;
|
||||
|
||||
if (wasClosedNowOpen) {
|
||||
setCurrentStep(0);
|
||||
}
|
||||
setIsOpen(isOpen);
|
||||
}, [isOpen, setIsOpen, setCurrentStep]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { StepType } from '@reactour/tour';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { AdminTourStep } from '@app/components/onboarding/tourSteps';
|
||||
import { addGlowToElements, removeAllGlows } from '@app/components/onboarding/tourGlow';
|
||||
|
||||
interface AdminStepActions {
|
||||
saveAdminState: () => void;
|
||||
openConfigModal: () => void;
|
||||
navigateToSection: (section: string) => void;
|
||||
scrollNavToSection: (section: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
interface CreateAdminStepsConfigArgs {
|
||||
t: TFunction;
|
||||
actions: AdminStepActions;
|
||||
}
|
||||
|
||||
export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArgs): Record<AdminTourStep, StepType> {
|
||||
const { saveAdminState, openConfigModal, navigateToSection, scrollNavToSection } = actions;
|
||||
|
||||
return {
|
||||
[AdminTourStep.WELCOME]: {
|
||||
selector: '[data-tour="config-button"]',
|
||||
content: t('adminOnboarding.welcome', "Welcome to the <strong>Admin Tour</strong>! Let's explore the powerful enterprise features and settings available to system administrators."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: () => {
|
||||
saveAdminState();
|
||||
},
|
||||
},
|
||||
[AdminTourStep.CONFIG_BUTTON]: {
|
||||
selector: '[data-tour="config-button"]',
|
||||
content: t('adminOnboarding.configButton', "Click the <strong>Config</strong> button to access all system settings and administrative controls."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
actionAfter: () => {
|
||||
openConfigModal();
|
||||
},
|
||||
},
|
||||
[AdminTourStep.SETTINGS_OVERVIEW]: {
|
||||
selector: '.modal-nav',
|
||||
content: t('adminOnboarding.settingsOverview', "This is the <strong>Settings Panel</strong>. Admin settings are organised by category for easy navigation."),
|
||||
position: 'right',
|
||||
padding: 0,
|
||||
action: () => {
|
||||
removeAllGlows();
|
||||
},
|
||||
},
|
||||
[AdminTourStep.TEAMS_AND_USERS]: {
|
||||
selector: '[data-tour="admin-people-nav"]',
|
||||
highlightedSelectors: ['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]'],
|
||||
content: t('adminOnboarding.teamsAndUsers', "Manage <strong>Teams</strong> and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: () => {
|
||||
removeAllGlows();
|
||||
navigateToSection('people');
|
||||
setTimeout(() => {
|
||||
addGlowToElements(['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]']);
|
||||
}, 100);
|
||||
},
|
||||
},
|
||||
[AdminTourStep.SYSTEM_CUSTOMIZATION]: {
|
||||
selector: '[data-tour="admin-adminGeneral-nav"]',
|
||||
highlightedSelectors: ['[data-tour="admin-adminGeneral-nav"]', '[data-tour="admin-adminFeatures-nav"]', '[data-tour="admin-adminEndpoints-nav"]', '[data-tour="settings-content-area"]'],
|
||||
content: t('adminOnboarding.systemCustomization', "We have extensive ways to customise the UI: <strong>System Settings</strong> let you change the app name and languages, <strong>Features</strong> allows server certificate management, and <strong>Endpoints</strong> lets you enable or disable specific tools for your users."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: () => {
|
||||
removeAllGlows();
|
||||
navigateToSection('adminGeneral');
|
||||
setTimeout(() => {
|
||||
addGlowToElements(['[data-tour="admin-adminGeneral-nav"]', '[data-tour="admin-adminFeatures-nav"]', '[data-tour="admin-adminEndpoints-nav"]', '[data-tour="settings-content-area"]']);
|
||||
}, 100);
|
||||
},
|
||||
},
|
||||
[AdminTourStep.DATABASE_SECTION]: {
|
||||
selector: '[data-tour="admin-adminDatabase-nav"]',
|
||||
highlightedSelectors: ['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]'],
|
||||
content: t('adminOnboarding.databaseSection', "For advanced production environments, we have settings to allow <strong>external database hookups</strong> so you can integrate with your existing infrastructure."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: () => {
|
||||
removeAllGlows();
|
||||
navigateToSection('adminDatabase');
|
||||
setTimeout(() => {
|
||||
addGlowToElements(['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]']);
|
||||
}, 100);
|
||||
},
|
||||
},
|
||||
[AdminTourStep.CONNECTIONS_SECTION]: {
|
||||
selector: '[data-tour="admin-adminConnections-nav"]',
|
||||
highlightedSelectors: ['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]'],
|
||||
content: t('adminOnboarding.connectionsSection', "The <strong>Connections</strong> section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: () => {
|
||||
removeAllGlows();
|
||||
navigateToSection('adminConnections');
|
||||
setTimeout(() => {
|
||||
addGlowToElements(['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]']);
|
||||
}, 100);
|
||||
},
|
||||
actionAfter: async () => {
|
||||
await scrollNavToSection('adminAudit');
|
||||
},
|
||||
},
|
||||
[AdminTourStep.ADMIN_TOOLS]: {
|
||||
selector: '[data-tour="admin-adminAudit-nav"]',
|
||||
highlightedSelectors: ['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]'],
|
||||
content: t('adminOnboarding.adminTools', "Finally, we have advanced administration tools like <strong>Auditing</strong> to track system activity and <strong>Usage Analytics</strong> to monitor how your users interact with the platform."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: () => {
|
||||
removeAllGlows();
|
||||
navigateToSection('adminAudit');
|
||||
setTimeout(() => {
|
||||
addGlowToElements(['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]']);
|
||||
}, 100);
|
||||
},
|
||||
},
|
||||
[AdminTourStep.WRAP_UP]: {
|
||||
selector: '[data-tour="help-button"]',
|
||||
content: t('adminOnboarding.wrapUp', "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. Access this tour anytime from the <strong>Help</strong> menu."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: () => {
|
||||
removeAllGlows();
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { usePreferences } from '@app/contexts/PreferencesContext';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import { useCookieConsentContext } from '@app/contexts/CookieConsentContext';
|
||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
||||
import type { LicenseNotice } from '@app/types/types';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ONBOARDING_SESSION_BLOCK_KEY,
|
||||
ONBOARDING_SESSION_EVENT,
|
||||
SERVER_LICENSE_REQUEST_EVENT,
|
||||
type ServerLicenseRequestPayload,
|
||||
} from '@app/constants/events';
|
||||
import { useServerExperience } from '@app/hooks/useServerExperience';
|
||||
|
||||
interface InitialModalHandlers {
|
||||
opened: boolean;
|
||||
onLicenseNoticeUpdate: (notice: LicenseNotice) => void;
|
||||
onRequestServerLicense: (options?: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean }) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface ServerLicenseModalHandlers {
|
||||
opened: boolean;
|
||||
licenseNotice: LicenseNotice;
|
||||
onClose: () => void;
|
||||
onSeePlans: () => void;
|
||||
}
|
||||
|
||||
export function useOnboardingFlow() {
|
||||
const { preferences, updatePreference } = usePreferences();
|
||||
const { config } = useAppConfig();
|
||||
const { showCookieConsent, isReady: isCookieConsentReady } = useCookieConsentContext();
|
||||
const { completeTour, tourType, isOpen } = useOnboarding();
|
||||
|
||||
const shouldShowIntro = !preferences.hasSeenIntroOnboarding;
|
||||
const isAdminUser = !!config?.isAdmin;
|
||||
const { hasPaidLicense } = useServerExperience();
|
||||
|
||||
const [licenseNotice, setLicenseNotice] = useState<LicenseNotice>({
|
||||
totalUsers: null,
|
||||
freeTierLimit: 5,
|
||||
isOverLimit: false,
|
||||
requiresLicense: false,
|
||||
});
|
||||
const [cookieBannerPending, setCookieBannerPending] = useState(false);
|
||||
const [serverLicenseIntent, setServerLicenseIntent] = useState<'idle' | 'pending' | 'deferred'>('idle');
|
||||
const [serverLicenseSource, setServerLicenseSource] = useState<'config' | 'self-reported' | null>(null);
|
||||
const [isServerLicenseOpen, setIsServerLicenseOpen] = useState(false);
|
||||
const [hasShownServerLicense, setHasShownServerLicense] = useState(false);
|
||||
const [toolPromptCompleted, setToolPromptCompleted] = useState(
|
||||
preferences.toolPanelModePromptSeen || preferences.hasSelectedToolPanelMode,
|
||||
);
|
||||
const introWasOpenRef = useRef(false);
|
||||
const navigate = useNavigate();
|
||||
const onboardingSessionMarkedRef = useRef(false);
|
||||
|
||||
const handleInitialModalClose = useCallback(() => {
|
||||
if (!preferences.hasSeenIntroOnboarding) {
|
||||
updatePreference('hasSeenIntroOnboarding', true);
|
||||
}
|
||||
}, [preferences.hasSeenIntroOnboarding, updatePreference]);
|
||||
|
||||
const handleLicenseNoticeUpdate = useCallback((notice: LicenseNotice) => {
|
||||
setLicenseNotice(notice);
|
||||
}, []);
|
||||
|
||||
const handleToolPromptComplete = useCallback(() => {
|
||||
setToolPromptCompleted(true);
|
||||
}, []);
|
||||
|
||||
const maybeShowCookieBanner = useCallback(() => {
|
||||
if (preferences.hasSeenCookieBanner) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isCookieConsentReady || isServerLicenseOpen || serverLicenseIntent !== 'idle' || !toolPromptCompleted) {
|
||||
setCookieBannerPending(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setCookieBannerPending(false);
|
||||
showCookieConsent();
|
||||
updatePreference('hasSeenCookieBanner', true);
|
||||
}, [
|
||||
isCookieConsentReady,
|
||||
isServerLicenseOpen,
|
||||
preferences.hasSeenCookieBanner,
|
||||
serverLicenseIntent,
|
||||
showCookieConsent,
|
||||
toolPromptCompleted,
|
||||
updatePreference,
|
||||
]);
|
||||
|
||||
const requestServerLicense = useCallback(
|
||||
({
|
||||
deferUntilTourComplete = false,
|
||||
selfReportedAdmin = false,
|
||||
}: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean } = {}) => {
|
||||
const qualifies = isAdminUser || selfReportedAdmin;
|
||||
if (!qualifies) {
|
||||
return;
|
||||
}
|
||||
if (hasPaidLicense || !licenseNotice.requiresLicense) {
|
||||
return;
|
||||
}
|
||||
setServerLicenseSource(isAdminUser ? 'config' : 'self-reported');
|
||||
setServerLicenseIntent((prev) => {
|
||||
if (prev === 'pending') {
|
||||
return prev;
|
||||
}
|
||||
if (prev === 'deferred') {
|
||||
return deferUntilTourComplete ? prev : 'pending';
|
||||
}
|
||||
if (prev === 'idle') {
|
||||
return deferUntilTourComplete ? 'deferred' : 'pending';
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
},
|
||||
[hasPaidLicense, isAdminUser, licenseNotice.requiresLicense],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleServerLicenseRequested = (event: Event) => {
|
||||
const { detail } = event as CustomEvent<ServerLicenseRequestPayload>;
|
||||
|
||||
if (detail?.licenseNotice) {
|
||||
setLicenseNotice((prev) => ({
|
||||
...prev,
|
||||
...detail.licenseNotice,
|
||||
totalUsers:
|
||||
detail.licenseNotice?.totalUsers ?? prev.totalUsers,
|
||||
freeTierLimit:
|
||||
detail.licenseNotice?.freeTierLimit ?? prev.freeTierLimit,
|
||||
isOverLimit:
|
||||
detail.licenseNotice?.isOverLimit ?? prev.isOverLimit,
|
||||
requiresLicense:
|
||||
detail.licenseNotice?.requiresLicense ?? prev.requiresLicense,
|
||||
}));
|
||||
}
|
||||
|
||||
requestServerLicense({
|
||||
deferUntilTourComplete: detail?.deferUntilTourComplete ?? false,
|
||||
selfReportedAdmin: detail?.selfReportedAdmin ?? false,
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener(
|
||||
SERVER_LICENSE_REQUEST_EVENT,
|
||||
handleServerLicenseRequested as EventListener,
|
||||
);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
SERVER_LICENSE_REQUEST_EVENT,
|
||||
handleServerLicenseRequested as EventListener,
|
||||
);
|
||||
};
|
||||
}, [requestServerLicense]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
cookieBannerPending &&
|
||||
isCookieConsentReady &&
|
||||
serverLicenseIntent === 'idle' &&
|
||||
!isServerLicenseOpen &&
|
||||
toolPromptCompleted
|
||||
) {
|
||||
maybeShowCookieBanner();
|
||||
}
|
||||
}, [
|
||||
cookieBannerPending,
|
||||
isCookieConsentReady,
|
||||
isServerLicenseOpen,
|
||||
serverLicenseIntent,
|
||||
toolPromptCompleted,
|
||||
maybeShowCookieBanner,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const isEligibleAdmin =
|
||||
isAdminUser || serverLicenseSource === 'self-reported' || licenseNotice.requiresLicense;
|
||||
if (
|
||||
introWasOpenRef.current &&
|
||||
!shouldShowIntro &&
|
||||
isEligibleAdmin &&
|
||||
toolPromptCompleted &&
|
||||
!hasShownServerLicense &&
|
||||
licenseNotice.requiresLicense &&
|
||||
serverLicenseIntent === 'idle'
|
||||
) {
|
||||
if (!serverLicenseSource) {
|
||||
setServerLicenseSource(isAdminUser ? 'config' : 'self-reported');
|
||||
}
|
||||
setServerLicenseIntent('pending');
|
||||
}
|
||||
introWasOpenRef.current = shouldShowIntro;
|
||||
}, [
|
||||
hasShownServerLicense,
|
||||
isAdminUser,
|
||||
serverLicenseIntent,
|
||||
shouldShowIntro,
|
||||
serverLicenseSource,
|
||||
toolPromptCompleted,
|
||||
licenseNotice.requiresLicense,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const isEligibleAdmin =
|
||||
isAdminUser || serverLicenseSource === 'self-reported' || licenseNotice.requiresLicense;
|
||||
if (
|
||||
serverLicenseIntent !== 'idle' &&
|
||||
!shouldShowIntro &&
|
||||
!isOpen &&
|
||||
!isServerLicenseOpen &&
|
||||
isEligibleAdmin &&
|
||||
toolPromptCompleted &&
|
||||
licenseNotice.requiresLicense
|
||||
) {
|
||||
setIsServerLicenseOpen(true);
|
||||
setServerLicenseIntent(serverLicenseIntent === 'deferred' ? 'pending' : 'idle');
|
||||
}
|
||||
}, [
|
||||
isAdminUser,
|
||||
isOpen,
|
||||
isServerLicenseOpen,
|
||||
serverLicenseIntent,
|
||||
shouldShowIntro,
|
||||
serverLicenseSource,
|
||||
toolPromptCompleted,
|
||||
licenseNotice.requiresLicense,
|
||||
]);
|
||||
|
||||
const handleServerLicenseClose = useCallback(() => {
|
||||
setIsServerLicenseOpen(false);
|
||||
setHasShownServerLicense(true);
|
||||
setServerLicenseIntent('idle');
|
||||
setServerLicenseSource(null);
|
||||
maybeShowCookieBanner();
|
||||
}, [maybeShowCookieBanner]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onboardingSessionMarkedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
if (shouldShowIntro || isOpen) {
|
||||
onboardingSessionMarkedRef.current = true;
|
||||
window.sessionStorage.setItem(ONBOARDING_SESSION_BLOCK_KEY, 'true');
|
||||
window.dispatchEvent(new CustomEvent(ONBOARDING_SESSION_EVENT));
|
||||
}
|
||||
}, [isOpen, shouldShowIntro]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
if (!shouldShowIntro && !isOpen) {
|
||||
window.sessionStorage.removeItem(ONBOARDING_SESSION_BLOCK_KEY);
|
||||
window.dispatchEvent(new CustomEvent(ONBOARDING_SESSION_EVENT));
|
||||
}
|
||||
}, [isOpen, shouldShowIntro]);
|
||||
|
||||
const handleServerLicenseSeePlans = useCallback(() => {
|
||||
handleServerLicenseClose();
|
||||
navigate('/settings/adminPlan');
|
||||
}, [handleServerLicenseClose, navigate]);
|
||||
|
||||
const handleTourCompletion = useCallback(() => {
|
||||
completeTour();
|
||||
if (serverLicenseIntent === 'deferred') {
|
||||
setServerLicenseIntent('pending');
|
||||
} else if (tourType === 'admin' && (isAdminUser || serverLicenseSource === 'self-reported')) {
|
||||
setServerLicenseSource((prev) => prev ?? (isAdminUser ? 'config' : 'self-reported'));
|
||||
setServerLicenseIntent((prev) => (prev === 'pending' ? prev : 'pending'));
|
||||
}
|
||||
maybeShowCookieBanner();
|
||||
}, [
|
||||
completeTour,
|
||||
isAdminUser,
|
||||
maybeShowCookieBanner,
|
||||
serverLicenseIntent,
|
||||
serverLicenseSource,
|
||||
tourType,
|
||||
]);
|
||||
|
||||
const initialModalProps: InitialModalHandlers = useMemo(
|
||||
() => ({
|
||||
opened: shouldShowIntro,
|
||||
onLicenseNoticeUpdate: handleLicenseNoticeUpdate,
|
||||
onRequestServerLicense: requestServerLicense,
|
||||
onClose: handleInitialModalClose,
|
||||
}),
|
||||
[handleInitialModalClose, handleLicenseNoticeUpdate, requestServerLicense, shouldShowIntro],
|
||||
);
|
||||
|
||||
const serverLicenseModalProps: ServerLicenseModalHandlers = useMemo(
|
||||
() => ({
|
||||
opened: isServerLicenseOpen,
|
||||
licenseNotice,
|
||||
onClose: handleServerLicenseClose,
|
||||
onSeePlans: handleServerLicenseSeePlans,
|
||||
}),
|
||||
[handleServerLicenseClose, handleServerLicenseSeePlans, isServerLicenseOpen, licenseNotice],
|
||||
);
|
||||
|
||||
return {
|
||||
tourType,
|
||||
isTourOpen: isOpen,
|
||||
maskClassName: tourType === 'admin' ? 'admin-tour-mask' : undefined,
|
||||
initialModalProps,
|
||||
handleToolPromptComplete,
|
||||
serverLicenseModalProps,
|
||||
handleTourCompletion,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import WelcomeSlide from '@app/components/onboarding/slides/WelcomeSlide';
|
||||
import DesktopInstallSlide from '@app/components/onboarding/slides/DesktopInstallSlide';
|
||||
import SecurityCheckSlide from '@app/components/onboarding/slides/SecurityCheckSlide';
|
||||
import PlanOverviewSlide from '@app/components/onboarding/slides/PlanOverviewSlide';
|
||||
import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide';
|
||||
import { SlideConfig, LicenseNotice } from '@app/types/types';
|
||||
|
||||
export type SlideId =
|
||||
| 'welcome'
|
||||
| 'desktop-install'
|
||||
| 'security-check'
|
||||
| 'admin-overview'
|
||||
| 'server-license';
|
||||
|
||||
export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo';
|
||||
|
||||
export type ButtonAction =
|
||||
| 'next'
|
||||
| 'prev'
|
||||
| 'close'
|
||||
| 'complete-close'
|
||||
| 'download-selected'
|
||||
| 'security-next'
|
||||
| 'launch-admin'
|
||||
| 'launch-tools'
|
||||
| 'launch-auto'
|
||||
| 'see-plans'
|
||||
| 'skip-to-license';
|
||||
|
||||
export interface FlowState {
|
||||
selectedRole: 'admin' | 'user' | null;
|
||||
}
|
||||
|
||||
export interface OSOption {
|
||||
label: string;
|
||||
url: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface SlideFactoryParams {
|
||||
osLabel: string;
|
||||
osUrl: string;
|
||||
osOptions?: OSOption[];
|
||||
onDownloadUrlChange?: (url: string) => void;
|
||||
selectedRole: 'admin' | 'user' | null;
|
||||
onRoleSelect: (role: 'admin' | 'user' | null) => void;
|
||||
licenseNotice?: LicenseNotice;
|
||||
loginEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface HeroDefinition {
|
||||
type: HeroType;
|
||||
}
|
||||
|
||||
export interface ButtonDefinition {
|
||||
key: string;
|
||||
type: 'button' | 'icon';
|
||||
label?: string;
|
||||
icon?: 'chevron-left';
|
||||
variant?: 'primary' | 'secondary' | 'default';
|
||||
group: 'left' | 'right';
|
||||
action: ButtonAction;
|
||||
disabledWhen?: (state: FlowState) => boolean;
|
||||
}
|
||||
|
||||
export interface SlideDefinition {
|
||||
id: SlideId;
|
||||
createSlide: (params: SlideFactoryParams) => SlideConfig;
|
||||
hero: HeroDefinition;
|
||||
buttons: ButtonDefinition[];
|
||||
}
|
||||
|
||||
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
||||
'welcome': {
|
||||
id: 'welcome',
|
||||
createSlide: () => WelcomeSlide(),
|
||||
hero: { type: 'rocket' },
|
||||
buttons: [
|
||||
{
|
||||
key: 'welcome-next',
|
||||
type: 'button',
|
||||
label: 'onboarding.buttons.next',
|
||||
variant: 'primary',
|
||||
group: 'right',
|
||||
action: 'next',
|
||||
},
|
||||
],
|
||||
},
|
||||
'desktop-install': {
|
||||
id: 'desktop-install',
|
||||
createSlide: ({ osLabel, osUrl, osOptions, onDownloadUrlChange }) => DesktopInstallSlide({ osLabel, osUrl, osOptions, onDownloadUrlChange }),
|
||||
hero: { type: 'dual-icon' },
|
||||
buttons: [
|
||||
{
|
||||
key: 'desktop-back',
|
||||
type: 'icon',
|
||||
icon: 'chevron-left',
|
||||
group: 'left',
|
||||
action: 'prev',
|
||||
},
|
||||
{
|
||||
key: 'desktop-skip',
|
||||
type: 'button',
|
||||
label: 'onboarding.buttons.skipForNow',
|
||||
variant: 'secondary',
|
||||
group: 'left',
|
||||
action: 'next',
|
||||
},
|
||||
{
|
||||
key: 'desktop-download',
|
||||
type: 'button',
|
||||
label: 'onboarding.buttons.download',
|
||||
variant: 'primary',
|
||||
group: 'right',
|
||||
action: 'download-selected',
|
||||
},
|
||||
],
|
||||
},
|
||||
'security-check': {
|
||||
id: 'security-check',
|
||||
createSlide: ({ selectedRole, onRoleSelect }) =>
|
||||
SecurityCheckSlide({ selectedRole, onRoleSelect }),
|
||||
hero: { type: 'shield' },
|
||||
buttons: [
|
||||
{
|
||||
key: 'security-back',
|
||||
type: 'button',
|
||||
label: 'onboarding.buttons.back',
|
||||
variant: 'secondary',
|
||||
group: 'left',
|
||||
action: 'prev',
|
||||
},
|
||||
{
|
||||
key: 'security-next',
|
||||
type: 'button',
|
||||
label: 'onboarding.buttons.next',
|
||||
variant: 'primary',
|
||||
group: 'right',
|
||||
action: 'security-next',
|
||||
disabledWhen: (state) => !state.selectedRole,
|
||||
},
|
||||
],
|
||||
},
|
||||
'admin-overview': {
|
||||
id: 'admin-overview',
|
||||
createSlide: ({ licenseNotice, loginEnabled }) =>
|
||||
PlanOverviewSlide({ isAdmin: true, licenseNotice, loginEnabled }),
|
||||
hero: { type: 'diamond' },
|
||||
buttons: [
|
||||
{
|
||||
key: 'admin-back',
|
||||
type: 'icon',
|
||||
icon: 'chevron-left',
|
||||
group: 'left',
|
||||
action: 'prev',
|
||||
},
|
||||
{
|
||||
key: 'admin-show',
|
||||
type: 'button',
|
||||
label: 'onboarding.buttons.showMeAround',
|
||||
variant: 'primary',
|
||||
group: 'right',
|
||||
action: 'launch-admin',
|
||||
},
|
||||
{
|
||||
key: 'admin-skip',
|
||||
type: 'button',
|
||||
label: 'onboarding.buttons.skipTheTour',
|
||||
variant: 'secondary',
|
||||
group: 'left',
|
||||
action: 'skip-to-license',
|
||||
},
|
||||
],
|
||||
},
|
||||
'server-license': {
|
||||
id: 'server-license',
|
||||
createSlide: ({ licenseNotice }) => ServerLicenseSlide({ licenseNotice }),
|
||||
hero: { type: 'logo' },
|
||||
buttons: [
|
||||
{
|
||||
key: 'license-close',
|
||||
type: 'button',
|
||||
label: 'onboarding.buttons.skipForNow',
|
||||
variant: 'secondary',
|
||||
group: 'left',
|
||||
action: 'close',
|
||||
},
|
||||
{
|
||||
key: 'license-see-plans',
|
||||
type: 'button',
|
||||
label: 'onboarding.serverLicense.seePlans',
|
||||
variant: 'primary',
|
||||
group: 'right',
|
||||
action: 'see-plans',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const FLOW_SEQUENCES = {
|
||||
loginAdmin: ['welcome', 'desktop-install', 'admin-overview'] as SlideId[],
|
||||
loginUser: ['welcome', 'desktop-install'] as SlideId[],
|
||||
noLoginBase: ['welcome', 'desktop-install', 'security-check'] as SlideId[],
|
||||
noLoginAdmin: ['admin-overview'] as SlideId[],
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
.hero {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
overflow: hidden;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.gradientLayer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: 180% 180%;
|
||||
opacity: 0;
|
||||
transition: opacity 0.8s ease-in-out;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.gradientLayerActive {
|
||||
opacity: 1;
|
||||
animation: gradientShift 18s ease-in-out infinite alternate;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.gradientLayerPrev {
|
||||
opacity: 1;
|
||||
z-index: 3;
|
||||
transition: opacity 0.8s ease-in-out;
|
||||
}
|
||||
|
||||
.gradientLayerPrevFadeOut {
|
||||
opacity: 0;
|
||||
z-index: 3;
|
||||
transition: opacity 0.8s ease-in-out;
|
||||
}
|
||||
|
||||
.circle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.12);
|
||||
animation-name: circleSway;
|
||||
animation-timing-function: ease-in-out;
|
||||
animation-iteration-count: infinite;
|
||||
animation-direction: alternate;
|
||||
animation-duration: var(--circle-duration, 15s);
|
||||
animation-delay: var(--circle-delay, 0s);
|
||||
will-change: transform;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@keyframes gradientShift {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes circleSway {
|
||||
0% {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
100% {
|
||||
transform: translate3d(var(--circle-move-x, 40px), var(--circle-move-y, 24px), 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import React from 'react';
|
||||
import styles from '@app/components/onboarding/slides/AnimatedSlideBackground.module.css';
|
||||
import { AnimatedSlideBackgroundProps } from '@app/types/types';
|
||||
|
||||
type CircleStyles = React.CSSProperties & {
|
||||
'--circle-move-x'?: string;
|
||||
'--circle-move-y'?: string;
|
||||
'--circle-duration'?: string;
|
||||
'--circle-delay'?: string;
|
||||
};
|
||||
|
||||
interface AnimatedSlideBackgroundComponentProps extends AnimatedSlideBackgroundProps {
|
||||
isActive: boolean;
|
||||
slideKey: string;
|
||||
}
|
||||
|
||||
export default function AnimatedSlideBackground({
|
||||
gradientStops,
|
||||
circles,
|
||||
isActive,
|
||||
}: AnimatedSlideBackgroundComponentProps) {
|
||||
const [prevGradient, setPrevGradient] = React.useState<[string, string] | null>(null);
|
||||
const [currentGradient, setCurrentGradient] = React.useState<[string, string]>(gradientStops);
|
||||
const [isTransitioning, setIsTransitioning] = React.useState(false);
|
||||
const isFirstMount = React.useRef(true);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Skip transition on first mount
|
||||
if (isFirstMount.current) {
|
||||
isFirstMount.current = false;
|
||||
setCurrentGradient(gradientStops);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only transition if gradient actually changed
|
||||
if (currentGradient[0] !== gradientStops[0] || currentGradient[1] !== gradientStops[1]) {
|
||||
// Store previous gradient and start transition
|
||||
setPrevGradient(currentGradient);
|
||||
setIsTransitioning(true);
|
||||
|
||||
// Update to new gradient (will fade in)
|
||||
setCurrentGradient(gradientStops);
|
||||
}
|
||||
}, [gradientStops]);
|
||||
|
||||
const currentGradientStyle = React.useMemo(
|
||||
() => ({
|
||||
backgroundImage: `linear-gradient(135deg, ${currentGradient[0]}, ${currentGradient[1]})`,
|
||||
}),
|
||||
[currentGradient],
|
||||
);
|
||||
|
||||
const prevGradientStyle = prevGradient
|
||||
? {
|
||||
backgroundImage: `linear-gradient(135deg, ${prevGradient[0]}, ${prevGradient[1]})`,
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className={styles.hero} key="animated-background">
|
||||
{prevGradientStyle && isTransitioning && (
|
||||
<div
|
||||
className={`${styles.gradientLayer} ${styles.gradientLayerPrevFadeOut}`}
|
||||
style={prevGradientStyle}
|
||||
onTransitionEnd={() => {
|
||||
setPrevGradient(null);
|
||||
setIsTransitioning(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`${styles.gradientLayer} ${isActive ? styles.gradientLayerActive : ''}`.trim()}
|
||||
style={currentGradientStyle}
|
||||
/>
|
||||
{circles.map((circle, index) => {
|
||||
const { position, size, color, opacity, blur, amplitude = 48, duration = 15, delay = 0 } = circle;
|
||||
|
||||
const moveX = position === 'bottom-left' ? amplitude : -amplitude;
|
||||
const moveY = position === 'bottom-left' ? -amplitude * 0.6 : amplitude * 0.6;
|
||||
|
||||
const circleStyle: CircleStyles = {
|
||||
width: size,
|
||||
height: size,
|
||||
background: color,
|
||||
opacity: opacity ?? 0.9,
|
||||
filter: blur ? `blur(${blur}px)` : undefined,
|
||||
'--circle-move-x': `${moveX}px`,
|
||||
'--circle-move-y': `${moveY}px`,
|
||||
'--circle-duration': `${duration}s`,
|
||||
'--circle-delay': `${delay}s`,
|
||||
};
|
||||
|
||||
const defaultOffset = -size / 2;
|
||||
const offsetX = circle.offsetX ?? 0;
|
||||
const offsetY = circle.offsetY ?? 0;
|
||||
|
||||
if (position === 'bottom-left') {
|
||||
circleStyle.left = `${defaultOffset + offsetX}px`;
|
||||
circleStyle.bottom = `${defaultOffset + offsetY}px`;
|
||||
} else {
|
||||
circleStyle.right = `${defaultOffset + offsetX}px`;
|
||||
circleStyle.top = `${defaultOffset + offsetY}px`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`circle-${index}-${position}`}
|
||||
className={styles.circle}
|
||||
style={circleStyle}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SlideConfig } from '@app/types/types';
|
||||
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
|
||||
import { DesktopInstallTitle, type OSOption } from '@app/components/onboarding/slides/DesktopInstallTitle';
|
||||
|
||||
export type { OSOption };
|
||||
|
||||
interface DesktopInstallSlideProps {
|
||||
osLabel: string;
|
||||
osUrl: string;
|
||||
osOptions?: OSOption[];
|
||||
onDownloadUrlChange?: (url: string) => void;
|
||||
}
|
||||
|
||||
const DesktopInstallBody = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<span>
|
||||
{t(
|
||||
'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.',
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default function DesktopInstallSlide({
|
||||
osLabel,
|
||||
osUrl,
|
||||
osOptions = [],
|
||||
onDownloadUrlChange,
|
||||
}: DesktopInstallSlideProps): SlideConfig {
|
||||
|
||||
return {
|
||||
key: 'desktop-install',
|
||||
title: (
|
||||
<DesktopInstallTitle
|
||||
osLabel={osLabel}
|
||||
osUrl={osUrl}
|
||||
osOptions={osOptions || []}
|
||||
onDownloadUrlChange={onDownloadUrlChange}
|
||||
/>
|
||||
),
|
||||
body: <DesktopInstallBody />,
|
||||
downloadUrl: osUrl,
|
||||
background: {
|
||||
gradientStops: ['#2563EB', '#0EA5E9'],
|
||||
circles: UNIFIED_CIRCLE_CONFIG,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Menu, ActionIcon } from '@mantine/core';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
|
||||
export interface OSOption {
|
||||
label: string;
|
||||
url: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface DesktopInstallTitleProps {
|
||||
osLabel: string;
|
||||
osUrl: string;
|
||||
osOptions: OSOption[];
|
||||
onDownloadUrlChange?: (url: string) => void;
|
||||
}
|
||||
|
||||
export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
|
||||
osLabel,
|
||||
osUrl,
|
||||
osOptions,
|
||||
onDownloadUrlChange
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedOsUrl, setSelectedOsUrl] = React.useState<string>(osUrl);
|
||||
|
||||
React.useEffect(() => {
|
||||
setSelectedOsUrl(osUrl);
|
||||
}, [osUrl]);
|
||||
|
||||
const handleOsSelect = React.useCallback((option: OSOption) => {
|
||||
setSelectedOsUrl(option.url);
|
||||
onDownloadUrlChange?.(option.url);
|
||||
}, [onDownloadUrlChange]);
|
||||
|
||||
const currentOsOption = osOptions.find(opt => opt.url === selectedOsUrl) ||
|
||||
(osOptions.length > 0 ? osOptions[0] : { label: osLabel, url: osUrl });
|
||||
|
||||
const displayLabel = currentOsOption.label || osLabel;
|
||||
const title = displayLabel
|
||||
? t('onboarding.desktopInstall.titleWithOs', 'Download for {{osLabel}}', { osLabel: displayLabel })
|
||||
: t('onboarding.desktopInstall.title', 'Download');
|
||||
|
||||
// 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={{ 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.Target>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
size="sm"
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'inherit',
|
||||
padding: 0
|
||||
}}
|
||||
>
|
||||
<ExpandMoreIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{osOptions.map((option) => {
|
||||
const isSelected = option.url === selectedOsUrl;
|
||||
return (
|
||||
<Menu.Item
|
||||
key={option.url}
|
||||
onClick={() => handleOsSelect(option)}
|
||||
style={{
|
||||
backgroundColor: isSelected
|
||||
? 'light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))'
|
||||
: 'transparent',
|
||||
color: isSelected
|
||||
? 'light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))'
|
||||
: 'inherit',
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import React from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { SlideConfig, LicenseNotice } from '@app/types/types';
|
||||
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
|
||||
|
||||
interface PlanOverviewSlideProps {
|
||||
isAdmin: boolean;
|
||||
licenseNotice?: LicenseNotice;
|
||||
loginEnabled?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_FREE_TIER_LIMIT = 5;
|
||||
|
||||
const PlanOverviewTitle: React.FC<{ isAdmin: boolean }> = ({ isAdmin }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
{isAdmin
|
||||
? t('onboarding.planOverview.adminTitle', 'Admin Overview')
|
||||
: t('onboarding.planOverview.userTitle', 'Plan Overview')}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const AdminOverviewBody: React.FC<{ freeTierLimit: number; loginEnabled: boolean }> = ({
|
||||
freeTierLimit,
|
||||
loginEnabled,
|
||||
}) => {
|
||||
const adminBodyKey = loginEnabled
|
||||
? 'onboarding.planOverview.adminBodyLoginEnabled'
|
||||
: 'onboarding.planOverview.adminBodyLoginDisabled';
|
||||
|
||||
const defaultValue = loginEnabled
|
||||
? 'As an admin, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge.'
|
||||
: 'Once you enable login mode, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge.';
|
||||
|
||||
return (
|
||||
<Trans
|
||||
i18nKey={adminBodyKey}
|
||||
values={{ freeTierLimit }}
|
||||
components={{ strong: <strong /> }}
|
||||
defaults={defaultValue}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const UserOverviewBody: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span>
|
||||
{t(
|
||||
'onboarding.planOverview.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.",
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const PlanOverviewBody: React.FC<{ isAdmin: boolean; freeTierLimit: number; loginEnabled: boolean }> = ({
|
||||
isAdmin,
|
||||
freeTierLimit,
|
||||
loginEnabled,
|
||||
}) =>
|
||||
isAdmin ? <AdminOverviewBody freeTierLimit={freeTierLimit} loginEnabled={loginEnabled} /> : <UserOverviewBody />;
|
||||
|
||||
export default function PlanOverviewSlide({
|
||||
isAdmin,
|
||||
licenseNotice,
|
||||
loginEnabled = false,
|
||||
}: PlanOverviewSlideProps): SlideConfig {
|
||||
const freeTierLimit = licenseNotice?.freeTierLimit ?? DEFAULT_FREE_TIER_LIMIT;
|
||||
|
||||
return {
|
||||
key: isAdmin ? 'admin-overview' : 'plan-overview',
|
||||
title: <PlanOverviewTitle isAdmin={isAdmin} />,
|
||||
body: <PlanOverviewBody isAdmin={isAdmin} freeTierLimit={freeTierLimit} loginEnabled={loginEnabled} />,
|
||||
background: {
|
||||
gradientStops: isAdmin ? ['#4F46E5', '#0EA5E9'] : ['#F97316', '#EF4444'],
|
||||
circles: UNIFIED_CIRCLE_CONFIG,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { Select } from '@mantine/core';
|
||||
import { SlideConfig } from '@app/types/types';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
|
||||
import i18n from '@app/i18n';
|
||||
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
|
||||
|
||||
interface SecurityCheckSlideProps {
|
||||
selectedRole: 'admin' | 'user' | null;
|
||||
onRoleSelect: (role: 'admin' | 'user' | null) => void;
|
||||
}
|
||||
|
||||
export default function SecurityCheckSlide({
|
||||
selectedRole,
|
||||
onRoleSelect,
|
||||
}: SecurityCheckSlideProps): SlideConfig {
|
||||
return {
|
||||
key: 'security-check',
|
||||
title: 'Security Check',
|
||||
body: (
|
||||
<div className={styles.securitySlideContent}>
|
||||
<div className={styles.securityCard}>
|
||||
<div className={styles.securityAlertRow}>
|
||||
<LocalIcon icon="error" width={20} height={20} style={{ color: '#F04438', flexShrink: 0 }} />
|
||||
<span>{i18n.t('onboarding.securityCheck.message', 'The application has undergone significant changes recently. Your server admin\'s attention may be required. Please confirm your role to continue.')}</span>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
placeholder="Confirm your role"
|
||||
value={selectedRole}
|
||||
data={[
|
||||
{ value: 'admin', label: 'Admin' },
|
||||
{ value: 'user', label: 'User' },
|
||||
]}
|
||||
onChange={(value) => onRoleSelect((value as 'admin' | 'user') ?? null)}
|
||||
comboboxProps={{ withinPortal: true, zIndex: 5000 }}
|
||||
styles={{
|
||||
input: {
|
||||
height: 48,
|
||||
fontSize: 15,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
background: {
|
||||
gradientStops: ['#5B21B6', '#2563EB'],
|
||||
circles: UNIFIED_CIRCLE_CONFIG,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
import { SlideConfig, LicenseNotice } from '@app/types/types';
|
||||
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
|
||||
import i18n from '@app/i18n';
|
||||
|
||||
interface ServerLicenseSlideProps {
|
||||
licenseNotice?: LicenseNotice;
|
||||
}
|
||||
|
||||
const DEFAULT_FREE_TIER_LIMIT = 5;
|
||||
|
||||
export default function ServerLicenseSlide({ licenseNotice }: ServerLicenseSlideProps = {}): SlideConfig {
|
||||
const freeTierLimit = licenseNotice?.freeTierLimit ?? DEFAULT_FREE_TIER_LIMIT;
|
||||
const totalUsers = licenseNotice?.totalUsers ?? null;
|
||||
const isOverLimit = licenseNotice?.isOverLimit ?? false;
|
||||
const formattedTotalUsers = totalUsers != null ? totalUsers.toLocaleString() : null;
|
||||
const overLimitUserCopy = formattedTotalUsers ?? `more than ${freeTierLimit}`;
|
||||
const title = isOverLimit
|
||||
? i18n.t('onboarding.serverLicense.overLimitTitle', 'Server License Needed')
|
||||
: i18n.t('onboarding.serverLicense.freeTitle', 'Server License');
|
||||
const key = isOverLimit ? 'server-license-over-limit' : 'server-license';
|
||||
|
||||
const overLimitBody = (
|
||||
<Trans
|
||||
i18nKey="onboarding.serverLicense.overLimitBody"
|
||||
values={{ freeTierLimit, overLimitUserCopy }}
|
||||
components={{
|
||||
strong: <strong />,
|
||||
}}
|
||||
defaults="Our licensing permits up to <strong>{{freeTierLimit}}</strong> users for free per server. You have <strong>{{overLimitUserCopy}}</strong> Stirling users. To continue uninterrupted, upgrade to the Stirling Server plan - <strong>unlimited seats</strong>, PDF text editing, and full admin control for $99/server/mo."
|
||||
/>
|
||||
);
|
||||
|
||||
const freeBody = (
|
||||
<Trans
|
||||
i18nKey="onboarding.serverLicense.freeBody"
|
||||
values={{ freeTierLimit }}
|
||||
components={{
|
||||
strong: <strong />,
|
||||
}}
|
||||
defaults="Our <strong>Open-Core</strong> licensing permits up to <strong>{{freeTierLimit}}</strong> users for free per server. To scale uninterrupted and get early access to our new <strong>PDF text editing tool</strong>, we recommend the Stirling Server plan - full editing and <strong>unlimited seats</strong> for $99/server/mo."
|
||||
/>
|
||||
);
|
||||
|
||||
const body = isOverLimit ? overLimitBody : freeBody;
|
||||
|
||||
return {
|
||||
key,
|
||||
title,
|
||||
body,
|
||||
background: {
|
||||
gradientStops: isOverLimit ? ['#F472B6', '#8B5CF6'] : ['#F97316', '#F59E0B'],
|
||||
circles: UNIFIED_CIRCLE_CONFIG,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { useTranslation, Trans } from 'react-i18next';
|
||||
import { SlideConfig } from '@app/types/types';
|
||||
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
|
||||
import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig';
|
||||
|
||||
function WelcomeSlideTitle() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<span className={styles.welcomeTitleContainer}>
|
||||
{t('onboarding.welcomeSlide.title', 'Welcome to Stirling')}
|
||||
<span className={styles.v2Badge}>V2</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const WelcomeSlideBody = () => (
|
||||
<span>
|
||||
<Trans
|
||||
i18nKey="onboarding.welcomeSlide.body"
|
||||
components={{ strong: <strong /> }}
|
||||
defaults="Stirling PDF is now ready for teams of all sizes. This update includes a new layout, powerful new admin capabilities, and our most requested feature - <strong>Edit Text</strong>."
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
|
||||
export default function WelcomeSlide(): SlideConfig {
|
||||
return {
|
||||
key: 'welcome',
|
||||
title: <WelcomeSlideTitle />,
|
||||
body: <WelcomeSlideBody />,
|
||||
background: {
|
||||
gradientStops: ['#7C3AED', '#EC4899'],
|
||||
circles: UNIFIED_CIRCLE_CONFIG,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { AnimatedCircleConfig } from '@app/types/types';
|
||||
|
||||
/**
|
||||
* Unified circle background configuration used across all onboarding slides.
|
||||
* Only gradient colors change between slides, creating smooth transitions.
|
||||
*/
|
||||
export const UNIFIED_CIRCLE_CONFIG: AnimatedCircleConfig[] = [
|
||||
{
|
||||
position: 'bottom-left',
|
||||
size: 270,
|
||||
color: 'rgba(255, 255, 255, 0.25)',
|
||||
opacity: 0.9,
|
||||
amplitude: 24,
|
||||
duration: 4.5,
|
||||
offsetX: 18,
|
||||
offsetY: 14,
|
||||
},
|
||||
{
|
||||
position: 'top-right',
|
||||
size: 300,
|
||||
color: 'rgba(255, 255, 255, 0.2)',
|
||||
opacity: 0.9,
|
||||
amplitude: 28,
|
||||
duration: 4.5,
|
||||
delay: 0.5,
|
||||
offsetX: 24,
|
||||
offsetY: 18,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export const addGlowToElements = (selectors: string[]) => {
|
||||
selectors.forEach((selector) => {
|
||||
const element = document.querySelector(selector);
|
||||
if (element) {
|
||||
if (selector === '[data-tour="settings-content-area"]') {
|
||||
element.classList.add('tour-content-glow');
|
||||
} else {
|
||||
element.classList.add('tour-nav-glow');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const removeAllGlows = () => {
|
||||
document.querySelectorAll('.tour-content-glow').forEach((el) => el.classList.remove('tour-content-glow'));
|
||||
document.querySelectorAll('.tour-nav-glow').forEach((el) => el.classList.remove('tour-nav-glow'));
|
||||
};
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
export enum TourStep {
|
||||
ALL_TOOLS,
|
||||
SELECT_CROP_TOOL,
|
||||
TOOL_INTERFACE,
|
||||
FILES_BUTTON,
|
||||
FILE_SOURCES,
|
||||
WORKBENCH,
|
||||
VIEW_SWITCHER,
|
||||
VIEWER,
|
||||
PAGE_EDITOR,
|
||||
ACTIVE_FILES,
|
||||
FILE_CHECKBOX,
|
||||
SELECT_CONTROLS,
|
||||
CROP_SETTINGS,
|
||||
RUN_BUTTON,
|
||||
RESULTS,
|
||||
FILE_REPLACEMENT,
|
||||
PIN_BUTTON,
|
||||
WRAP_UP,
|
||||
}
|
||||
|
||||
export enum AdminTourStep {
|
||||
WELCOME,
|
||||
CONFIG_BUTTON,
|
||||
SETTINGS_OVERVIEW,
|
||||
TEAMS_AND_USERS,
|
||||
SYSTEM_CUSTOMIZATION,
|
||||
DATABASE_SECTION,
|
||||
CONNECTIONS_SECTION,
|
||||
ADMIN_TOOLS,
|
||||
WRAP_UP,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { StepType } from '@reactour/tour';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { TourStep } from '@app/components/onboarding/tourSteps';
|
||||
|
||||
interface UserStepActions {
|
||||
saveWorkbenchState: () => void;
|
||||
closeFilesModal: () => void;
|
||||
backToAllTools: () => void;
|
||||
selectCropTool: () => void;
|
||||
loadSampleFile: () => void;
|
||||
switchToViewer: () => void;
|
||||
switchToPageEditor: () => void;
|
||||
switchToActiveFiles: () => void;
|
||||
selectFirstFile: () => void;
|
||||
pinFile: () => void;
|
||||
modifyCropSettings: () => void;
|
||||
executeTool: () => void;
|
||||
openFilesModal: () => void;
|
||||
}
|
||||
|
||||
interface CreateUserStepsConfigArgs {
|
||||
t: TFunction;
|
||||
actions: UserStepActions;
|
||||
}
|
||||
|
||||
export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs): Record<TourStep, StepType> {
|
||||
const {
|
||||
saveWorkbenchState,
|
||||
closeFilesModal,
|
||||
backToAllTools,
|
||||
selectCropTool,
|
||||
loadSampleFile,
|
||||
switchToViewer,
|
||||
switchToPageEditor,
|
||||
switchToActiveFiles,
|
||||
selectFirstFile,
|
||||
pinFile,
|
||||
modifyCropSettings,
|
||||
executeTool,
|
||||
openFilesModal,
|
||||
} = actions;
|
||||
|
||||
return {
|
||||
[TourStep.ALL_TOOLS]: {
|
||||
selector: '[data-tour="tool-panel"]',
|
||||
content: t('onboarding.allTools', 'This is the <strong>Tools</strong> panel, where you can browse and select from all available PDF tools.'),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
action: () => {
|
||||
saveWorkbenchState();
|
||||
closeFilesModal();
|
||||
backToAllTools();
|
||||
},
|
||||
},
|
||||
[TourStep.SELECT_CROP_TOOL]: {
|
||||
selector: '[data-tour="tool-button-crop"]',
|
||||
content: t('onboarding.selectCropTool', "Let's select the <strong>Crop</strong> tool to demonstrate how to use one of the tools."),
|
||||
position: 'right',
|
||||
padding: 0,
|
||||
actionAfter: () => selectCropTool(),
|
||||
},
|
||||
[TourStep.TOOL_INTERFACE]: {
|
||||
selector: '[data-tour="tool-panel"]',
|
||||
content: t('onboarding.toolInterface', "This is the <strong>Crop</strong> tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet."),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
},
|
||||
[TourStep.FILES_BUTTON]: {
|
||||
selector: '[data-tour="files-button"]',
|
||||
content: t('onboarding.filesButton', "The <strong>Files</strong> button on the Quick Access bar allows you to upload PDFs to use the tools on."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
action: () => openFilesModal(),
|
||||
},
|
||||
[TourStep.FILE_SOURCES]: {
|
||||
selector: '[data-tour="file-sources"]',
|
||||
content: t('onboarding.fileSources', "You can upload new files or access recent files from here. For the tour, we'll just use a sample file."),
|
||||
position: 'right',
|
||||
padding: 0,
|
||||
actionAfter: () => {
|
||||
loadSampleFile();
|
||||
closeFilesModal();
|
||||
},
|
||||
},
|
||||
[TourStep.WORKBENCH]: {
|
||||
selector: '[data-tour="workbench"]',
|
||||
content: t('onboarding.workbench', 'This is the <strong>Workbench</strong> - the main area where you view and edit your PDFs.'),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
},
|
||||
[TourStep.VIEW_SWITCHER]: {
|
||||
selector: '[data-tour="view-switcher"]',
|
||||
content: t('onboarding.viewSwitcher', 'Use these controls to select how you want to view your PDFs.'),
|
||||
position: 'bottom',
|
||||
padding: 0,
|
||||
},
|
||||
[TourStep.VIEWER]: {
|
||||
selector: '[data-tour="workbench"]',
|
||||
content: t('onboarding.viewer', "The <strong>Viewer</strong> lets you read and annotate your PDFs."),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
action: () => switchToViewer(),
|
||||
},
|
||||
[TourStep.PAGE_EDITOR]: {
|
||||
selector: '[data-tour="workbench"]',
|
||||
content: t('onboarding.pageEditor', "The <strong>Page Editor</strong> allows you to do various operations on the pages within your PDFs, such as reordering, rotating and deleting."),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
action: () => switchToPageEditor(),
|
||||
},
|
||||
[TourStep.ACTIVE_FILES]: {
|
||||
selector: '[data-tour="workbench"]',
|
||||
content: t('onboarding.activeFiles', "The <strong>Active Files</strong> view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process."),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
action: () => switchToActiveFiles(),
|
||||
},
|
||||
[TourStep.FILE_CHECKBOX]: {
|
||||
selector: '[data-tour="file-card-checkbox"]',
|
||||
content: t('onboarding.fileCheckbox', "Clicking one of the files selects it for processing. You can select multiple files for batch operations."),
|
||||
position: 'top',
|
||||
padding: 10,
|
||||
},
|
||||
[TourStep.SELECT_CONTROLS]: {
|
||||
selector: '[data-tour="right-rail-controls"]',
|
||||
highlightedSelectors: ['[data-tour="right-rail-controls"]', '[data-tour="right-rail-settings"]'],
|
||||
content: t('onboarding.selectControls', "The <strong>Right Rail</strong> contains buttons to quickly select/deselect all of your active PDFs, along with buttons to change the app's theme or language."),
|
||||
position: 'left',
|
||||
padding: 5,
|
||||
action: () => selectFirstFile(),
|
||||
},
|
||||
[TourStep.CROP_SETTINGS]: {
|
||||
selector: '[data-tour="crop-settings"]',
|
||||
content: t('onboarding.cropSettings', "Now that we've selected the file we want crop, we can configure the <strong>Crop</strong> tool to choose the area that we want to crop the PDF to."),
|
||||
position: 'left',
|
||||
padding: 10,
|
||||
action: () => modifyCropSettings(),
|
||||
},
|
||||
[TourStep.RUN_BUTTON]: {
|
||||
selector: '[data-tour="run-button"]',
|
||||
content: t('onboarding.runButton', "Once the tool has been configured, this button allows you to run the tool on all the selected PDFs."),
|
||||
position: 'top',
|
||||
padding: 10,
|
||||
actionAfter: () => executeTool(),
|
||||
},
|
||||
[TourStep.RESULTS]: {
|
||||
selector: '[data-tour="tool-panel"]',
|
||||
content: t('onboarding.results', "After the tool has finished running, the <strong>Review</strong> step will show a preview of the results in this panel, and allow you to undo the operation or download the file. "),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
},
|
||||
[TourStep.FILE_REPLACEMENT]: {
|
||||
selector: '[data-tour="file-card-checkbox"]',
|
||||
content: t('onboarding.fileReplacement', "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools."),
|
||||
position: 'left',
|
||||
padding: 10,
|
||||
},
|
||||
[TourStep.PIN_BUTTON]: {
|
||||
selector: '[data-tour="file-card-pin"]',
|
||||
content: t('onboarding.pinButton', "You can use the <strong>Pin</strong> button if you'd rather your files stay active after running tools on them."),
|
||||
position: 'left',
|
||||
padding: 10,
|
||||
action: () => pinFile(),
|
||||
},
|
||||
[TourStep.WRAP_UP]: {
|
||||
selector: '[data-tour="help-button"]',
|
||||
content: t('onboarding.wrapUp', "You're all set! You've learnt about the main areas of the app and how to use them. Click the <strong>Help</strong> button whenever you like to see this tour again."),
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user