feat(premium): let self-hosted instances sell premium and gifts (#3025)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-28 21:21:51 +02:00
1 parent 192cec689a
commit 3736d94d73
159 files changed
+15802 -9067

No files matched your search

@@ -92,3 +92,17 @@
.comparisonTableContainer {
margin-top: 0.5rem;
}
.redeemSection {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
text-align: center;
}
.redeemDescription {
font-size: 0.875rem;
line-height: 1.375rem;
color: var(--text-primary-muted);
}
@@ -20,6 +20,7 @@ import {SelfServeRefundSection} from '@app/features/app/components/dialogs/compo
import {SubscriptionCard} from '@app/features/app/components/dialogs/components/plutonium/SubscriptionCard';
import {PREMIUM_PRODUCT_FULL_NAME, PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
import GeoIP from '@app/features/app/state/GeoIP';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import Guilds from '@app/features/guild/state/Guilds';
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
import * as PremiumCommands from '@app/features/premium/commands/PremiumCommands';
@@ -29,6 +30,13 @@ import {
FREE_VS_PREMIUM_DESCRIPTOR,
VERIFY_EMAIL_TO_PURCHASE_PREMIUM_DESCRIPTOR,
} from '@app/features/premium/utils/PremiumMessageDescriptors';
import {
areGiftPurchasesAvailable,
arePremiumPurchasesAvailable,
canServiceStripeSubscriptions,
shouldShowPremiumFeatures,
} from '@app/features/premium/utils/PremiumUtils';
import {Button} from '@app/features/ui/button/Button';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {modal} from '@app/features/ui/commands/ModalCommands';
import MobileLayout from '@app/features/ui/state/MobileLayout';
@@ -97,6 +105,13 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
const isClaimed = currentUser?.isClaimed() ?? false;
const isEmailVerified = currentUser?.verified === true;
const purchaseDisabled = !isClaimed || !isEmailVerified;
const userPurchaseDisabled = premiumState?.effective.premium_purchase_disabled === true;
const purchasesAvailable = arePremiumPurchasesAvailable(userPurchaseDisabled);
const giftPurchasesAvailable = areGiftPurchasesAvailable(priceIds, userPurchaseDisabled);
const hasBillingRelationship = subscriptionStatus.hasEverPurchased || premiumState?.billing.subscription != null;
const billingUnavailable =
!shouldShowPremiumFeatures() ||
(!arePremiumPurchasesAvailable() && !(hasBillingRelationship && canServiceStripeSubscriptions()));
const purchaseDisabledTooltip = !isClaimed
? i18n._(CLAIM_ACCOUNT_TO_PURCHASE_PREMIUM_DESCRIPTOR, {premiumProductFullName: PREMIUM_PRODUCT_FULL_NAME})
: i18n._(VERIFY_EMAIL_TO_PURCHASE_PREMIUM_DESCRIPTOR, {premiumProductFullName: PREMIUM_PRODUCT_FULL_NAME});
@@ -162,16 +177,18 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
return (
<div className={styles.giftModeContainer} data-flx="app.plutonium-content.gift-mode-container">
<PlutoniumUpsellBanner data-flx="app.plutonium-content.plutonium-upsell-banner" />
<GiftSection
giftSectionRef={giftSectionRef}
giftMonthlyPrice={giftMonthlyPrice}
giftYearlyPrice={giftYearlyPrice}
loadingCheckout={loadingCheckout}
handleSelectPlan={handleSelectPlanGuarded}
purchaseDisabled={purchaseDisabled}
purchaseDisabledTooltip={purchaseDisabledTooltip}
data-flx="app.plutonium-content.gift-section"
/>
{giftPurchasesAvailable && (
<GiftSection
giftSectionRef={giftSectionRef}
giftMonthlyPrice={giftMonthlyPrice}
giftYearlyPrice={giftYearlyPrice}
loadingCheckout={loadingCheckout}
handleSelectPlan={handleSelectPlanGuarded}
purchaseDisabled={purchaseDisabled}
purchaseDisabledTooltip={purchaseDisabledTooltip}
data-flx="app.plutonium-content.gift-section"
/>
)}
<div ref={perksSectionRef} data-flx="app.plutonium-content.div">
<section className={styles.perksSection} data-flx="app.plutonium-content.perks-section">
<SectionHeader
@@ -246,14 +263,17 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
handleCommunityButtonClick={handleCommunityButtonClick}
purchaseDisabled={purchaseDisabled}
purchaseDisabledTooltip={purchaseDisabledTooltip}
billingUnavailable={billingUnavailable}
data-flx="app.plutonium-content.subscription-card"
/>
<div className={styles.disclaimerContainer} data-flx="app.plutonium-content.disclaimer-container">
<PurchaseDisclaimer align="center" isPremium data-flx="app.plutonium-content.purchase-disclaimer" />
</div>
{purchasesAvailable && (
<div className={styles.disclaimerContainer} data-flx="app.plutonium-content.disclaimer-container">
<PurchaseDisclaimer align="center" isPremium data-flx="app.plutonium-content.purchase-disclaimer" />
</div>
)}
</section>
)}
{subscriptionStatus.hasEverPurchased && (
{subscriptionStatus.hasEverPurchased && !billingUnavailable && (
<>
<PurchaseHistorySection
premiumState={premiumState}
@@ -261,16 +281,37 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
handleOpenCustomerPortal={handleOpenCustomerPortal}
data-flx="app.plutonium-content.purchase-history-section"
/>
<SelfServeRefundSection
eligibility={premiumState?.billing.refund_eligibility ?? null}
refreshPremiumState={() => PremiumCommands.refreshPremiumState(countryCode ?? undefined)}
data-flx="app.plutonium-content.self-serve-refund-section"
/>
{!RuntimeConfig.isSelfHosted() && (
<SelfServeRefundSection
eligibility={premiumState?.billing.refund_eligibility ?? null}
refreshPremiumState={() => PremiumCommands.refreshPremiumState(countryCode ?? undefined)}
data-flx="app.plutonium-content.self-serve-refund-section"
/>
)}
</>
)}
{!subscriptionStatus.shouldShowPremiumCard ? (
{!purchasesAvailable ? (
!subscriptionStatus.shouldShowPremiumCard && (
<section className={styles.redeemSection} data-flx="app.plutonium-content.redeem-section">
<p className={styles.redeemDescription} data-flx="app.plutonium-content.redeem-description">
<Trans comment="Premium page text shown on an instance where the premium tier cannot be bought, only redeemed. {PREMIUM_PRODUCT_NAME} is the premium tier name.">
Have a gift code? Redeem it to unlock {PREMIUM_PRODUCT_NAME}.
</Trans>
</p>
<Button
variant="primary"
small
onClick={navigateToRedeemGift}
data-flx="app.plutonium-content.redeem-section.button.navigate-to-redeem-gift"
>
<Trans comment="Billing button for entering a premium gift code.">Redeem gift code</Trans>
</Button>
</section>
)
) : !subscriptionStatus.shouldShowPremiumCard ? (
<PricingSection
isGiftMode={isGiftMode}
isGiftMode={isGiftMode && giftPurchasesAvailable}
giftPurchasesAvailable={giftPurchasesAvailable}
setIsGiftMode={setIsGiftMode}
monthlyPrice={monthlyPrice}
yearlyPrice={yearlyPrice}
@@ -283,16 +324,18 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
data-flx="app.plutonium-content.pricing-section"
/>
) : (
<GiftSection
giftSectionRef={giftSectionRef}
giftMonthlyPrice={giftMonthlyPrice}
giftYearlyPrice={giftYearlyPrice}
loadingCheckout={loadingCheckout}
handleSelectPlan={handleSelectPlanGuarded}
purchaseDisabled={purchaseDisabled}
purchaseDisabledTooltip={purchaseDisabledTooltip}
data-flx="app.plutonium-content.gift-section--2"
/>
giftPurchasesAvailable && (
<GiftSection
giftSectionRef={giftSectionRef}
giftMonthlyPrice={giftMonthlyPrice}
giftYearlyPrice={giftYearlyPrice}
loadingCheckout={loadingCheckout}
handleSelectPlan={handleSelectPlanGuarded}
purchaseDisabled={purchaseDisabled}
purchaseDisabledTooltip={purchaseDisabledTooltip}
data-flx="app.plutonium-content.gift-section--2"
/>
)
)}
<div ref={perksSectionRef} data-flx="app.plutonium-content.div--2">
<section className={styles.perksSection} data-flx="app.plutonium-content.perks-section--2">
@@ -308,9 +351,9 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
</div>
</section>
</div>
{!subscriptionStatus.isPremium && (
{!subscriptionStatus.isPremium && purchasesAvailable && (
<BottomCTASection
isGiftMode={isGiftMode}
isGiftMode={isGiftMode && giftPurchasesAvailable}
monthlyPrice={monthlyPrice}
yearlyPrice={yearlyPrice}
giftMonthlyPrice={giftMonthlyPrice}
@@ -3,28 +3,68 @@
import {Routes} from '@app/app/Routes';
import styles from '@app/features/app/components/dialogs/components/PurchaseDisclaimer.module.css';
import {ExternalLink} from '@app/features/app/components/shared/ExternalLink';
import {PAYMENT_PROVIDER_NAME} from '@app/features/app/config/I18nDisplayConstants';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import {Trans} from '@lingui/react/macro';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
export const PurchaseDisclaimer = observer(
({isPremium = false, align = 'center'}: {isPremium?: boolean; align?: 'left' | 'center'}) => {
const terms = (
<ExternalLink href={Routes.terms()} data-flx="app.purchase-disclaimer.external-link">
const selfHosted = RuntimeConfig.isSelfHosted();
const termsUrl = selfHosted ? RuntimeConfig.termsUrl : Routes.terms();
const privacyUrl = selfHosted ? RuntimeConfig.privacyUrl : Routes.privacy();
const terms = termsUrl ? (
<ExternalLink href={termsUrl} data-flx="app.purchase-disclaimer.external-link">
<Trans>Terms of service</Trans>
</ExternalLink>
);
const privacy = (
<ExternalLink href={Routes.privacy()} data-flx="app.purchase-disclaimer.external-link--2">
) : null;
const privacy = privacyUrl ? (
<ExternalLink href={privacyUrl} data-flx="app.purchase-disclaimer.external-link--2">
<Trans>Privacy policy</Trans>
</ExternalLink>
);
return (
<p
className={clsx(styles.disclaimer, align === 'center' ? styles.center : styles.left)}
data-flx="app.purchase-disclaimer.disclaimer"
>
{isPremium ? (
) : null;
const termsOnly = termsUrl ? (
isPremium ? (
<Trans comment="Purchase disclaimer when the instance links only its terms of service. The linked text is the terms of service document.">
By purchasing, you agreed to our{' '}
<ExternalLink href={termsUrl} data-flx="app.purchase-disclaimer.external-link--terms-only">
Terms of service
</ExternalLink>
.
</Trans>
) : (
<Trans comment="Purchase disclaimer when the instance links only its terms of service. The linked text is the terms of service document.">
By purchasing, you agree to our{' '}
<ExternalLink href={termsUrl} data-flx="app.purchase-disclaimer.external-link--terms-only">
Terms of service
</ExternalLink>
.
</Trans>
)
) : null;
const privacyOnly = privacyUrl ? (
isPremium ? (
<Trans comment="Purchase disclaimer when the instance links only its privacy policy. The linked text is the privacy policy document.">
By purchasing, you agreed to our{' '}
<ExternalLink href={privacyUrl} data-flx="app.purchase-disclaimer.external-link--privacy-only">
Privacy policy
</ExternalLink>
.
</Trans>
) : (
<Trans comment="Purchase disclaimer when the instance links only its privacy policy. The linked text is the privacy policy document.">
By purchasing, you agree to our{' '}
<ExternalLink href={privacyUrl} data-flx="app.purchase-disclaimer.external-link--privacy-only">
Privacy policy
</ExternalLink>
.
</Trans>
)
) : null;
const agreement =
terms && privacy ? (
isPremium ? (
<Trans>
By purchasing, you agreed to our {terms} and {privacy}.
</Trans>
@@ -32,13 +72,29 @@ export const PurchaseDisclaimer = observer(
<Trans>
By purchasing, you agree to our {terms} and {privacy}.
</Trans>
)}{' '}
<Trans>
Self-serve refunds available within 3 days of payment, once every 30 days. Refunding a subscription cancels
it. EU/EEA buyers waive the 14-day right of withdrawal at checkout to access content immediately. Use the
in-app refund button instead of a chargeback. Chargebacks can permanently restrict your account. Stripe
handles payment securely. We never see your full card number.
</Trans>
)
) : (
(termsOnly ?? privacyOnly)
);
return (
<p
className={clsx(styles.disclaimer, align === 'center' ? styles.center : styles.left)}
data-flx="app.purchase-disclaimer.disclaimer"
>
{agreement}
{agreement ? ' ' : null}
{selfHosted ? (
<Trans comment="Purchase disclaimer on a self-hosted instance. {PAYMENT_PROVIDER_NAME} is the payment processor name.">
{PAYMENT_PROVIDER_NAME} handles payment securely.
</Trans>
) : (
<Trans>
Self-serve refunds available within 3 days of payment, once every 30 days. Refunding a subscription cancels
it. EU/EEA buyers waive the 14-day right of withdrawal at checkout to access content immediately. Use the
in-app refund button instead of a chargeback. Chargebacks can permanently restrict your account. Stripe
handles payment securely. We never see your full card number.
</Trans>
)}
</p>
);
},
@@ -70,6 +70,7 @@ const MESSAGE_1_MONTH_GIFT_DESCRIPTOR = msg({
interface PricingSectionProps {
isGiftMode: boolean;
giftPurchasesAvailable?: boolean;
setIsGiftMode: (value: boolean) => void;
monthlyPrice: string;
yearlyPrice: string;
@@ -84,6 +85,7 @@ interface PricingSectionProps {
export const PricingSection: React.FC<PricingSectionProps> = observer(
({
isGiftMode,
giftPurchasesAvailable = true,
setIsGiftMode,
monthlyPrice,
yearlyPrice,
@@ -100,25 +102,27 @@ export const PricingSection: React.FC<PricingSectionProps> = observer(
i18n._(CLAIM_ACCOUNT_TO_PURCHASE_PREMIUM_DESCRIPTOR, {premiumProductFullName: PREMIUM_PRODUCT_FULL_NAME});
return (
<section className={styles.section} data-flx="app.plutonium.pricing-section.section">
<div
className={styles.toggleContainer}
role="group"
aria-label={i18n._(PURCHASE_MODE_DESCRIPTOR)}
data-flx="app.plutonium.pricing-section.toggle-container"
>
<ToggleButton
active={!isGiftMode}
onClick={() => setIsGiftMode(false)}
label={i18n._(FOR_ME_DESCRIPTOR)}
data-flx="app.plutonium.pricing-section.toggle-button.set-is-gift-mode"
/>
<ToggleButton
active={isGiftMode}
onClick={() => setIsGiftMode(true)}
label={i18n._(AS_A_GIFT_DESCRIPTOR)}
data-flx="app.plutonium.pricing-section.toggle-button.set-is-gift-mode--2"
/>
</div>
{giftPurchasesAvailable && (
<div
className={styles.toggleContainer}
role="group"
aria-label={i18n._(PURCHASE_MODE_DESCRIPTOR)}
data-flx="app.plutonium.pricing-section.toggle-container"
>
<ToggleButton
active={!isGiftMode}
onClick={() => setIsGiftMode(false)}
label={i18n._(FOR_ME_DESCRIPTOR)}
data-flx="app.plutonium.pricing-section.toggle-button.set-is-gift-mode"
/>
<ToggleButton
active={isGiftMode}
onClick={() => setIsGiftMode(true)}
label={i18n._(AS_A_GIFT_DESCRIPTOR)}
data-flx="app.plutonium.pricing-section.toggle-button.set-is-gift-mode--2"
/>
</div>
)}
<div className={gridStyles.gridWrapper} data-flx="app.plutonium.pricing-section.div">
<div className={gridStyles.gridTwoColumns} data-flx="app.plutonium.pricing-section.div--2">
{!isGiftMode ? (
@@ -116,6 +116,7 @@ interface SubscriptionCardProps {
handleCommunityButtonClick: () => void;
purchaseDisabled?: boolean;
purchaseDisabledTooltip?: React.ReactNode;
billingUnavailable?: boolean;
}
function getStatusBadgeClass(args: {
@@ -180,6 +181,7 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
handleCommunityButtonClick,
purchaseDisabled = false,
purchaseDisabledTooltip,
billingUnavailable = false,
}) => {
const {i18n} = useLingui();
const {loadingSwitchToListPrice, handleSwitchToListPrice} = useSubscriptionActions();
@@ -704,7 +706,7 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
})()}
</div>
<div className={styles.actions} data-flx="app.plutonium.subscription-card.actions">
{isGiftSubscription ? (
{isGiftSubscription || billingUnavailable ? (
wrapIfDisabled(
<Button
variant="primary"
@@ -11,6 +11,7 @@ import {
SUPPORT_EMAIL,
UPI_PAYMENT_METHOD,
} from '@app/features/app/config/I18nDisplayConstants';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import {CANCEL_DESCRIPTOR, CLOSE_DESCRIPTOR, OKAY_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {HttpError} from '@app/features/platform/types/EndpointError';
import {Logger} from '@app/features/platform/utils/AppLogger';
@@ -140,6 +141,10 @@ const PURCHASES_DISABLED_BODY_DESCRIPTOR = msg({
message: 'Purchases are disabled for this account. Contact {supportEmail} if this looks wrong.',
comment: 'Modal body shown when purchases are disabled. Provides the support email for appeals.',
});
const PURCHASES_DISABLED_SELF_HOSTED_BODY_DESCRIPTOR = msg({
message: 'Purchases are disabled for this account.',
comment: 'Modal body shown on a self-hosted instance when purchases are disabled for the account.',
});
const CHECKOUT_BLOCKED_TITLE_DESCRIPTOR = msg({
message: 'Checkout unavailable',
comment: 'Modal title for the generic "checkout blocked" state when no more specific reason is known.',
@@ -148,6 +153,10 @@ const CHECKOUT_BLOCKED_BODY_DESCRIPTOR = msg({
message: 'Checkout is blocked for this account. Contact {supportEmail} if you need help.',
comment: 'Modal body for the generic "checkout blocked" state. Provides the support email.',
});
const CHECKOUT_BLOCKED_SELF_HOSTED_BODY_DESCRIPTOR = msg({
message: 'Checkout is blocked for this account.',
comment: 'Modal body for the generic "checkout blocked" state on a self-hosted instance.',
});
const CHECKOUT_START_FAILED_TITLE_DESCRIPTOR = msg({
message: "Couldn't start checkout",
comment: 'Title of the generic fallback error modal shown when creating a checkout session fails unexpectedly.',
@@ -234,7 +243,7 @@ function alternativePaymentMethodForCurrency(
isGift: boolean,
plan: Plan,
): CheckoutPaymentMethod | null {
if (isGift || (plan !== 'monthly' && plan !== 'yearly')) {
if (isGift || (plan !== 'monthly' && plan !== 'yearly') || RuntimeConfig.isSelfHosted()) {
return null;
}
if (currency === 'BRL') return 'pix';
@@ -404,9 +413,11 @@ export const useCheckoutActions = (
modal(() => (
<ConfirmModal
title={i18n._(PURCHASES_DISABLED_TITLE_DESCRIPTOR)}
description={i18n._(PURCHASES_DISABLED_BODY_DESCRIPTOR, {
supportEmail: SUPPORT_EMAIL,
})}
description={
RuntimeConfig.isSelfHosted()
? i18n._(PURCHASES_DISABLED_SELF_HOSTED_BODY_DESCRIPTOR)
: i18n._(PURCHASES_DISABLED_BODY_DESCRIPTOR, {supportEmail: SUPPORT_EMAIL})
}
secondaryText={i18n._(CLOSE_DESCRIPTOR)}
data-flx="app.plutonium.use-checkout-actions.handle-checkout-error.confirm-modal--3"
/>
@@ -418,9 +429,11 @@ export const useCheckoutActions = (
modal(() => (
<ConfirmModal
title={i18n._(CHECKOUT_BLOCKED_TITLE_DESCRIPTOR)}
description={i18n._(CHECKOUT_BLOCKED_BODY_DESCRIPTOR, {
supportEmail: SUPPORT_EMAIL,
})}
description={
RuntimeConfig.isSelfHosted()
? i18n._(CHECKOUT_BLOCKED_SELF_HOSTED_BODY_DESCRIPTOR)
: i18n._(CHECKOUT_BLOCKED_BODY_DESCRIPTOR, {supportEmail: SUPPORT_EMAIL})
}
secondaryText={i18n._(CLOSE_DESCRIPTOR)}
data-flx="app.plutonium.use-checkout-actions.handle-checkout-error.confirm-modal--4"
/>
@@ -21,6 +21,7 @@ import * as NotificationUtils from '@app/features/notification/utils/Notificatio
import NativePermission from '@app/features/permissions/system/state/NativePermission';
import {resolvePriceAnnouncementCampaign} from '@app/features/premium/config/PriceAnnouncementCampaign';
import PremiumState from '@app/features/premium/state/PremiumState';
import {canServiceStripeSubscriptions, shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils';
import StreamerMode from '@app/features/streamer_mode/state/StreamerMode';
import Nagbar from '@app/features/ui/state/Nagbar';
import {hasUnavailableElectronNativeContext, isDesktop} from '@app/features/ui/utils/NativeUtils';
@@ -81,6 +82,10 @@ export const useNagbarConditions = (): NagbarConditions => {
const premiumWillCancel = user?.premiumWillCancel ?? false;
const isMockPremium = premiumOverrideType != null && premiumOverrideType > 0;
const isSelfHosted = RuntimeConfig.isSelfHosted();
const showPremium = shouldShowPremiumFeatures();
const canServiceSubscription =
!isSelfHosted ||
(canServiceStripeSubscriptions() && (user?.premiumBillingCycle != null || user?.hasEverPurchased === true));
const [startupVoiceSessionRestoreSnapshotKey, setStartupVoiceSessionRestoreSnapshotKey] = useState<
string | null | undefined
>(undefined);
@@ -109,7 +114,7 @@ export const useNagbarConditions = (): NagbarConditions => {
return true;
})();
const canShowPremiumGracePeriod = (() => {
if (isSelfHosted) return false;
if (!showPremium || !canServiceSubscription) return false;
if (nagbarState.forceHidePremiumGracePeriod) return false;
if (nagbarState.forcePremiumGracePeriod) return true;
if (!user?.premiumUntil || user.premiumType === 2 || premiumWillCancel) return false;
@@ -123,7 +128,7 @@ export const useNagbarConditions = (): NagbarConditions => {
return isInGracePeriod && !nagbarState.premiumGracePeriodDismissed;
})();
const canShowPremiumExpired = (() => {
if (isSelfHosted) return false;
if (!showPremium || !canServiceSubscription) return false;
if (nagbarState.forceHidePremiumExpired) return false;
if (nagbarState.forcePremiumExpired) return true;
if (!user?.premiumUntil || user.premiumType === 2 || premiumWillCancel) return false;
@@ -140,13 +145,13 @@ export const useNagbarConditions = (): NagbarConditions => {
return showExpiredState && !nagbarState.premiumExpiredDismissed;
})();
const canShowGiftInventory = (() => {
if (isSelfHosted) return false;
if (!showPremium) return false;
if (nagbarState.forceHideGiftInventory) return false;
if (nagbarState.forceGiftInventory) return true;
return Boolean(user?.hasUnreadGiftInventory && !nagbarState.giftInventoryDismissed);
})();
const canShowPremiumOnboarding = (() => {
if (isSelfHosted) return false;
if (!showPremium) return false;
if (nagbarState.forceHidePremiumOnboarding) return false;
if (nagbarState.forcePremiumOnboarding) return true;
if (isMockPremium) return false;
@@ -339,7 +339,8 @@ const PREMIUM_MIRROR_NAME_DESCRIPTOR = msg({
comment: 'Premium model option that mirrors free and premium tiers.',
});
const PREMIUM_MIRROR_DESC_DESCRIPTOR = msg({
message: 'Keep free and premium tiers. You can customize the tiers later.',
message:
'Keep free and premium tiers. You can customize the tiers, sell premium through Stripe, or hand out gift codes later from the admin panel.',
comment: 'Description for the mirror premium model.',
});
const PREMIUM_EVERYONE_NAME_DESCRIPTOR = msg({
@@ -10,7 +10,7 @@ import {
import {SkeletonLine} from '@app/features/app/components/skeleton/SkeletonLine';
import {createSkeletonRandomFromKey} from '@app/features/app/components/skeleton/SkeletonSeed';
import {SkeletonEmphasis, SkeletonRadius} from '@app/features/app/components/skeleton/SkeletonStyle';
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
import {shouldShowPremiumFeatures} from '@app/features/premium/utils/PremiumUtils';
import MobileLayout from '@app/features/ui/state/MobileLayout';
import {flxElementClassName} from '@app/lib/react';
import {observer} from 'mobx-react-lite';
@@ -66,7 +66,7 @@ function createDMActionSkeletonSpecs(
if (layout?.personalNotesVisible ?? true) {
specs.push({key: 'personal-notes', labelWidth: PERSONAL_NOTES_ACTION_LABEL_WIDTH});
}
if (layout?.premiumVisible ?? (!RuntimeConfig.isSelfHosted() && !isMobile)) {
if (layout?.premiumVisible ?? (shouldShowPremiumFeatures() && !isMobile)) {
specs.push({key: 'premium', labelWidth: PREMIUM_ACTION_LABEL_WIDTH});
}
return Object.freeze(specs);
@@ -8,6 +8,14 @@ function getBootstrapProductName(): string {
return productName || 'Fluxer';
}
function getBootstrapPremiumProductName(): string {
if (typeof window === 'undefined') {
return 'Plutonium';
}
const premiumProductName = window.__FLUXER_BOOTSTRAP__?.instance.app_public?.branding?.premium_product_name?.trim();
return premiumProductName || 'Plutonium';
}
export const PRODUCT_NAME = getBootstrapProductName();
export const PREMIUM_PRODUCT_NAME = 'Plutonium';
export const PREMIUM_PRODUCT_NAME = getBootstrapPremiumProductName();
export const PREMIUM_PRODUCT_FULL_NAME = `${PRODUCT_NAME} ${PREMIUM_PRODUCT_NAME}`;
@@ -100,6 +100,8 @@ export function runtimeConfigSnapshotsAreSameInstance(
const DEFAULT_INSTANCE_FEATURES: InstanceFeatures = {
voice_enabled: false,
stripe_enabled: false,
premium_enabled: false,
stripe_serviceable: false,
self_hosted: false,
presigned_attachment_uploads: false,
emails_enabled: false,
@@ -147,6 +149,8 @@ export const DEFAULT_APP_PUBLIC_CONFIG: InstanceAppPublic = {
theme_color: null,
status_page_url: null,
status_page_incident_history_url: null,
premium_product_name: 'Plutonium',
premium_info_url: null,
},
setup: {
configured: false,
@@ -398,6 +402,9 @@ class RuntimeConfig {
this.features = {
...this.features,
self_hosted: config.self_hosted,
premium_enabled: !config.self_hosted || config.policy.premium_mode === 'mirror',
stripe_enabled: config.billing.billing_active,
stripe_serviceable: config.billing.stripe_serviceable,
};
this.registration = normalizeInstanceRegistration(config.registration);
this.community = normalizeInstanceCommunity({
@@ -495,6 +502,28 @@ class RuntimeConfig {
return DeveloperOptions.selfHostedModeOverride || this.features.self_hosted;
}
get premiumEnabled(): boolean {
return this.features.premium_enabled;
}
get stripeEnabled(): boolean {
return this.features.stripe_enabled;
}
get stripeServiceable(): boolean {
return this.features.stripe_serviceable;
}
get premiumProductName(): string {
return (
this.appPublic.branding.premium_product_name?.trim() || DEFAULT_APP_PUBLIC_CONFIG.branding.premium_product_name
);
}
get premiumInfoUrl(): string | null {
return this.appPublic.branding.premium_info_url ?? null;
}
get emailsEnabled(): boolean {
return this.features.emails_enabled;
}