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:
EthanHealy01
2025-11-25 13:45:02 +00:00
committed by GitHub
co-authored by Anthony Stirling Connor Yoh
parent 80f2980755
commit a8db2fda18
66 changed files with 5641 additions and 1248 deletions
+59 -18
View File
@@ -10,6 +10,7 @@ declare global {
show: (show?: boolean) => void;
acceptedCategory: (category: string) => boolean;
acceptedService: (serviceName: string, category: string) => boolean;
validConsent?: () => boolean;
};
}
}
@@ -19,26 +20,50 @@ interface CookieConsentConfig {
}
export const useCookieConsent = ({
analyticsEnabled = false
analyticsEnabled = false,
}: CookieConsentConfig = {}) => {
const { t } = useTranslation();
const { config } = useAppConfig();
const [isInitialized, setIsInitialized] = useState(false);
const [hasRespondedInternal, setHasRespondedInternal] = useState(false);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const markResponded = () => setHasRespondedInternal(true);
const removeConsentListeners = () => {
window.removeEventListener('cc:onFirstConsent', markResponded);
window.removeEventListener('cc:onConsent', markResponded);
window.removeEventListener('cc:onChange', markResponded);
};
window.addEventListener('cc:onFirstConsent', markResponded);
window.addEventListener('cc:onConsent', markResponded);
window.addEventListener('cc:onChange', markResponded);
if (analyticsEnabled) {
setHasRespondedInternal(window.CookieConsent?.validConsent?.() ?? false);
}
if (!analyticsEnabled) {
console.log('Cookie consent not enabled - analyticsEnabled is false');
return;
setHasRespondedInternal(false);
return () => {
removeConsentListeners();
};
}
// Prevent double initialization
if (window.CookieConsent) {
setIsInitialized(true);
// Force show the modal if it exists but isn't visible
setTimeout(() => {
window.CookieConsent?.show();
}, 100);
return;
if (window.CookieConsent.validConsent?.()) {
markResponded();
}
return () => {
removeConsentListeners();
};
}
// Load the cookie consent CSS files first
@@ -116,7 +141,7 @@ export const useCookieConsent = ({
// Initialize cookie consent with full configuration
try {
window.CookieConsent.run({
autoShow: true,
autoShow: false,
hideFromBots: false,
guiOptions: {
consentModal: {
@@ -202,18 +227,21 @@ export const useCookieConsent = ({
}
}
}
}
},
onFirstConsent: markResponded,
onConsent: markResponded,
onChange: markResponded,
});
// Force show after initialization
setTimeout(() => {
window.CookieConsent?.show();
}, 200);
} catch (error) {
console.error('Error initializing CookieConsent:', error);
}
setIsInitialized(true);
if (window.CookieConsent?.validConsent?.()) {
markResponded();
} else {
setHasRespondedInternal(false);
}
setIsInitialized(true);
}, 100); // Small delay to ensure DOM is ready
};
@@ -224,6 +252,8 @@ export const useCookieConsent = ({
document.head.appendChild(script);
return () => {
// Cleanup event listeners
removeConsentListeners();
// Cleanup script and CSS when component unmounts
if (document.head.contains(script)) {
document.head.removeChild(script);
@@ -237,11 +267,17 @@ export const useCookieConsent = ({
};
}, [analyticsEnabled, config?.enablePosthog, config?.enableScarf, t]);
const showCookiePreferences = () => {
const showCookieConsent = useCallback(() => {
if (isInitialized && window.CookieConsent) {
window.CookieConsent?.show();
}
}, [isInitialized]);
const showCookiePreferences = useCallback(() => {
if (isInitialized && window.CookieConsent) {
window.CookieConsent?.show(true);
}
};
}, [isInitialized]);
const isServiceAccepted = useCallback((service: string, category: string): boolean => {
if (typeof window === 'undefined' || !window.CookieConsent) {
@@ -250,8 +286,13 @@ export const useCookieConsent = ({
return window.CookieConsent.acceptedService(service, category);
}, []);
const effectiveHasResponded = analyticsEnabled ? hasRespondedInternal : true;
return {
showCookieConsent,
showCookiePreferences,
isServiceAccepted
isServiceAccepted,
isInitialized,
hasResponded: effectiveHasResponded,
};
};
@@ -0,0 +1,53 @@
import { useEffect, useState } from 'react';
import {
UPGRADE_BANNER_ALERT_EVENT,
type UpgradeBannerAlertPayload,
} from '@app/constants/events';
export interface LicenseAlertState {
active: boolean;
audience: 'admin' | 'user' | null;
totalUsers: number | null;
freeTierLimit: number;
}
const defaultState: LicenseAlertState = {
active: false,
audience: null,
totalUsers: null,
freeTierLimit: 5,
};
export function useLicenseAlert(): LicenseAlertState {
const [state, setState] = useState<LicenseAlertState>(defaultState);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const handleAlert = (event: Event) => {
const detail = (event as CustomEvent<UpgradeBannerAlertPayload>).detail;
if (detail?.active) {
setState({
active: true,
audience: detail.audience ?? 'user',
totalUsers:
typeof detail.totalUsers === 'number' ? detail.totalUsers : null,
freeTierLimit: detail.freeTierLimit ?? 5,
});
} else {
setState(defaultState);
}
};
window.addEventListener(UPGRADE_BANNER_ALERT_EVENT, handleAlert as EventListener);
return () => {
window.removeEventListener(UPGRADE_BANNER_ALERT_EVENT, handleAlert as EventListener);
};
}, []);
return state;
}
+89
View File
@@ -0,0 +1,89 @@
import { useEffect, useState } from 'react';
export type OS =
| 'windows'
| 'mac-intel'
| 'mac-apple'
| 'linux-x64'
| 'linux-arm64'
| 'ios'
| 'android'
| 'unknown';
function parseUA(ua: string): OS {
const uaLower = ua.toLowerCase();
// iOS (includes iPadOS masquerading as Mac in some cases)
const isIOS = /iphone|ipad|ipod/.test(uaLower) || (ua.includes('Macintosh') && typeof window !== 'undefined' && 'ontouchstart' in window);
if (isIOS) return 'ios';
if (/android/.test(uaLower)) return 'android';
if (/windows nt/.test(uaLower)) return 'windows';
if (/mac os x/.test(uaLower)) {
// Default to Intel; refine via hints below
let detected: OS = 'mac-intel';
// Safari on Apple Silicon sometimes exposes both tokens
if (ua.includes('Apple') && ua.includes('ARM')) {
detected = 'mac-apple';
}
return detected; // will be further refined via Client Hints if available
}
if (/linux|x11/.test(uaLower)) return 'linux-x64';
return 'unknown';
}
export function useOs(): OS {
const [os, setOs] = useState<OS>('unknown');
useEffect(() => {
let cancelled = false;
async function detect() {
// Start with UA fallback
let detected: OS = parseUA(navigator.userAgent);
// Try Client Hints for better platform + architecture
const uaData = (navigator as any).userAgentData;
if (uaData?.getHighEntropyValues) {
try {
const { platform, architecture, bitness } = await uaData.getHighEntropyValues([
'platform',
'architecture',
'bitness',
'platformVersion',
]);
const plat = (platform || '').toLowerCase();
if (plat.includes('windows')) detected = 'windows';
else if (plat.includes('ios')) detected = 'ios';
else if (plat.includes('android')) detected = 'android';
else if (plat.includes('mac')) {
// CH “architecture” is often "arm" on Apple Silicon
detected = architecture?.toLowerCase().includes('arm') ? 'mac-apple' : 'mac-intel';
} else if (plat.includes('linux') || plat.includes('chrome os')) {
const archLower = (architecture || '').toLowerCase();
const isArm = archLower.includes('arm') || (bitness === '32' && /aarch|arm/.test(architecture || ''));
detected = isArm ? 'linux-arm64' : 'linux-x64';
}
} catch {
// ignore
}
} else {
// Heuristic Apple Silicon from UA when no Client Hints (Safari): uncertain, prefer not to guess
// Keep detected as-is (often 'mac-intel').
}
if (!cancelled) setOs(detected);
}
detect();
return () => {
cancelled = true;
};
}, []);
return os;
}
@@ -0,0 +1,157 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useAppConfig } from '@app/contexts/AppConfigContext';
const SELF_REPORTED_ADMIN_KEY = 'stirling-self-reported-admin';
const FREE_TIER_LIMIT = 5;
type UserCountSource = 'admin' | 'estimate' | 'unknown';
export type ServerScenarioKey =
| 'unknown'
| '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';
export interface ServerExperienceValue {
loginEnabled: boolean;
configIsAdmin: boolean;
effectiveIsAdmin: boolean;
selfReportedAdmin: boolean;
isAuthenticated: boolean;
isNewServer: boolean | null;
isNewUser: boolean | null;
premiumEnabled: boolean | null;
license: string | undefined;
runningProOrHigher: boolean | undefined;
runningEE: boolean | undefined;
hasPaidLicense: boolean;
licenseKeyValid: boolean | null;
licenseLoading: boolean;
licenseInfoAvailable: boolean;
totalUsers: number | null;
weeklyActiveUsers: number | null;
userCountLoading: boolean;
userCountError: string | null;
userCountSource: UserCountSource;
userCountResolved: boolean;
overFreeTierLimit: boolean | null;
freeTierLimit: number;
refreshUserCounts: () => Promise<void>;
setSelfReportedAdmin: (value: boolean) => void;
scenarioKey: ServerScenarioKey;
}
function readSelfReportedAdmin(): boolean {
if (typeof window === 'undefined') {
return false;
}
try {
return window.localStorage.getItem(SELF_REPORTED_ADMIN_KEY) === 'true';
} catch {
return false;
}
}
export function useServerExperience(): ServerExperienceValue {
const { config } = useAppConfig();
const [selfReportedAdmin, setSelfReportedAdminState] = useState<boolean>(readSelfReportedAdmin);
const loginEnabled = config?.enableLogin !== false;
const configIsAdmin = Boolean(config?.isAdmin);
const effectiveIsAdmin = configIsAdmin || (!loginEnabled && selfReportedAdmin);
const hasPaidLicense = config?.license === 'PRO' || config?.license === 'ENTERPRISE';
const setSelfReportedAdmin = useCallback((value: boolean) => {
setSelfReportedAdminState(value);
if (typeof window === 'undefined') {
return;
}
try {
if (value) {
window.localStorage.setItem(SELF_REPORTED_ADMIN_KEY, 'true');
} else {
window.localStorage.removeItem(SELF_REPORTED_ADMIN_KEY);
}
} catch {
// ignore storage write failures
}
}, []);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const handleStorage = (event: StorageEvent) => {
if (event.key === SELF_REPORTED_ADMIN_KEY) {
setSelfReportedAdminState(event.newValue === 'true');
}
};
window.addEventListener('storage', handleStorage);
return () => window.removeEventListener('storage', handleStorage);
}, []);
useEffect(() => {
if (config?.isNewServer && !loginEnabled && !selfReportedAdmin) {
setSelfReportedAdmin(true);
}
}, [config?.isNewServer, loginEnabled, selfReportedAdmin, setSelfReportedAdmin]);
const scenarioKey: ServerScenarioKey = useMemo(() => {
if (hasPaidLicense) {
return 'licensed';
}
return 'unknown';
}, [hasPaidLicense]);
const value = useMemo<ServerExperienceValue>(() => ({
loginEnabled,
configIsAdmin,
effectiveIsAdmin,
selfReportedAdmin,
isAuthenticated: false,
isNewServer: config?.isNewServer ?? null,
isNewUser: config?.isNewUser ?? null,
premiumEnabled: config?.premiumEnabled ?? null,
license: config?.license,
runningProOrHigher: config?.runningProOrHigher,
runningEE: config?.runningEE,
hasPaidLicense,
licenseKeyValid: config?.premiumEnabled ?? null,
licenseLoading: false,
licenseInfoAvailable: false,
totalUsers: null,
weeklyActiveUsers: null,
userCountLoading: false,
userCountError: null,
userCountSource: 'unknown',
userCountResolved: false,
overFreeTierLimit: null,
freeTierLimit: FREE_TIER_LIMIT,
refreshUserCounts: async () => {},
setSelfReportedAdmin,
scenarioKey,
}), [
config?.isNewServer,
config?.isNewUser,
config?.license,
config?.premiumEnabled,
config?.runningEE,
config?.runningProOrHigher,
configIsAdmin,
effectiveIsAdmin,
hasPaidLicense,
loginEnabled,
scenarioKey,
selfReportedAdmin,
setSelfReportedAdmin,
]);
return value;
}
@@ -5,7 +5,8 @@ export function useShouldShowWelcomeModal(): boolean {
const { preferences } = usePreferences();
const isMobile = useIsMobile();
return !preferences.hasCompletedOnboarding
return preferences.hasSeenIntroOnboarding
&& !preferences.hasCompletedOnboarding
&& preferences.toolPanelModePromptSeen
&& !isMobile;
}