mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 02:54:06 +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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user