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 <77850077+Frooodle@users.noreply.github.com> Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user