onboarding slides

This commit is contained in:
EthanHealy01
2025-11-12 12:51:51 +00:00
parent 504c78926d
commit 7cf1d9d128
8 changed files with 424 additions and 42 deletions

View File

@@ -0,0 +1,84 @@
.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;
}
.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: 52px;
height: 52px;
animation: heroLogoRotate 0.25s ease forwards;
transform-origin: center;
}
.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);
}
}

View File

@@ -7,6 +7,12 @@ import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
import OnboardingStepper from '@app/components/onboarding/OnboardingStepper';
import { useOs } from '@app/hooks/useOs';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import WelcomeSlide from '@app/components/onboarding/slides/WelcomeSlide';
import DesktopInstallSlide from '@app/components/onboarding/slides/DesktopInstallSlide';
import PlanOverviewSlide from '@app/components/onboarding/slides/PlanOverviewSlide';
import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground';
import { SlideConfig } from '@app/components/onboarding/slides/types';
import styles from './InitialOnboardingModal.module.css';
interface InitialOnboardingModalProps {
opened: boolean;
@@ -54,39 +60,17 @@ export default function InitialOnboardingModal({ opened, onClose }: InitialOnboa
const goNext = () => setStep((s) => Math.min(totalSteps - 1, s + 1));
const goPrev = () => setStep((s) => Math.max(0, s - 1));
const titleByStep = [
'Welcome to Stirling',
os.label ? `Download for ${os.label}` : 'Download',
isAdmin ? 'Admin Overview' : 'Plan Overview',
];
// Get slide content from the slide components
const slides = React.useMemo<SlideConfig[]>(
() => [
WelcomeSlide(),
DesktopInstallSlide({ osLabel: os.label, osUrl: os.url }),
PlanOverviewSlide({ isAdmin }),
],
[isAdmin, os.label, os.url],
);
const bodyByStep: React.ReactNode[] = [
(
<span>
Stirling helps you read and edit PDFs privately. The app includes a simple <strong>Reader</strong> with basic editing tools and an advanced <strong>Editor</strong> with professional editing tools.
</span>
),
(
<span>
Stirling works best as a desktop app. You can use it offline, access documents faster, and make edits locally on your computer.
</span>
),
isAdmin ? (
<span>
As an admin, you can manage users, configure settings, and monitor server health. The first 5 people on your server get to use Stirling free of charge.
</span>
) : (
<span>
For the next <strong>30 days</strong>, youll enjoy <strong>unlimited Pro access</strong> to the Reader and the Editor. Afterwards, you can continue with the Reader for free or upgrade to keep the Editor too.
</span>
),
];
const imageByStep = [
'/branding/onboarding1.svg',
'/branding/onboarding2.svg',
'/branding/onboarding3.svg',
];
const currentSlide = slides[step];
// Buttons per step
const renderButtons = () => {
@@ -143,8 +127,9 @@ export default function InitialOnboardingModal({ opened, onClose }: InitialOnboa
</Group>
<Button
onClick={() => {
if (os.url) {
window.open(os.url, '_blank', 'noopener');
const downloadUrl = currentSlide.downloadUrl;
if (downloadUrl) {
window.open(downloadUrl, '_blank', 'noopener');
}
goNext();
}}
@@ -245,17 +230,25 @@ export default function InitialOnboardingModal({ opened, onClose }: InitialOnboa
}}
>
<Stack gap={0} style={{ background: 'var(--bg-surface)' }}>
<div style={{ width: '100%', height: 220, overflow: 'hidden' }}>
<img
src={imageByStep[step]}
alt={titleByStep[step]}
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
<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}`}>
<div className={styles.heroLogoCircle}>
<img src="/branding/StirlingPDFLogoNoTextLight.svg" alt="Stirling logo" />
</div>
</div>
</div>
<div style={{ padding: 24 }}>
<Stack gap={16}>
<div
key={`title-${currentSlide.key}`}
className={styles.title}
style={{
fontFamily: 'Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif',
fontWeight: 600,
@@ -263,7 +256,7 @@ export default function InitialOnboardingModal({ opened, onClose }: InitialOnboa
color: 'var(--onboarding-title)',
}}
>
{titleByStep[step]}
{currentSlide.title}
</div>
<div
@@ -275,8 +268,12 @@ export default function InitialOnboardingModal({ opened, onClose }: InitialOnboa
}}
>
{/* strong tags should match the title color */}
<div style={{ color: 'inherit' }}>
{bodyByStep[step]}
<div
key={`body-${currentSlide.key}`}
className={styles.bodyCopy}
style={{ color: 'inherit' }}
>
{currentSlide.body}
</div>
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
</div>

View File

@@ -0,0 +1,43 @@
.hero {
position: relative;
width: 100%;
height: 220px;
overflow: hidden;
border-radius: 0;
background-size: 180% 180%;
animation: gradientShift 18s ease-in-out infinite alternate;
}
.heroActive {}
.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;
}
@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);
}
}

View File

@@ -0,0 +1,76 @@
import React from 'react';
import styles from './AnimatedSlideBackground.module.css';
import { AnimatedSlideBackgroundProps } from './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,
slideKey,
}: AnimatedSlideBackgroundComponentProps) {
const gradientStyle = React.useMemo(
() => ({
backgroundImage: `linear-gradient(135deg, ${gradientStops[0]}, ${gradientStops[1]})`,
}),
[gradientStops],
);
return (
<div
className={`${styles.hero} ${isActive ? styles.heroActive : ''}`.trim()}
style={gradientStyle}
key={slideKey}
>
{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={`${slideKey}-circle-${index}`}
className={styles.circle}
style={circleStyle}
/>
);
})}
</div>
);
}

View File

@@ -0,0 +1,49 @@
import React from 'react';
import { SlideConfig } from './types';
interface DesktopInstallSlideProps {
osLabel: string;
osUrl: string;
}
export default function DesktopInstallSlide({ osLabel, osUrl }: DesktopInstallSlideProps): SlideConfig {
const title = osLabel ? `Download for ${osLabel}` : 'Download';
return {
key: 'desktop-install',
title,
body: (
<span>
Stirling works best as a desktop app. You can use it offline, access documents faster, and make edits locally on your computer.
</span>
),
downloadUrl: osUrl,
background: {
gradientStops: ['#2563EB', '#0EA5E9'],
circles: [
{
position: 'bottom-left',
size: 260,
color: 'rgba(255, 255, 255, 0.2)',
opacity: 0.88,
amplitude: 24,
duration: 11,
offsetX: 16,
offsetY: 12,
},
{
position: 'top-right',
size: 300,
color: 'rgba(28, 155, 235, 0.34)',
opacity: 0.86,
amplitude: 28,
duration: 12,
delay: 1,
offsetX: 20,
offsetY: 16,
},
],
},
};
}

View File

@@ -0,0 +1,49 @@
import React from 'react';
import { SlideConfig } from './types';
interface PlanOverviewSlideProps {
isAdmin: boolean;
}
export default function PlanOverviewSlide({ isAdmin }: PlanOverviewSlideProps): SlideConfig {
return {
key: isAdmin ? 'admin-overview' : 'plan-overview',
title: isAdmin ? 'Admin Overview' : 'Plan Overview',
body: isAdmin ? (
<span>
As an admin, you can manage users, configure settings, and monitor server health. The first 5 people on your server get to use Stirling free of charge.
</span>
) : (
<span>
For the next <strong>30 days</strong>, you'll enjoy <strong>unlimited Pro access</strong> to the Reader and the Editor. Afterwards, you can continue with the Reader for free or upgrade to keep the Editor too.
</span>
),
background: {
gradientStops: ['#F97316', '#EF4444'],
circles: [
{
position: 'bottom-left',
size: 260,
color: 'rgba(255, 255, 255, 0.25)',
opacity: 0.9,
amplitude: 26,
duration: 11,
offsetX: 18,
offsetY: 12,
},
{
position: 'top-right',
size: 300,
color: 'rgba(251, 191, 36, 0.4)',
opacity: 0.9,
amplitude: 30,
duration: 12,
delay: 1.4,
offsetX: 24,
offsetY: 18,
},
],
},
};
}

View File

@@ -0,0 +1,57 @@
import React from 'react';
import { SlideConfig } from './types';
export default function WelcomeSlide(): SlideConfig {
return {
key: 'welcome',
title: (
<span style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 12 }}>
Welcome to Stirling
<span
style={{
background: '#DBEFFF',
color: '#2A4BFF',
padding: '4px 12px',
borderRadius: 6,
fontSize: 14,
fontWeight: 600,
}}
>
V2
</span>
</span>
),
body: (
<span>
Stirling helps you read and edit PDFs privately. The app includes a simple <strong>Reader</strong> with basic editing tools and an advanced <strong>Editor</strong> with professional editing tools.
</span>
),
background: {
gradientStops: ['#7C3AED', '#EC4899'],
circles: [
{
position: 'bottom-left',
size: 260,
color: 'rgba(255, 255, 255, 0.25)',
opacity: 0.9,
amplitude: 24,
duration: 11,
offsetX: 18,
offsetY: 14,
},
{
position: 'top-right',
size: 300,
color: 'rgba(196, 181, 253, 0.4)',
opacity: 0.9,
amplitude: 28,
duration: 12,
delay: 1.2,
offsetX: 24,
offsetY: 18,
},
],
},
};
}

View File

@@ -0,0 +1,27 @@
import { ReactNode } from 'react';
export interface AnimatedCircleConfig {
size: number;
color: string;
opacity?: number;
blur?: number;
position: 'bottom-left' | 'top-right';
amplitude?: number;
duration?: number;
delay?: number;
offsetX?: number;
offsetY?: number;
}
export interface AnimatedSlideBackgroundProps {
gradientStops: [string, string];
circles: AnimatedCircleConfig[];
}
export interface SlideConfig {
key: string;
title: ReactNode;
body: ReactNode;
background: AnimatedSlideBackgroundProps;
downloadUrl?: string;
}