mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 19:10:47 +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
@@ -1,143 +1,391 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Group, Text, Button, ActionIcon, Paper } from '@mantine/core';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useCookieConsentContext } from '@app/contexts/CookieConsentContext';
|
||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
||||
import { useCheckout } from '@app/contexts/CheckoutContext';
|
||||
import { useLicense } from '@app/contexts/LicenseContext';
|
||||
import { mapLicenseToTier } from '@app/services/licenseService';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { isSupabaseConfigured } from '@app/services/supabaseClient';
|
||||
import { InfoBanner } from '@app/components/shared/InfoBanner';
|
||||
import {
|
||||
ONBOARDING_SESSION_BLOCK_KEY,
|
||||
ONBOARDING_SESSION_EVENT,
|
||||
SERVER_LICENSE_REQUEST_EVENT,
|
||||
type ServerLicenseRequestPayload,
|
||||
UPGRADE_BANNER_TEST_EVENT,
|
||||
type UpgradeBannerTestPayload,
|
||||
type UpgradeBannerTestScenario,
|
||||
UPGRADE_BANNER_ALERT_EVENT,
|
||||
} from '@core/constants/events';
|
||||
import { useServerExperience } from '@app/hooks/useServerExperience';
|
||||
|
||||
/**
|
||||
* UpgradeBanner - Dismissable top banner encouraging users to upgrade
|
||||
*
|
||||
* This component demonstrates:
|
||||
* - How to check authentication status with useAuth()
|
||||
* - How to check license status with licenseService
|
||||
* - How to open checkout modal with useCheckout()
|
||||
* - How to persist dismissal state with localStorage
|
||||
*
|
||||
* To remove this banner:
|
||||
* 1. Remove the import and component from AppProviders.tsx
|
||||
* 2. Delete this file
|
||||
*/
|
||||
const FRIENDLY_LAST_SEEN_KEY = 'upgradeBannerFriendlyLastShownAt';
|
||||
const WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const UpgradeBanner: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { hasResponded: cookieChoiceMade } = useCookieConsentContext();
|
||||
const { isOpen: tourOpen } = useOnboarding();
|
||||
const { openCheckout } = useCheckout();
|
||||
const { licenseInfo, loading: licenseLoading } = useLicense();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const {
|
||||
totalUsers,
|
||||
userCountResolved,
|
||||
userCountLoading,
|
||||
effectiveIsAdmin: configIsAdmin,
|
||||
hasPaidLicense,
|
||||
licenseLoading,
|
||||
freeTierLimit,
|
||||
overFreeTierLimit,
|
||||
scenarioKey,
|
||||
} = useServerExperience();
|
||||
const [sessionBlocked, setSessionBlocked] = useState(true);
|
||||
const [friendlyVisible, setFriendlyVisible] = useState(false);
|
||||
const isDev = import.meta.env.DEV;
|
||||
const [testScenario, setTestScenario] = useState<UpgradeBannerTestScenario>(null);
|
||||
|
||||
// Check if user should see the banner
|
||||
// Track onboarding session flag so we don't show banner if onboarding ran this load
|
||||
useEffect(() => {
|
||||
// Don't show if not logged in
|
||||
if (!user) {
|
||||
setIsVisible(false);
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't show if Supabase is not configured (no checkout available)
|
||||
if (!isSupabaseConfigured) {
|
||||
setIsVisible(false);
|
||||
const evaluateBlock = () => {
|
||||
const blocked = window.sessionStorage.getItem(ONBOARDING_SESSION_BLOCK_KEY) === 'true';
|
||||
setSessionBlocked(blocked);
|
||||
};
|
||||
|
||||
evaluateBlock();
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
evaluateBlock();
|
||||
}, 1000);
|
||||
|
||||
const handleOnboardingEvent = () => {
|
||||
evaluateBlock();
|
||||
};
|
||||
|
||||
window.addEventListener(ONBOARDING_SESSION_EVENT, handleOnboardingEvent as EventListener);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener(ONBOARDING_SESSION_EVENT, handleOnboardingEvent as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDev || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't show while license is loading
|
||||
if (licenseLoading) {
|
||||
const handleTestEvent = (event: Event) => {
|
||||
const { detail } = event as CustomEvent<UpgradeBannerTestPayload>;
|
||||
setTestScenario(detail?.scenario ?? null);
|
||||
|
||||
if (detail?.scenario === 'friendly') {
|
||||
setFriendlyVisible(true);
|
||||
} else if (!detail?.scenario) {
|
||||
setFriendlyVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(UPGRADE_BANNER_TEST_EVENT, handleTestEvent as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener(UPGRADE_BANNER_TEST_EVENT, handleTestEvent as EventListener);
|
||||
};
|
||||
}, [isDev]);
|
||||
|
||||
const isAdmin = configIsAdmin;
|
||||
|
||||
const scenario = isDev ? testScenario : null;
|
||||
const scenarioIsFriendly = scenario === 'friendly';
|
||||
const scenarioIsUrgentUser = scenario === 'urgent-user';
|
||||
|
||||
const userCountKnown = typeof totalUsers === 'number';
|
||||
const isUnderLimit = userCountKnown ? totalUsers < freeTierLimit : null;
|
||||
const isOverLimit = userCountKnown ? totalUsers > freeTierLimit : overFreeTierLimit;
|
||||
const baseTotalUsersLoaded = userCountResolved && !userCountLoading;
|
||||
|
||||
const scenarioProvidesInfo =
|
||||
scenarioKey && scenarioKey !== 'unknown' && scenarioKey !== 'licensed';
|
||||
const derivedIsAdmin = scenarioProvidesInfo
|
||||
? scenarioKey!.includes('admin')
|
||||
: isAdmin;
|
||||
const derivedHasPaidLicense =
|
||||
scenarioKey === 'licensed'
|
||||
? true
|
||||
: scenarioKey === 'unknown'
|
||||
? hasPaidLicense
|
||||
: false;
|
||||
const derivedIsUnderLimit = scenarioProvidesInfo
|
||||
? scenarioKey!.includes('under-limit')
|
||||
: isUnderLimit === true;
|
||||
const derivedIsOverLimit = scenarioProvidesInfo
|
||||
? scenarioKey!.includes('over-limit')
|
||||
: isOverLimit === true;
|
||||
|
||||
const effectiveIsAdmin = scenario
|
||||
? scenarioIsUrgentUser
|
||||
? false
|
||||
: true
|
||||
: derivedIsAdmin;
|
||||
const effectiveTotalUsers =
|
||||
scenario != null ? (scenarioIsFriendly ? 3 : 8) : totalUsers;
|
||||
const effectiveTotalUsersLoaded = scenario != null ? true : baseTotalUsersLoaded;
|
||||
const effectiveHasPaidLicense = scenario != null ? false : derivedHasPaidLicense;
|
||||
const effectiveIsUnderLimit =
|
||||
scenario != null ? scenarioIsFriendly : derivedIsUnderLimit;
|
||||
const effectiveIsOverLimit =
|
||||
scenario != null ? !scenarioIsFriendly : derivedIsOverLimit;
|
||||
|
||||
const isDerivedAdmin = scenario
|
||||
? !scenarioIsUrgentUser
|
||||
: scenarioKey === 'login-user-over-limit-no-license'
|
||||
? false
|
||||
: effectiveIsAdmin;
|
||||
|
||||
const shouldShowFriendlyBase = Boolean(
|
||||
isDerivedAdmin &&
|
||||
!effectiveHasPaidLicense &&
|
||||
effectiveIsUnderLimit &&
|
||||
effectiveTotalUsersLoaded,
|
||||
);
|
||||
const shouldShowUrgentBase = Boolean(
|
||||
!effectiveHasPaidLicense &&
|
||||
effectiveTotalUsersLoaded &&
|
||||
(effectiveIsOverLimit || scenarioKey === 'login-user-over-limit-no-license'),
|
||||
);
|
||||
|
||||
const shouldEvaluateFriendly = scenario
|
||||
? scenarioIsFriendly
|
||||
: Boolean(
|
||||
shouldShowFriendlyBase &&
|
||||
!licenseLoading &&
|
||||
effectiveTotalUsersLoaded &&
|
||||
cookieChoiceMade &&
|
||||
!tourOpen &&
|
||||
!sessionBlocked,
|
||||
);
|
||||
const shouldEvaluateUrgent = scenario
|
||||
? Boolean(scenario && !scenarioIsFriendly)
|
||||
: Boolean(
|
||||
shouldShowUrgentBase &&
|
||||
!licenseLoading &&
|
||||
cookieChoiceMade &&
|
||||
!tourOpen &&
|
||||
!sessionBlocked,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if banner was dismissed
|
||||
const dismissed = localStorage.getItem('upgradeBannerDismissed');
|
||||
if (dismissed === 'true') {
|
||||
setIsVisible(false);
|
||||
if (!shouldShowFriendlyBase && effectiveTotalUsersLoaded) {
|
||||
window.localStorage.removeItem(FRIENDLY_LAST_SEEN_KEY);
|
||||
}
|
||||
}, [shouldShowFriendlyBase, effectiveTotalUsersLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scenario === 'friendly') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check license status from global context
|
||||
const tier = mapLicenseToTier(licenseInfo);
|
||||
|
||||
// Show banner only for free tier users
|
||||
if (tier === 'free' || tier === null) {
|
||||
setIsVisible(true);
|
||||
} else {
|
||||
// Auto-hide banner if user upgrades
|
||||
setIsVisible(false);
|
||||
if (!shouldEvaluateFriendly) {
|
||||
setFriendlyVisible(false);
|
||||
return;
|
||||
}
|
||||
}, [user, licenseInfo, licenseLoading]);
|
||||
|
||||
// Handle dismiss
|
||||
const handleDismiss = () => {
|
||||
localStorage.setItem('upgradeBannerDismissed', 'true');
|
||||
setIsVisible(false);
|
||||
};
|
||||
if (friendlyVisible || typeof window === 'undefined' || userCountLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle upgrade button click
|
||||
const handleUpgrade = () => {
|
||||
openCheckout('server', {
|
||||
// Currency auto-detected from locale in CheckoutContext
|
||||
minimumSeats: 1,
|
||||
onSuccess: () => {
|
||||
// Banner will auto-hide on next render when license is detected
|
||||
setIsVisible(false);
|
||||
},
|
||||
const lastShownRaw = window.localStorage.getItem(FRIENDLY_LAST_SEEN_KEY);
|
||||
const lastShown = lastShownRaw ? parseInt(lastShownRaw, 10) : 0;
|
||||
const now = Date.now();
|
||||
const due = !Number.isFinite(lastShown) || now - lastShown >= WEEK_IN_MS;
|
||||
setFriendlyVisible(due);
|
||||
}, [scenario, shouldEvaluateFriendly, friendlyVisible, userCountLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const detail = shouldEvaluateUrgent
|
||||
? {
|
||||
active: true,
|
||||
audience: effectiveIsAdmin ? 'admin' : 'user',
|
||||
totalUsers: effectiveTotalUsers ?? null,
|
||||
freeTierLimit,
|
||||
}
|
||||
: { active: false };
|
||||
|
||||
console.debug('[UpgradeBanner] Dispatching alert event', {
|
||||
shouldEvaluateUrgent,
|
||||
detail,
|
||||
totalUsers: effectiveTotalUsers,
|
||||
freeTierLimit,
|
||||
effectiveIsAdmin,
|
||||
effectiveHasPaidLicense,
|
||||
userCountLoaded: effectiveTotalUsersLoaded,
|
||||
});
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(UPGRADE_BANNER_ALERT_EVENT, { detail }),
|
||||
);
|
||||
}, [shouldEvaluateUrgent, effectiveIsAdmin, effectiveTotalUsers, scenario, freeTierLimit]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(UPGRADE_BANNER_ALERT_EVENT, { detail: { active: false } }),
|
||||
);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const recordFriendlyLastShown = useCallback(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(FRIENDLY_LAST_SEEN_KEY, Date.now().toString());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (friendlyVisible) {
|
||||
recordFriendlyLastShown();
|
||||
}
|
||||
}, [friendlyVisible, recordFriendlyLastShown]);
|
||||
|
||||
const handleUpgrade = () => {
|
||||
recordFriendlyLastShown();
|
||||
|
||||
const hideBanner = () => setFriendlyVisible(false);
|
||||
const navigateFallback = () => {
|
||||
navigate('/settings/adminPlan');
|
||||
hideBanner();
|
||||
};
|
||||
|
||||
try {
|
||||
openCheckout('server', {
|
||||
minimumSeats: 1,
|
||||
onSuccess: () => {
|
||||
hideBanner();
|
||||
},
|
||||
onError: () => {
|
||||
navigateFallback();
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[UpgradeBanner] Failed to open checkout, redirecting instead', error);
|
||||
navigateFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep legacy behavior so banner disappears once the user initiates checkout
|
||||
hideBanner();
|
||||
};
|
||||
|
||||
// Don't render anything if loading or not visible
|
||||
if (licenseLoading || !isVisible) {
|
||||
const handleFriendlyDismiss = () => {
|
||||
recordFriendlyLastShown();
|
||||
setFriendlyVisible(false);
|
||||
};
|
||||
|
||||
const handleSeeInfo = () => {
|
||||
if (typeof window === 'undefined' || !effectiveIsAdmin) {
|
||||
return;
|
||||
}
|
||||
|
||||
const detail: ServerLicenseRequestPayload = {
|
||||
licenseNotice: {
|
||||
totalUsers: effectiveTotalUsers ?? null,
|
||||
freeTierLimit,
|
||||
isOverLimit: effectiveIsOverLimit ?? false,
|
||||
},
|
||||
selfReportedAdmin: true,
|
||||
deferUntilTourComplete: false,
|
||||
};
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(SERVER_LICENSE_REQUEST_EVENT, { detail }),
|
||||
);
|
||||
};
|
||||
|
||||
const renderUrgentBanner = () => {
|
||||
if (!shouldEvaluateUrgent) {
|
||||
console.debug('[UpgradeBanner] renderUrgentBanner → hidden (shouldEvaluateUrgent=false)');
|
||||
return null;
|
||||
}
|
||||
console.debug('[UpgradeBanner] renderUrgentBanner → visible', {
|
||||
totalUsers: effectiveTotalUsers,
|
||||
freeTierLimit,
|
||||
effectiveIsAdmin,
|
||||
effectiveHasPaidLicense,
|
||||
});
|
||||
|
||||
const buttonText = effectiveIsAdmin ? t('upgradeBanner.seeInfo', 'See info') : undefined;
|
||||
|
||||
const attentionMessage = effectiveIsAdmin
|
||||
? t(
|
||||
'upgradeBanner.attentionBodyAdmin',
|
||||
'Review the license requirements to keep this server compliant.',
|
||||
)
|
||||
: t(
|
||||
'upgradeBanner.attentionBody',
|
||||
'Your admin needs to sign in to see more info. Please contact them immediately.',
|
||||
);
|
||||
|
||||
return (
|
||||
<InfoBanner
|
||||
icon="warning-rounded"
|
||||
tone="warning"
|
||||
title={t('upgradeBanner.attentionTitle', 'This server needs admin attention')}
|
||||
message={attentionMessage}
|
||||
buttonText={buttonText}
|
||||
buttonIcon="info-rounded"
|
||||
onButtonClick={buttonText ? handleSeeInfo : undefined}
|
||||
dismissible={false}
|
||||
minHeight={60}
|
||||
background="#FFF4E6"
|
||||
borderColor="var(--mantine-color-orange-7)"
|
||||
textColor="#9A3412"
|
||||
iconColor="#EA580C"
|
||||
buttonVariant="filled"
|
||||
buttonColor="orange.7"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
if (!friendlyVisible && !shouldEvaluateUrgent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper
|
||||
shadow="sm"
|
||||
p="md"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
borderRadius: 0,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="md" wrap="nowrap">
|
||||
<LocalIcon icon="stars-rounded" width="1.5rem" height="1.5rem" />
|
||||
<div>
|
||||
<Text size="sm" fw={600}>
|
||||
{t('upgradeBanner.title', 'Upgrade to Server Plan')}
|
||||
</Text>
|
||||
<Text size="xs" opacity={0.9}>
|
||||
{t('upgradeBanner.message', 'Get the most out of Stirling PDF with unlimited users and advanced features')}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Button
|
||||
variant="white"
|
||||
size="sm"
|
||||
onClick={handleUpgrade}
|
||||
leftSection={<LocalIcon icon="upgrade-rounded" width="1rem" height="1rem" />}
|
||||
>
|
||||
{t('upgradeBanner.upgradeButton', 'Upgrade Now')}
|
||||
</Button>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="white"
|
||||
size="lg"
|
||||
onClick={handleDismiss}
|
||||
aria-label={t('upgradeBanner.dismiss', 'Dismiss banner')}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
<>
|
||||
{friendlyVisible && (
|
||||
<InfoBanner
|
||||
icon="stars-rounded"
|
||||
title={t('upgradeBanner.title', 'Upgrade to Server Plan')}
|
||||
message={t(
|
||||
'upgradeBanner.message',
|
||||
'Get the most out of Stirling PDF with unlimited users and advanced features.',
|
||||
)}
|
||||
buttonText={t('upgradeBanner.upgradeButton', 'Upgrade Now')}
|
||||
buttonIcon="upgrade-rounded"
|
||||
onButtonClick={handleUpgrade}
|
||||
onDismiss={handleFriendlyDismiss}
|
||||
show={friendlyVisible}
|
||||
background="linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
|
||||
borderColor="transparent"
|
||||
textColor="#fff"
|
||||
iconColor="#fff"
|
||||
closeIconColor="#fff"
|
||||
buttonVariant="white"
|
||||
buttonColor="blue"
|
||||
minHeight={64}
|
||||
/>
|
||||
)}
|
||||
{renderUrgentBanner()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user