Files
Stirling-PDF/frontend/src/proprietary/components/shared/UpgradeBanner.tsx
T

373 lines
11 KiB
TypeScript

import React, { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useLocation } from "react-router-dom";
import { isAuthRoute } from "@core/constants/routes";
import { useCheckout } from "@app/contexts/CheckoutContext";
import { InfoBanner } from "@app/components/shared/InfoBanner";
import {
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";
import { isOnboardingCompleted } from "@core/components/onboarding/orchestrator/onboardingStorage";
const FRIENDLY_LAST_SEEN_KEY = "upgradeBannerFriendlyLastShownAt";
const WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000;
const UpgradeBanner: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
// Check if we're on an auth route (evaluated after hooks, used in render)
const onAuthRoute = isAuthRoute(location.pathname);
const { openCheckout } = useCheckout();
const {
loginEnabled,
totalUsers,
userCountResolved,
userCountLoading,
effectiveIsAdmin: configIsAdmin,
hasPaidLicense,
licenseLoading,
freeTierLimit,
overFreeTierLimit,
weeklyActiveUsers,
scenarioKey,
} = useServerExperience();
const onboardingComplete = isOnboardingCompleted();
const [friendlyVisible, setFriendlyVisible] = useState(() => {
if (typeof window === "undefined") return false;
const lastShownRaw = window.localStorage.getItem(FRIENDLY_LAST_SEEN_KEY);
if (!lastShownRaw) return false;
const lastShown = parseInt(lastShownRaw, 10);
if (!Number.isFinite(lastShown)) return false;
return Date.now() - lastShown >= WEEK_IN_MS;
});
const isDev = import.meta.env.DEV;
const [testScenario, setTestScenario] =
useState<UpgradeBannerTestScenario>(null);
useEffect(() => {
if (!isDev || typeof window === "undefined") {
return;
}
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 &&
onboardingComplete,
);
// Urgent banner should always show when over-limit
const shouldEvaluateUrgent = scenario
? Boolean(scenario && !scenarioIsFriendly)
: Boolean(shouldShowUrgentBase && !licenseLoading);
useEffect(() => {
if (scenario === "friendly") {
return;
}
if (!shouldEvaluateFriendly) {
setFriendlyVisible(false);
return;
}
if (friendlyVisible || typeof window === "undefined" || userCountLoading) {
return;
}
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 };
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());
}, []);
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();
};
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) {
return null;
}
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"
/>
);
};
const suppressForNoLogin =
!loginEnabled ||
(!loginEnabled && (weeklyActiveUsers ?? Number.POSITIVE_INFINITY) > 5);
// Don't show on auth routes or if neither banner type should show
// Also suppress entirely for no-login servers (treat them as regular users only)
// and, per request, never surface upgrade messaging there when WAU > 5.
if (
onAuthRoute ||
suppressForNoLogin ||
(!friendlyVisible && !shouldEvaluateUrgent)
) {
return null;
}
return (
<>
{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={48}
compact
/>
)}
{renderUrgentBanner()}
</>
);
};
export default UpgradeBanner;