373 lines
11 KiB
TypeScript
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;
|