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
@@ -2,8 +2,9 @@ import { AppProviders as CoreAppProviders, AppProvidersProps } from "@core/compo
import { AuthProvider } from "@app/auth/UseSession";
import { LicenseProvider } from "@app/contexts/LicenseContext";
import { CheckoutProvider } from "@app/contexts/CheckoutContext";
import { UpgradeBannerInitializer } from "@app/components/shared/UpgradeBannerInitializer";
import { ServerExperienceProvider } from "@app/contexts/ServerExperienceContext";
import { UpdateSeatsProvider } from "@app/contexts/UpdateSeatsContext";
import UpgradeBanner from "@app/components/shared/UpgradeBanner";
export function AppProviders({ children, appConfigRetryOptions, appConfigProviderProps }: AppProvidersProps) {
return (
@@ -13,12 +14,14 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
>
<AuthProvider>
<LicenseProvider>
<CheckoutProvider>
<UpdateSeatsProvider>
<UpgradeBanner />
{children}
</UpdateSeatsProvider>
</CheckoutProvider>
<UpdateSeatsProvider>
<ServerExperienceProvider>
<CheckoutProvider>
<UpgradeBannerInitializer />
{children}
</CheckoutProvider>
</ServerExperienceProvider>
</UpdateSeatsProvider>
</LicenseProvider>
</AuthProvider>
</CoreAppProviders>
@@ -22,6 +22,7 @@ import { userManagementService } from '@app/services/userManagementService';
import { teamService, Team } from '@app/services/teamService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useNavigate } from 'react-router-dom';
interface InviteMembersModalProps {
opened: boolean;
@@ -31,6 +32,7 @@ interface InviteMembersModalProps {
export default function InviteMembersModal({ opened, onClose }: InviteMembersModalProps) {
const { t } = useTranslation();
const { config } = useAppConfig();
const navigate = useNavigate();
const [teams, setTeams] = useState<Team[]>([]);
const [processing, setProcessing] = useState(false);
const [inviteMode, setInviteMode] = useState<'email' | 'direct' | 'link'>('direct');
@@ -45,6 +47,7 @@ export default function InviteMembersModal({ opened, onClose }: InviteMembersMod
premiumEnabled: boolean;
totalUsers: number;
} | null>(null);
const hasNoSlots = licenseInfo ? licenseInfo.availableSlots <= 0 : false;
// Form state for direct invite
const [inviteForm, setInviteForm] = useState({
@@ -242,6 +245,21 @@ export default function InviteMembersModal({ opened, onClose }: InviteMembersMod
onClose();
};
const handleGoToPlan = () => {
handleClose();
navigate('/settings/adminPlan');
};
const handlePrimaryAction = () => {
if (inviteMode === 'email') {
handleEmailInvite();
} else if (inviteMode === 'link') {
handleGenerateInviteLink();
} else {
handleInviteUser();
}
};
return (
<Modal
opened={opened}
@@ -281,16 +299,23 @@ export default function InviteMembersModal({ opened, onClose }: InviteMembersMod
{licenseInfo && (
<Paper withBorder p="sm" bg={licenseInfo.availableSlots === 0 ? 'var(--mantine-color-red-light)' : 'var(--mantine-color-blue-light)'}>
<Stack gap="xs">
<Group gap="xs">
<LocalIcon icon={licenseInfo.availableSlots > 0 ? 'info' : 'warning'} width="1rem" height="1rem" />
<Text size="sm" fw={500}>
{licenseInfo.availableSlots > 0
? t('workspace.people.license.slotsAvailable', {
count: licenseInfo.availableSlots,
defaultValue: `${licenseInfo.availableSlots} user slot(s) available`
})
: t('workspace.people.license.noSlotsAvailable', 'No user slots available')}
</Text>
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<LocalIcon icon={licenseInfo.availableSlots > 0 ? 'info' : 'warning'} width="1rem" height="1rem" />
<Text size="sm" fw={500}>
{licenseInfo.availableSlots > 0
? t('workspace.people.license.slotsAvailable', {
count: licenseInfo.availableSlots,
defaultValue: `${licenseInfo.availableSlots} user slot(s) available`
})
: t('workspace.people.license.noSlotsAvailable', 'No user slots available')}
</Text>
</Group>
{licenseInfo.availableSlots === 0 && (
<Button size="xs" variant="light" onClick={handleGoToPlan}>
{t('workspace.people.actions.upgrade', 'Upgrade')}
</Button>
)}
</Group>
<Text size="xs" c="dimmed">
{t('workspace.people.license.currentUsage', {
@@ -495,8 +520,8 @@ export default function InviteMembersModal({ opened, onClose }: InviteMembersMod
{/* Action Button */}
<Button
onClick={inviteMode === 'email' ? handleEmailInvite : inviteMode === 'link' ? handleGenerateInviteLink : handleInviteUser}
loading={processing}
onClick={handlePrimaryAction}
loading={!hasNoSlots && processing}
fullWidth
size="md"
mt="md"
@@ -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()}
</>
);
};
@@ -0,0 +1,17 @@
import { useEffect } from 'react';
import { useBanner } from '@app/contexts/BannerContext';
import UpgradeBanner from '@app/components/shared/UpgradeBanner';
export function UpgradeBannerInitializer() {
const { setBanner } = useBanner();
useEffect(() => {
setBanner(<UpgradeBanner />);
return () => {
setBanner(null);
};
}, [setBanner]);
return null;
}
@@ -1,4 +1,4 @@
import React, { useState, useCallback, useEffect } from 'react';
import React, { useState, useCallback, useEffect, useMemo } from 'react';
import { Divider, Loader, Alert, Group, Text, Collapse, Button, TextInput, Stack, Paper } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { usePlans } from '@app/hooks/usePlans';
@@ -9,6 +9,9 @@ import AvailablePlansSection from '@app/components/shared/config/configSections/
import StaticPlanSection from '@app/components/shared/config/configSections/plan/StaticPlanSection';
import { alert } from '@app/components/toast';
import LocalIcon from '@app/components/shared/LocalIcon';
import { ManageBillingButton } from '@app/components/shared/ManageBillingButton';
import { InfoBanner } from '@app/components/shared/InfoBanner';
import { useLicenseAlert } from '@app/hooks/useLicenseAlert';
import { isSupabaseConfigured } from '@app/services/supabaseClient';
import { getPreferredCurrency, setCachedCurrency } from '@app/utils/currencyDetection';
@@ -25,10 +28,11 @@ const AdminPlanSection: React.FC = () => {
const [licenseKeyInput, setLicenseKeyInput] = useState<string>('');
const [savingLicense, setSavingLicense] = useState(false);
const { plans, loading, error, refetch } = usePlans(currency);
const licenseAlert = useLicenseAlert();
// Check if we should use static version
useEffect(() => {
// Check if Stripe and Supabase are configured
// Check if Stripe is configured
const stripeKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
if (!stripeKey || !isSupabaseConfigured || error) {
setUseStaticVersion(true);
@@ -146,6 +150,23 @@ const AdminPlanSection: React.FC = () => {
[openCheckout, currency, refetch, licenseInfo, t]
);
const shouldShowLicenseWarning = licenseAlert.active && licenseAlert.audience === 'admin';
const formattedUserCount = useMemo(() => {
if (licenseAlert.totalUsers == null) {
return t('plan.licenseWarning.overLimit', 'more than {{limit}}', {
limit: licenseAlert.freeTierLimit,
});
}
return licenseAlert.totalUsers.toLocaleString();
}, [licenseAlert.totalUsers, licenseAlert.freeTierLimit, t]);
const scrollToPlans = useCallback(() => {
const el = document.getElementById('available-plans-section');
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, []);
// Show static version if Stripe is not configured or there's an error
if (useStaticVersion) {
return <StaticPlanSection currentLicenseInfo={licenseInfo ?? undefined} />;
@@ -175,6 +196,40 @@ const AdminPlanSection: React.FC = () => {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
{shouldShowLicenseWarning && (
<InfoBanner
icon="warning-rounded"
tone="warning"
title={t('plan.licenseWarning.title', 'Free self-hosted limit reached')}
message={t('plan.licenseWarning.body', {
total: formattedUserCount,
limit: licenseAlert.freeTierLimit,
})}
buttonText={t('plan.licenseWarning.cta', 'See plans')}
buttonIcon="upgrade-rounded"
onButtonClick={scrollToPlans}
dismissible={false}
minHeight={68}
background="#FFF4E6"
borderColor="var(--mantine-color-orange-7)"
textColor="#9A3412"
iconColor="#EA580C"
buttonVariant="filled"
buttonColor="orange.7"
/>
)}
{/* Manage Subscription Button - Only show if user has active license and Supabase is configured */}
{licenseInfo?.licenseKey && isSupabaseConfigured && (
<Paper withBorder p="md" radius="md">
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{t('plan.manageSubscription.description', 'Manage your subscription, billing, and payment methods')}
</Text>
<ManageBillingButton />
</Group>
</Paper>
)}
<AvailablePlansSection
plans={plans}
currentLicenseInfo={licenseInfo}
@@ -140,12 +140,15 @@ const AdminUsageSection: React.FC = () => {
);
}
const chartData = data?.endpoints?.map((e) => ({ label: e.endpoint, value: e.visits })) || [];
const endpoints = data?.endpoints ?? [];
const chartData = endpoints.map((e) => ({ label: e.endpoint, value: e.visits }));
const displayedVisits = data?.endpoints?.reduce((sum, e) => sum + e.visits, 0) || 0;
const displayedVisits = endpoints.reduce((sum, e) => sum + e.visits, 0);
const totalVisits = data?.totalVisits ?? displayedVisits ?? 0;
const totalEndpoints = data?.totalEndpoints ?? endpoints.length ?? 0;
const displayedPercentage = (data?.totalVisits || 0) > 0
? ((displayedVisits / (data?.totalVisits || 1)) * 100).toFixed(1)
const displayedPercentage = totalVisits > 0
? ((displayedVisits / (totalVisits || 1)) * 100).toFixed(1)
: '0';
return (
@@ -220,7 +223,7 @@ const AdminUsageSection: React.FC = () => {
{t('usage.stats.totalEndpoints', 'Total Endpoints')}
</Text>
<Text size="lg" fw={600}>
{data.totalEndpoints}
{totalEndpoints}
</Text>
</div>
<div>
@@ -228,7 +231,7 @@ const AdminUsageSection: React.FC = () => {
{t('usage.stats.totalVisits', 'Total Visits')}
</Text>
<Text size="lg" fw={600}>
{data.totalVisits.toLocaleString()}
{totalVisits.toLocaleString()}
</Text>
</div>
<div>
@@ -253,7 +256,7 @@ const AdminUsageSection: React.FC = () => {
{/* Chart and Table */}
<UsageAnalyticsChart data={chartData} />
<UsageAnalyticsTable data={data.endpoints} />
<UsageAnalyticsTable data={endpoints} />
</Stack>
);
};
@@ -27,6 +27,7 @@ import { useAppConfig } from '@app/contexts/AppConfigContext';
import InviteMembersModal from '@app/components/shared/InviteMembersModal';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
import { useNavigate } from 'react-router-dom';
import UpdateSeatsButton from '@app/components/shared/UpdateSeatsButton';
import { useLicense } from '@app/contexts/LicenseContext';
@@ -34,6 +35,7 @@ export default function PeopleSection() {
const { t } = useTranslation();
const { config } = useAppConfig();
const { loginEnabled } = useLoginRequired();
const navigate = useNavigate();
const { licenseInfo: globalLicenseInfo } = useLicense();
const [users, setUsers] = useState<User[]>([]);
const [teams, setTeams] = useState<Team[]>([]);
@@ -53,6 +55,24 @@ export default function PeopleSection() {
premiumEnabled: boolean;
totalUsers: number;
} | null>(null);
const hasNoSlots = licenseInfo ? licenseInfo.availableSlots === 0 : false;
const handleAddMembersClick = () => {
if (!loginEnabled) {
return;
}
if (hasNoSlots) {
navigate('/settings/adminPlan');
return;
}
setInviteModalOpened(true);
};
const addMemberTooltip = !loginEnabled
? t('workspace.people.loginRequired', 'Enable login mode first')
: hasNoSlots
? t('workspace.people.license.noSlotsAvailable', 'No user slots available')
: null;
// Form state for edit user modal
const [editForm, setEditForm] = useState({
@@ -326,9 +346,18 @@ export default function PeopleSection() {
</Text>
{licenseInfo.availableSlots === 0 && (
<Badge color="red" variant="light" size="sm">
{t('workspace.people.license.noSlotsAvailable', 'No slots available')}
</Badge>
<Group gap="xs" wrap="nowrap" align="center">
<Badge color="red" variant="light" size="sm">
{t('workspace.people.license.noSlotsAvailable', 'No slots available')}
</Badge>
<Button
size="compact-sm"
variant="outline"
onClick={() => navigate('/settings/adminPlan')}
>
{t('workspace.people.actions.upgrade', 'Upgrade')}
</Button>
</Group>
)}
{licenseInfo.grandfatheredUserCount > 0 && (
@@ -369,14 +398,14 @@ export default function PeopleSection() {
style={{ maxWidth: 300 }}
/>
<Tooltip
label={!loginEnabled ? 'Enable login mode first' : t('workspace.people.license.noSlotsAvailable', 'No user slots available')}
label={addMemberTooltip || undefined}
disabled={loginEnabled && (!licenseInfo || licenseInfo.availableSlots > 0)}
position="bottom"
withArrow
>
<Button
leftSection={<LocalIcon icon="person-add" width="1rem" height="1rem" />}
onClick={() => setInviteModalOpened(true)}
onClick={handleAddMembersClick}
disabled={!loginEnabled || (licenseInfo ? licenseInfo.availableSlots === 0 : false)}
>
{t('workspace.people.addMembers')}
@@ -1,6 +1,7 @@
import React, { createContext, useContext, useState, useCallback, useEffect, useMemo, useRef, ReactNode } from 'react';
import licenseService, { LicenseInfo } from '@app/services/licenseService';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { getSimulatedLicenseInfo } from '@app/testing/serverExperienceSimulations';
interface LicenseContextValue {
licenseInfo: LicenseInfo | null;
@@ -57,6 +58,14 @@ export const LicenseProvider: React.FC<LicenseProviderProps> = ({ children }) =>
console.log('[LicenseContext] Fetching license info');
try {
const testInfo = getSimulatedLicenseInfo();
if (testInfo) {
setLicenseInfo(testInfo);
setLoading(false);
setError(null);
return;
}
setLoading(true);
setError(null);
const info = await licenseService.getLicenseInfo();
@@ -0,0 +1,367 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
import apiClient from '@app/services/apiClient';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useAuth } from '@app/auth/UseSession';
import { useLicense } from '@app/contexts/LicenseContext';
import {
getSimulatedAdminUsage,
getSimulatedWauResponse,
} from '@app/testing/serverExperienceSimulations';
const SELF_REPORTED_ADMIN_KEY = 'stirling-self-reported-admin';
const FREE_TIER_LIMIT = 5;
type UserCountSource = 'admin' | 'estimate' | 'unknown';
interface WeeklyActiveUsersResponse {
trackingSince: string;
daysOnline: number;
totalUniqueBrowsers: number;
weeklyActiveUsers: number;
}
interface UserCountState {
totalUsers: number | null;
weeklyActiveUsers: number | null;
loading: boolean;
source: UserCountSource;
lastUpdated: number | null;
error: string | null;
}
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;
}
const ServerExperienceContext = createContext<ServerExperienceValue | undefined>(undefined);
function getStoredSelfReportedAdmin(): boolean {
if (typeof window === 'undefined') {
return false;
}
try {
return window.localStorage.getItem(SELF_REPORTED_ADMIN_KEY) === 'true';
} catch {
return false;
}
}
function getErrorMessage(error: unknown): string {
if (typeof error === 'string') {
return error;
}
if (
typeof error === 'object' &&
error !== null &&
'response' in error &&
typeof (error as any).response?.data?.message === 'string'
) {
return (error as any).response.data.message;
}
if (error instanceof Error) {
return error.message;
}
return 'Unable to load server usage';
}
export function ServerExperienceProvider({ children }: { children: ReactNode }) {
const { config } = useAppConfig();
const { user } = useAuth();
const { licenseInfo, loading: licenseLoading } = useLicense();
const [selfReportedAdmin, setSelfReportedAdminState] = useState<boolean>(getStoredSelfReportedAdmin);
const [userCountState, setUserCountState] = useState<UserCountState>({
totalUsers: null,
weeklyActiveUsers: null,
loading: false,
source: 'unknown',
lastUpdated: null,
error: null,
});
const loginEnabled = config?.enableLogin !== false;
const configIsAdmin = Boolean(config?.isAdmin);
const effectiveIsAdmin = configIsAdmin || (!loginEnabled && selfReportedAdmin);
const isAuthenticated = Boolean(user);
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 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) {
return;
}
if (config.isNewServer && !loginEnabled && !selfReportedAdmin) {
setSelfReportedAdmin(true);
}
}, [config, loginEnabled, selfReportedAdmin, setSelfReportedAdmin]);
const fetchUserCounts = useCallback(async () => {
if (!config) {
return;
}
const shouldUseAdminData = (config.enableLogin ?? true) && config.isAdmin;
const shouldUseEstimate = config.enableLogin === false;
if (!shouldUseAdminData && !shouldUseEstimate) {
setUserCountState((prev) => ({
...prev,
totalUsers: null,
weeklyActiveUsers: null,
loading: false,
source: 'unknown',
error: null,
}));
return;
}
setUserCountState((prev) => ({
...prev,
loading: true,
error: null,
}));
try {
if (shouldUseAdminData) {
const testResponse = getSimulatedAdminUsage();
const responseData =
testResponse ??
(
await apiClient.get<{ totalUsers?: number }>(
'/api/v1/proprietary/ui-data/admin-settings',
{ suppressErrorToast: true } as any,
)
).data;
const totalUsers =
typeof responseData?.totalUsers === 'number' ? responseData.totalUsers : null;
setUserCountState({
totalUsers,
weeklyActiveUsers: null,
loading: false,
source: 'admin',
lastUpdated: Date.now(),
error: null,
});
return;
}
if (shouldUseEstimate) {
const testResponse = getSimulatedWauResponse();
const responseData =
testResponse ??
(
await apiClient.get<WeeklyActiveUsersResponse>('/api/v1/info/wau', {
suppressErrorToast: true,
} as any)
).data;
const weeklyActiveUsers =
typeof responseData?.weeklyActiveUsers === 'number'
? responseData.weeklyActiveUsers
: null;
setUserCountState({
totalUsers: weeklyActiveUsers,
weeklyActiveUsers,
loading: false,
source: 'estimate',
lastUpdated: Date.now(),
error: null,
});
}
} catch (error) {
setUserCountState({
totalUsers: null,
weeklyActiveUsers: null,
loading: false,
source: 'unknown',
lastUpdated: null,
error: getErrorMessage(error),
});
}
}, [config]);
useEffect(() => {
void fetchUserCounts();
}, [fetchUserCounts]);
const refreshUserCounts = useCallback(async () => {
await fetchUserCounts();
}, [fetchUserCounts]);
const hasPaidLicense = useMemo(() => {
return config?.license === 'PRO' || config?.license === 'ENTERPRISE';
}, [config?.license]);
const licenseKeyValid = useMemo(() => {
if (licenseInfo) {
return licenseInfo.hasKey && licenseInfo.enabled;
}
if (config?.premiumEnabled) {
return true;
}
return null;
}, [config?.premiumEnabled, licenseInfo]);
const overFreeTierLimit = useMemo(() => {
if (typeof userCountState.totalUsers !== 'number') {
return null;
}
return userCountState.totalUsers > FREE_TIER_LIMIT;
}, [userCountState.totalUsers]);
const userCountResolved =
!userCountState.loading && userCountState.source !== 'unknown' && userCountState.totalUsers !== null;
const scenarioKey = useMemo<ServerScenarioKey>(() => {
if (hasPaidLicense) {
return 'licensed';
}
if (!userCountResolved || typeof userCountState.totalUsers !== 'number') {
return 'unknown';
}
const overLimit = userCountState.totalUsers > FREE_TIER_LIMIT;
if (!loginEnabled) {
if (selfReportedAdmin) {
return overLimit
? 'no-login-admin-over-limit-no-license'
: 'no-login-admin-under-limit-no-license';
}
return overLimit
? 'no-login-user-over-limit-no-license'
: 'no-login-user-under-limit-no-license';
}
if (configIsAdmin) {
return overLimit
? 'login-admin-over-limit-no-license'
: 'login-admin-under-limit-no-license';
}
return overLimit
? 'login-user-over-limit-no-license'
: 'login-user-under-limit-no-license';
}, [
hasPaidLicense,
userCountResolved,
userCountState.totalUsers,
loginEnabled,
selfReportedAdmin,
configIsAdmin,
]);
const value: ServerExperienceValue = {
loginEnabled,
configIsAdmin,
effectiveIsAdmin,
selfReportedAdmin,
isAuthenticated,
isNewServer: config?.isNewServer ?? null,
isNewUser: config?.isNewUser ?? null,
premiumEnabled: config?.premiumEnabled ?? null,
license: config?.license,
runningProOrHigher: config?.runningProOrHigher,
runningEE: config?.runningEE,
hasPaidLicense,
licenseKeyValid,
licenseLoading,
licenseInfoAvailable: Boolean(licenseInfo),
totalUsers: userCountState.totalUsers,
weeklyActiveUsers: userCountState.weeklyActiveUsers,
userCountLoading: userCountState.loading,
userCountError: userCountState.error,
userCountSource: userCountState.source,
userCountResolved,
overFreeTierLimit,
freeTierLimit: FREE_TIER_LIMIT,
refreshUserCounts,
setSelfReportedAdmin,
scenarioKey,
};
return (
<ServerExperienceContext.Provider value={value}>
{children}
</ServerExperienceContext.Provider>
);
}
export function useServerExperienceContext() {
const context = useContext(ServerExperienceContext);
if (!context) {
throw new Error('useServerExperience must be used within ServerExperienceProvider');
}
return context;
}
@@ -0,0 +1,6 @@
import { useServerExperienceContext } from '@app/contexts/ServerExperienceContext';
export function useServerExperience() {
return useServerExperienceContext();
}
@@ -0,0 +1,202 @@
import type { AppConfig } from '@app/contexts/AppConfigContext';
import type { LicenseInfo } from '@app/services/licenseService';
interface WauResponse {
trackingSince: string;
daysOnline: number;
totalUniqueBrowsers: number;
weeklyActiveUsers: number;
}
interface AdminUsageResponse {
totalUsers?: number;
}
interface SimulationScenario {
/**
* Human-friendly label describing the scenario.
* Keep in sync with the comment map below.
*/
label: string;
appConfig: AppConfig;
wau?: WauResponse;
adminUsage?: AdminUsageResponse;
licenseInfo: LicenseInfo;
}
const DEV_TESTING_MODE = false;
/**
* Scenario index cheat sheet:
* 0 → no-login-user-under-limit (no license)
* 1 → no-login-admin-under-limit (no license)
* 2 → no-login-user-over-limit (no license)
* 3 → no-login-admin-over-limit (no license)
* 4 → login-user-under-limit (no license)
* 5 → login-admin-under-limit (no license)
* 6 → login-user-over-limit (no license)
* 7 → login-admin-over-limit (no license)
*/
const SIMULATION_INDEX = 0;
const FREE_LICENSE_INFO: LicenseInfo = {
licenseType: 'NORMAL',
enabled: false,
maxUsers: 5,
hasKey: false,
};
const BASE_NO_LOGIN_CONFIG: AppConfig = {
enableAnalytics: true,
appVersion: '2.0.0',
serverCertificateEnabled: false,
enableAlphaFunctionality: false,
serverPort: 8080,
premiumEnabled: false,
runningProOrHigher: false,
runningEE: false,
enableLogin: false,
activeSecurity: false,
languages: [],
contextPath: '/',
license: 'NORMAL',
baseUrl: 'http://localhost',
enableEmailInvites: true,
};
const BASE_LOGIN_CONFIG: AppConfig = {
...BASE_NO_LOGIN_CONFIG,
enableLogin: true,
activeSecurity: true,
};
const SIMULATION_SCENARIOS: SimulationScenario[] = [
{
label: 'no-login-user-under-limit (no-license)',
appConfig: {
...BASE_NO_LOGIN_CONFIG,
},
wau: {
trackingSince: '2025-11-18T23:20:12.520884200Z',
daysOnline: 0,
totalUniqueBrowsers: 3,
weeklyActiveUsers: 3,
},
licenseInfo: { ...FREE_LICENSE_INFO },
},
{
label: 'no-login-admin-under-limit (no-license)',
appConfig: {
...BASE_NO_LOGIN_CONFIG,
},
wau: {
trackingSince: '2025-10-01T00:00:00Z',
daysOnline: 14,
totalUniqueBrowsers: 4,
weeklyActiveUsers: 4,
},
licenseInfo: { ...FREE_LICENSE_INFO },
},
{
label: 'no-login-user-over-limit (no-license)',
appConfig: {
...BASE_NO_LOGIN_CONFIG,
},
wau: {
trackingSince: '2025-09-01T00:00:00Z',
daysOnline: 30,
totalUniqueBrowsers: 12,
weeklyActiveUsers: 9,
},
licenseInfo: { ...FREE_LICENSE_INFO },
},
{
label: 'no-login-admin-over-limit (no-license)',
appConfig: {
...BASE_NO_LOGIN_CONFIG,
},
wau: {
trackingSince: '2025-08-15T00:00:00Z',
daysOnline: 45,
totalUniqueBrowsers: 18,
weeklyActiveUsers: 12,
},
licenseInfo: { ...FREE_LICENSE_INFO },
},
{
label: 'login-user-under-limit (no-license)',
appConfig: {
...BASE_LOGIN_CONFIG,
isAdmin: false,
},
adminUsage: {
totalUsers: 3,
},
licenseInfo: { ...FREE_LICENSE_INFO },
},
{
label: 'login-admin-under-limit (no-license)',
appConfig: {
...BASE_LOGIN_CONFIG,
isAdmin: true,
},
adminUsage: {
totalUsers: 4,
},
licenseInfo: { ...FREE_LICENSE_INFO },
},
{
label: 'login-user-over-limit (no-license)',
appConfig: {
...BASE_LOGIN_CONFIG,
isAdmin: false,
},
adminUsage: {
totalUsers: 12,
},
licenseInfo: { ...FREE_LICENSE_INFO },
},
{
label: 'login-admin-over-limit (no-license)',
appConfig: {
...BASE_LOGIN_CONFIG,
isAdmin: true,
},
adminUsage: {
totalUsers: 57,
},
licenseInfo: { ...FREE_LICENSE_INFO },
},
];
function getActiveScenario(): SimulationScenario | null {
if (!DEV_TESTING_MODE) {
return null;
}
const scenario = SIMULATION_SCENARIOS[SIMULATION_INDEX];
if (!scenario) {
console.warn('[Simulation] SIMULATION_INDEX out of range, using live backend.');
return null;
}
console.warn(`[Simulation] Using scenario #${SIMULATION_INDEX} (${scenario.label}).`);
return scenario;
}
export function getSimulatedAppConfig(): AppConfig | null {
return getActiveScenario()?.appConfig ?? null;
}
export function getSimulatedWauResponse(): WauResponse | null {
return getActiveScenario()?.wau ?? null;
}
export function getSimulatedAdminUsage(): AdminUsageResponse | null {
return getActiveScenario()?.adminUsage ?? null;
}
export function getSimulatedLicenseInfo(): LicenseInfo | null {
return getActiveScenario()?.licenseInfo ?? null;
}
export const DEV_TESTING_ENABLED = DEV_TESTING_MODE;