feat(api): retire prices safely and add a self-serve switch (#2637)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-10 17:28:16 +02:00
1 parent d79cd99050
commit 4a93b677af
77 files changed
+9242 -2542

No files matched your search

@@ -2,6 +2,7 @@
import {ConfirmModal} from '@app/features/app/components/dialogs/ConfirmModal';
import {PerksButton} from '@app/features/app/components/dialogs/components/PerksButton';
import {useSubscriptionActions} from '@app/features/app/components/dialogs/components/plutonium/hooks/useSubscriptionActions';
import type {GracePeriodInfo} from '@app/features/app/components/dialogs/components/plutonium/hooks/useSubscriptionStatus';
import statusStyles from '@app/features/app/components/dialogs/components/plutonium/PurchaseHistoryStatus.module.css';
import styles from '@app/features/app/components/dialogs/components/plutonium/SubscriptionCard.module.css';
@@ -11,6 +12,7 @@ import {
PREMIUM_PRODUCT_NAME,
} from '@app/features/app/config/I18nDisplayConstants';
import {JOIN_COMMUNITY_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import PremiumState from '@app/features/premium/state/PremiumState';
import {
CLAIM_ACCOUNT_TO_PURCHASE_OR_REDEEM_PREMIUM_DESCRIPTOR,
MANAGE_SUBSCRIPTION_DESCRIPTOR,
@@ -21,6 +23,7 @@ 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 {Tooltip} from '@app/features/ui/tooltip/Tooltip';
import Users from '@app/features/user/state/Users';
import {getFormattedLongDate} from '@fluxer/date_utils/src/DateFormatting';
import type {PendingSubscriptionChangeResponse} from '@fluxer/schema/src/domains/premium/PremiumSchemas';
import {msg} from '@lingui/core/macro';
@@ -55,6 +58,14 @@ const SWITCH_TO_MONTHLY_DESCRIPTOR = msg({
message: 'Switch to monthly',
comment: 'Button confirming a change to monthly subscription billing.',
});
const SWITCH_TO_THE_NEW_PRICE_TITLE_DESCRIPTOR = msg({
message: 'Switch to the new price?',
comment: 'Billing confirmation title for moving an active subscription down to the current price.',
});
const SWITCH_PRICE_DESCRIPTOR = msg({
message: 'Switch price',
comment: 'Button confirming a move of an active subscription down to the current price.',
});
const NOT_NOW_DESCRIPTOR = msg({
message: 'Not now',
comment: 'Button that cancels a subscription billing change.',
@@ -171,6 +182,7 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
purchaseDisabledTooltip,
}) => {
const {i18n} = useLingui();
const {loadingSwitchToListPrice, handleSwitchToListPrice} = useSubscriptionActions();
const {isInGracePeriod, isExpired: isFullyExpired, graceEndDate} = gracePeriodInfo;
const tooltipText: string | (() => React.ReactNode) =
purchaseDisabledTooltip != null
@@ -184,9 +196,26 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
const effectiveYearlyPrice =
billingCycle === 'yearly' && currentSubscriptionPriceLabel ? currentSubscriptionPriceLabel : yearlyPrice;
const hasPendingSubscriptionChange = pendingSubscriptionChange != null && !premiumWillCancel;
const currentUserId = Users.currentUser?.id;
const listPriceSwitch =
currentUserId != null && PremiumState.loadedForUserId === currentUserId
? (PremiumState.state?.billing.list_price_switch ?? null)
: null;
const listPriceNewLabel = listPriceSwitch
? formatMinorUnitPrice(listPriceSwitch.list_amount_minor, listPriceSwitch.currency, locale)
: null;
const listPriceCurrentLabel = listPriceSwitch
? formatMinorUnitPrice(listPriceSwitch.current_amount_minor, listPriceSwitch.currency, locale)
: null;
const listPriceEffectiveDate = listPriceSwitch?.effective_at
? getFormattedLongDate(listPriceSwitch.effective_at, locale)
: null;
const hasPendingListPriceSwitch = listPriceSwitch?.pending === true && !premiumWillCancel;
const pendingChangeDate = pendingSubscriptionChange
? getFormattedLongDate(new Date(pendingSubscriptionChange.effective_at), locale)
: null;
: hasPendingListPriceSwitch
? listPriceEffectiveDate
: null;
const pendingInitialPriceLabel = pendingSubscriptionChange
? formatMinorUnitPrice(pendingSubscriptionChange.initial_amount_minor, pendingSubscriptionChange.currency, locale)
: null;
@@ -200,6 +229,26 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
const pendingCreditPriceLabel = pendingSubscriptionChange
? formatMinorUnitPrice(pendingSubscriptionChange.credit_amount_minor, pendingSubscriptionChange.currency, locale)
: null;
const canSwitchToListPrice =
listPriceSwitch?.available === true &&
!premiumWillCancel &&
!hasPendingSubscriptionChange &&
listPriceNewLabel != null &&
listPriceCurrentLabel != null &&
listPriceEffectiveDate != null;
const shouldSuggestCancelingPendingChange =
listPriceSwitch?.available === false &&
listPriceSwitch.reason === 'conflicting_pending_change' &&
listPriceSwitch.list_amount_minor != null &&
listPriceSwitch.current_amount_minor != null &&
listPriceSwitch.list_amount_minor < listPriceSwitch.current_amount_minor;
const shouldMentionListPriceWhileCancelling =
listPriceSwitch?.available === false &&
listPriceSwitch.reason === 'subscription_cancelling' &&
listPriceSwitch.list_amount_minor != null &&
listPriceSwitch.current_amount_minor != null &&
listPriceSwitch.list_amount_minor < listPriceSwitch.current_amount_minor;
const pendingTargetPriceLabel = hasPendingListPriceSwitch ? listPriceNewLabel : null;
const grandfatheredTooltip =
isCurrentSubscriptionGrandfathered && currentSubscriptionListPriceLabel
? i18n._(LEGACY_RATE_WITH_PRICE_DESCRIPTOR, {currentSubscriptionListPriceLabel})
@@ -314,6 +363,32 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
yearlyAmountMinor,
],
);
const handleConfirmSwitchToListPrice = useCallback(() => {
ModalCommands.push(
modal(() => (
<ConfirmModal
title={i18n._(SWITCH_TO_THE_NEW_PRICE_TITLE_DESCRIPTOR)}
description={
<Trans comment="Billing confirmation body for moving an active subscription down to the current price. {listPriceEffectiveDate} is a date, and {listPriceCurrentLabel} and {listPriceNewLabel} are currency amounts, all already formatted and localized by code; never write a date or an amount into the translation.">
On{' '}
<strong data-flx="app.plutonium.subscription-card.handle-confirm-switch-to-list-price.strong">
{listPriceEffectiveDate}
</strong>{' '}
your subscription moves from {listPriceCurrentLabel} to {listPriceNewLabel}. Nothing is charged today,
and the rest of your subscription stays exactly as it is.
</Trans>
}
primaryText={i18n._(SWITCH_PRICE_DESCRIPTOR)}
primaryVariant="primary"
secondaryText={i18n._(NOT_NOW_DESCRIPTOR)}
onPrimary={async () => {
await handleSwitchToListPrice();
}}
data-flx="app.plutonium.subscription-card.handle-confirm-switch-to-list-price.confirm-modal"
/>
)),
);
}, [handleSwitchToListPrice, i18n, listPriceCurrentLabel, listPriceEffectiveDate, listPriceNewLabel]);
const wrapIfDisabled = (element: React.ReactElement, key: string, disabled: boolean) =>
disabled ? (
<Tooltip key={key} text={tooltipText} data-flx="app.plutonium.subscription-card.wrap-if-disabled.tooltip">
@@ -498,8 +573,7 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
)}
</div>
{!isVisionary &&
hasPendingSubscriptionChange &&
pendingSubscriptionChange &&
(hasPendingSubscriptionChange || hasPendingListPriceSwitch) &&
pendingChangeDate &&
!isInGracePeriod &&
!isFullyExpired &&
@@ -508,7 +582,20 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
className={styles.pendingChangeInfo}
data-flx="app.plutonium.subscription-card.pending-change-info"
>
{pendingSubscriptionChange.target_billing_cycle === 'yearly' ? (
{hasPendingListPriceSwitch ? (
pendingTargetPriceLabel ? (
<Trans comment="Plutonium subscription card line shown when a move to a lower price is scheduled. {pendingChangeDate} is a date and {pendingTargetPriceLabel} is a currency amount, both already formatted and localized by code; never write a date or an amount into the translation.">
New price scheduled for{' '}
<strong data-flx="app.plutonium.subscription-card.strong--13">{pendingChangeDate}</strong>.
Renewals will be {pendingTargetPriceLabel} from then on.
</Trans>
) : (
<Trans comment="Plutonium subscription card line shown when a move to a lower price is scheduled but the new amount is unknown. {pendingChangeDate} is a date already formatted and localized by code; never write a date into the translation.">
New price scheduled for{' '}
<strong data-flx="app.plutonium.subscription-card.strong--13">{pendingChangeDate}</strong>.
</Trans>
)
) : pendingSubscriptionChange?.target_billing_cycle === 'yearly' ? (
pendingInitialPriceLabel && pendingRecurringPriceLabel ? (
pendingCreditPriceLabel ? (
<Trans>
@@ -546,6 +633,59 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
)}
</div>
)}
{!isVisionary &&
shouldSuggestCancelingPendingChange &&
!isInGracePeriod &&
!isFullyExpired &&
!isGiftSubscription && (
<div
className={styles.pendingChangeInfo}
data-flx="app.plutonium.subscription-card.list-price-switch-blocked-info"
>
<Trans comment="Plutonium subscription card hint shown to a subscriber who could move to a lower price but has another billing change already scheduled.">
Cancel the scheduled change to move to the current price instead.
</Trans>
</div>
)}
{!isVisionary &&
shouldMentionListPriceWhileCancelling &&
listPriceNewLabel &&
premiumUntil &&
!isInGracePeriod &&
!isFullyExpired &&
!isGiftSubscription &&
(() => {
const cancelDate = getFormattedLongDate(premiumUntil, locale);
return (
<div
className={styles.pendingChangeInfo}
data-flx="app.plutonium.subscription-card.list-price-switch-cancelling-info"
>
<Trans comment="Plutonium subscription card line shown to a subscriber on a legacy price whose subscription is already set to cancel. {cancelDate} is a date and {listPriceNewLabel} is a currency amount, both already formatted and localized by code; never write a date or an amount into the translation.">
Your subscription still ends on{' '}
<strong data-flx="app.plutonium.subscription-card.strong--15">{cancelDate}</strong>. The current
price is now {listPriceNewLabel}. If you reactivate, you can switch to it from here.
</Trans>
</div>
);
})()}
{!isVisionary &&
canSwitchToListPrice &&
listPriceEffectiveDate &&
!isInGracePeriod &&
!isFullyExpired &&
!isGiftSubscription && (
<div
className={styles.pendingChangeInfo}
data-flx="app.plutonium.subscription-card.list-price-switch-info"
>
<Trans comment="Plutonium subscription card line offering a move to a lower price. {listPriceEffectiveDate} is a date already formatted and localized by code; never write a date into the translation.">
A lower price is available. Switching takes effect on{' '}
<strong data-flx="app.plutonium.subscription-card.strong--14">{listPriceEffectiveDate}</strong> and
changes nothing else.
</Trans>
</div>
)}
{!isVisionary &&
premiumUntil &&
!premiumWillCancel &&
@@ -628,6 +768,7 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
{shouldUseChangePlanQuickAction &&
targetBillingCycle &&
!hasPendingSubscriptionChange &&
!hasPendingListPriceSwitch &&
wrapIfDisabled(
<Button
variant="secondary"
@@ -651,7 +792,21 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
'change-plan',
purchaseDisabled,
)}
{hasPendingSubscriptionChange && (
{canSwitchToListPrice && listPriceNewLabel && (
<Button
variant="secondary"
onClick={handleConfirmSwitchToListPrice}
submitting={loadingSwitchToListPrice}
small
className={styles.actionButton}
data-flx="app.plutonium.subscription-card.action-button.confirm-switch-to-list-price"
>
<Trans comment="Billing button that opens confirmation to move the subscription down to the current price. {listPriceNewLabel} is the localized new price.">
Switch to {listPriceNewLabel}
</Trans>
</Button>
)}
{(hasPendingSubscriptionChange || hasPendingListPriceSwitch) && (
<Button
variant="secondary"
onClick={handleCancelPendingSubscriptionChange}
@@ -660,7 +815,11 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
className={styles.actionButton}
data-flx="app.plutonium.subscription-card.action-button.cancel-pending-subscription-change"
>
{pendingSubscriptionChange?.target_billing_cycle === 'yearly' ? (
{hasPendingListPriceSwitch ? (
<Trans comment="Billing button that cancels a scheduled move to a lower subscription price.">
Cancel price change
</Trans>
) : pendingSubscriptionChange?.target_billing_cycle === 'yearly' ? (
<Trans comment="Billing button that cancels a scheduled yearly upgrade.">
Cancel yearly upgrade
</Trans>
@@ -106,6 +106,9 @@ export const usePremiumData = (
currentSubscriptionPrice,
currentSubscriptionPriceLabel,
currentSubscriptionListPriceLabel,
isCurrentSubscriptionGrandfathered: currentSubscriptionPrice?.is_grandfathered ?? false,
isCurrentSubscriptionGrandfathered:
currentSubscriptionPrice?.is_grandfathered === true &&
(currentSubscriptionPrice.list_amount_minor == null ||
currentSubscriptionPrice.list_amount_minor > currentSubscriptionPrice.amount_minor),
};
};
@@ -5,6 +5,8 @@ import {Logger} from '@app/features/platform/utils/AppLogger';
import * as PremiumCommands from '@app/features/premium/commands/PremiumCommands';
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
import {openExternalUrl} from '@app/features/ui/utils/NativeUtils';
import * as LocaleUtils from '@app/features/user/utils/LocaleUtils';
import {getFormattedLongDate} from '@fluxer/date_utils/src/DateFormatting';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {useCallback, useState} from 'react';
@@ -91,6 +93,31 @@ const CANCEL_PENDING_CHANGE_FAILED_MESSAGE_DESCRIPTOR = msg({
comment:
'Body of the generic fallback error modal shown when canceling a scheduled Plutonium billing cycle change fails.',
});
const YOUR_SUBSCRIPTION_WILL_MOVE_TO_THE_NEW_PRICE_DESCRIPTOR = msg({
message: 'Your subscription moves to the new price on {effectiveDate}.',
comment:
'Toast success shown after a Plutonium subscription is scheduled to move down to the current price. {effectiveDate} is the localized date the new price starts.',
});
const THE_NEW_PRICE_IS_ALREADY_SCHEDULED_DESCRIPTOR = msg({
message: 'The new price is already scheduled for {effectiveDate}.',
comment:
'Toast success shown when a move down to the current price was already scheduled by an earlier request. {effectiveDate} is the localized date the new price starts.',
});
const SWITCH_TO_LIST_PRICE_UNAVAILABLE_DESCRIPTOR = msg({
message: "Your subscription can't move to the new price right now.",
comment:
'Toast error shown when the server refuses to move a Plutonium subscription down to the current price, whatever the reason.',
});
const SWITCH_TO_LIST_PRICE_FAILED_TITLE_DESCRIPTOR = msg({
message: "Couldn't change your price",
comment:
'Title of the generic fallback error modal shown when moving a Plutonium subscription down to the current price fails.',
});
const SWITCH_TO_LIST_PRICE_FAILED_MESSAGE_DESCRIPTOR = msg({
message: 'Something went wrong while changing your price. Please try again in a moment.',
comment:
'Body of the generic fallback error modal shown when moving a Plutonium subscription down to the current price fails.',
});
const logger = new Logger('useSubscriptionActions');
export const useSubscriptionActions = (countryCode?: string | null) => {
const {i18n} = useLingui();
@@ -100,6 +127,7 @@ export const useSubscriptionActions = (countryCode?: string | null) => {
const [loadingEndGrace, setLoadingEndGrace] = useState(false);
const [loadingChangeBillingCycle, setLoadingChangeBillingCycle] = useState<'monthly' | 'yearly' | null>(null);
const [loadingCancelPendingChange, setLoadingCancelPendingChange] = useState(false);
const [loadingSwitchToListPrice, setLoadingSwitchToListPrice] = useState(false);
const handleOpenCustomerPortal = useCallback(async () => {
setLoadingPortal(true);
try {
@@ -213,6 +241,36 @@ export const useSubscriptionActions = (countryCode?: string | null) => {
},
[countryCode, i18n],
);
const handleSwitchToListPrice = useCallback(async () => {
setLoadingSwitchToListPrice(true);
try {
const result = await PremiumCommands.switchSubscriptionToListPrice();
await PremiumCommands.refreshPremiumState(countryCode ?? undefined);
if (result.status === 'ineligible') {
logger.warn('List price switch refused', {reason: result.reason});
ToastCommands.error(i18n._(SWITCH_TO_LIST_PRICE_UNAVAILABLE_DESCRIPTOR));
return;
}
const effectiveDate = getFormattedLongDate(result.effective_at, LocaleUtils.getCurrentLocale());
ToastCommands.success(
result.status === 'already_scheduled'
? i18n._(THE_NEW_PRICE_IS_ALREADY_SCHEDULED_DESCRIPTOR, {effectiveDate})
: i18n._(YOUR_SUBSCRIPTION_WILL_MOVE_TO_THE_NEW_PRICE_DESCRIPTOR, {effectiveDate}),
);
} catch (error) {
logger.error('Failed to switch subscription to the current list price', error);
showPremiumActionErrorModal(
error,
{
fallbackTitle: SWITCH_TO_LIST_PRICE_FAILED_TITLE_DESCRIPTOR,
fallbackMessage: SWITCH_TO_LIST_PRICE_FAILED_MESSAGE_DESCRIPTOR,
},
'app.plutonium.use-subscription-actions.switch-to-list-price.generic-error-modal',
);
} finally {
setLoadingSwitchToListPrice(false);
}
}, [countryCode, i18n]);
const handleCancelPendingSubscriptionChange = useCallback(async () => {
setLoadingCancelPendingChange(true);
try {
@@ -240,11 +298,13 @@ export const useSubscriptionActions = (countryCode?: string | null) => {
loadingEndGrace,
loadingChangeBillingCycle,
loadingCancelPendingChange,
loadingSwitchToListPrice,
handleOpenCustomerPortal,
handleCancelSubscription,
handleEndPremiumGracePeriod,
handleReactivateSubscription,
handleChangeSubscriptionBillingCycle,
handleCancelPendingSubscriptionChange,
handleSwitchToListPrice,
};
};
@@ -1275,6 +1275,8 @@ const SKELETON_NAGBAR_ROW_SHAPES: Record<NagbarType, SkeletonNagbarRowShape> = {
[NagbarType.PREMIUM_GRACE_PERIOD]: {tone: SkeletonNagbarTone.PREMIUM, hasActions: true},
[NagbarType.PREMIUM_EXPIRED]: {tone: SkeletonNagbarTone.DANGER, hasActions: true},
[NagbarType.PREMIUM_ONBOARDING]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
[NagbarType.PRICE_ANNOUNCEMENT]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
[NagbarType.LEGACY_PRICE_OPT_IN]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
[NagbarType.GIFT_INVENTORY]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
[NagbarType.DESKTOP_DOWNLOAD]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
[NagbarType.DESKTOP_UPDATE_READY]: {tone: SkeletonNagbarTone.BRAND, hasActions: true},
@@ -18,6 +18,8 @@ import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
import * as NotificationUtils from '@app/features/notification/utils/NotificationUtils';
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 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';
@@ -151,6 +153,27 @@ export const useNagbarConditions = (): NagbarConditions => {
user?.isPremium() && !user?.hasDismissedPremiumOnboarding && !nagbarState.premiumOnboardingDismissed,
);
})();
const premiumState = PremiumState.loadedForUserId === user?.id ? PremiumState.state : null;
const priceAnnouncementCampaign = resolvePriceAnnouncementCampaign(premiumState);
const hasPurchaseReadyAccount = Boolean(user?.isClaimed() && (!RuntimeConfig.emailsEnabled || user.verified));
const canShowPriceAnnouncement = (() => {
if (isSelfHosted) return false;
if (!hasPurchaseReadyAccount) return false;
if (!premiumState || !priceAnnouncementCampaign) return false;
if (premiumState.actual.has_active_paid_premium) return false;
return !nagbarState.getPriceAnnouncementDismissed(priceAnnouncementCampaign.campaign.id);
})();
const canShowLegacyPriceOptIn = (() => {
if (isSelfHosted) return false;
if (!hasPurchaseReadyAccount) return false;
if (!premiumState || !priceAnnouncementCampaign) return false;
const listPriceSwitch = premiumState.billing.list_price_switch ?? null;
if (!listPriceSwitch?.available || listPriceSwitch.pending) return false;
if (listPriceSwitch.currency !== priceAnnouncementCampaign.currency) return false;
if (listPriceSwitch.current_amount_minor == null || listPriceSwitch.list_amount_minor == null) return false;
if (listPriceSwitch.effective_at == null || listPriceSwitch.billing_cycle == null) return false;
return !nagbarState.getLegacyPriceOptInDismissed(priceAnnouncementCampaign.campaign.id);
})();
const isNativeDesktop = isDesktop();
const hasBrokenElectronNativeContext = hasUnavailableElectronNativeContext();
const isMobileDevice = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
@@ -281,6 +304,8 @@ export const useNagbarConditions = (): NagbarConditions => {
canShowPremiumGracePeriod,
canShowPremiumExpired,
canShowPremiumOnboarding,
canShowPriceAnnouncement,
canShowLegacyPriceOptIn,
canShowGiftInventory,
canShowDesktopDownload,
canShowGuildMembershipCta,
@@ -356,6 +381,12 @@ export const useActiveNagbars = (conditions: NagbarConditions): Array<NagbarStat
visible: conditions.canShowVoiceSessionRestore,
dismissible: true,
},
{
type: NagbarType.LEGACY_PRICE_OPT_IN,
priority: 2,
visible: conditions.canShowLegacyPriceOptIn,
dismissible: true,
},
{
type: NagbarType.PREMIUM_ONBOARDING,
priority: 4,
@@ -368,6 +399,12 @@ export const useActiveNagbars = (conditions: NagbarConditions): Array<NagbarStat
visible: conditions.canShowGiftInventory,
dismissible: true,
},
{
type: NagbarType.PRICE_ANNOUNCEMENT,
priority: 5.5,
visible: conditions.canShowPriceAnnouncement,
dismissible: true,
},
{
type: NagbarType.GUILD_MEMBERSHIP_CTA,
priority: 6,
@@ -13,6 +13,8 @@ export const NagbarType = {
PREMIUM_GRACE_PERIOD: 'premium-grace-period',
PREMIUM_EXPIRED: 'premium-expired',
PREMIUM_ONBOARDING: 'premium-onboarding',
PRICE_ANNOUNCEMENT: 'price-announcement',
LEGACY_PRICE_OPT_IN: 'legacy-price-opt-in',
GIFT_INVENTORY: 'gift-inventory',
DESKTOP_DOWNLOAD: 'desktop-download',
DESKTOP_UPDATE_READY: 'desktop-update-ready',
@@ -49,6 +51,8 @@ export interface NagbarConditions {
canShowPremiumGracePeriod: boolean;
canShowPremiumExpired: boolean;
canShowPremiumOnboarding: boolean;
canShowPriceAnnouncement: boolean;
canShowLegacyPriceOptIn: boolean;
canShowGiftInventory: boolean;
canShowDesktopDownload: boolean;
canShowDesktopUpdateReady: boolean;
@@ -11,10 +11,12 @@ import {DesktopUpdateReadyNagbar} from '@app/features/app/components/layout/app_
import {EmailVerificationNagbar} from '@app/features/app/components/layout/app_layout/nagbars/EmailVerificationNagbar';
import {GiftInventoryNagbar} from '@app/features/app/components/layout/app_layout/nagbars/GiftInventoryNagbar';
import {GuildMembershipCtaNagbar} from '@app/features/app/components/layout/app_layout/nagbars/GuildMembershipCtaNagbar';
import {LegacyPriceOptInNagbar} from '@app/features/app/components/layout/app_layout/nagbars/LegacyPriceOptInNagbar';
import {LinuxInputAccessNagbar} from '@app/features/app/components/layout/app_layout/nagbars/LinuxInputAccessNagbar';
import {PremiumExpiredNagbar} from '@app/features/app/components/layout/app_layout/nagbars/PremiumExpiredNagbar';
import {PremiumGracePeriodNagbar} from '@app/features/app/components/layout/app_layout/nagbars/PremiumGracePeriodNagbar';
import {PremiumOnboardingNagbar} from '@app/features/app/components/layout/app_layout/nagbars/PremiumOnboardingNagbar';
import {PriceAnnouncementNagbar} from '@app/features/app/components/layout/app_layout/nagbars/PriceAnnouncementNagbar';
import {ScheduledMaintenanceNagbar} from '@app/features/app/components/layout/app_layout/nagbars/ScheduledMaintenanceNagbar';
import {StreamerModeNagbar} from '@app/features/app/components/layout/app_layout/nagbars/StreamerModeNagbar';
import {TermsAcceptanceNagbar} from '@app/features/app/components/layout/app_layout/nagbars/TermsAcceptanceNagbar';
@@ -137,6 +139,24 @@ export const NagbarContainer: React.FC<NagbarContainerProps> = observer(({nagbar
data-flx="app.app-layout.nagbar-container.premium-onboarding-nagbar"
/>
);
case NagbarType.PRICE_ANNOUNCEMENT:
if (!showPremiumFeatures) return null;
return (
<PriceAnnouncementNagbar
key={nagbar.type}
isMobile={mobileLayout.enabled}
data-flx="app.app-layout.nagbar-container.price-announcement-nagbar"
/>
);
case NagbarType.LEGACY_PRICE_OPT_IN:
if (!showPremiumFeatures) return null;
return (
<LegacyPriceOptInNagbar
key={nagbar.type}
isMobile={mobileLayout.enabled}
data-flx="app.app-layout.nagbar-container.legacy-price-opt-in-nagbar"
/>
);
case NagbarType.GIFT_INVENTORY:
if (!showPremiumFeatures) return null;
return (
@@ -0,0 +1,88 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
import {resolvePriceAnnouncementCampaign} from '@app/features/premium/config/PriceAnnouncementCampaign';
import PremiumState from '@app/features/premium/state/PremiumState';
import {MANAGE_SUBSCRIPTION_DESCRIPTOR} from '@app/features/premium/utils/PremiumMessageDescriptors';
import {formatMinorUnitPrice} from '@app/features/premium/utils/PricingUtils';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {modal} from '@app/features/ui/commands/ModalCommands';
import NagbarState from '@app/features/ui/state/Nagbar';
import {UserSettingsModal} from '@app/features/user/components/modals/UserSettingsModal';
import Users from '@app/features/user/state/Users';
import * as LocaleUtils from '@app/features/user/utils/LocaleUtils';
import {getFormattedLongDate} from '@fluxer/date_utils/src/DateFormatting';
import {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {useCallback} from 'react';
export const LegacyPriceOptInNagbar = observer(function LegacyPriceOptInNagbar({isMobile}: {isMobile: boolean}) {
const {i18n} = useLingui();
const currentUser = Users.currentUser;
const premiumState = PremiumState.loadedForUserId === currentUser?.id ? PremiumState.state : null;
const resolved = resolvePriceAnnouncementCampaign(premiumState);
const campaignId = resolved?.campaign.id ?? null;
const handleOpenPremiumSettings = useCallback(() => {
ModalCommands.push(
modal(() => (
<UserSettingsModal
initialTab="plutonium"
data-flx="app.app-layout.nagbars.legacy-price-opt-in-nagbar.handle-open-premium-settings.user-settings-modal"
/>
)),
);
}, []);
const handleDismiss = useCallback(() => {
if (campaignId == null) return;
NagbarState.dismissLegacyPriceOptIn(campaignId);
}, [campaignId]);
if (!premiumState || !resolved) return null;
const listPriceSwitch = premiumState.billing.list_price_switch ?? null;
if (!listPriceSwitch?.available || listPriceSwitch.pending) return null;
if (listPriceSwitch.currency !== resolved.currency || listPriceSwitch.effective_at == null) return null;
if (listPriceSwitch.billing_cycle == null) return null;
const locale = LocaleUtils.getCurrentLocale();
const currentPrice = formatMinorUnitPrice(listPriceSwitch.current_amount_minor, listPriceSwitch.currency, locale);
const newPrice = formatMinorUnitPrice(listPriceSwitch.list_amount_minor, listPriceSwitch.currency, locale);
if (currentPrice == null || newPrice == null) return null;
return (
<Nagbar
isMobile={isMobile}
backgroundColor={NAGBAR_TONES[NagbarToneKind.BRAND].backgroundColor}
textColor={NAGBAR_TONES[NagbarToneKind.BRAND].textColor}
dismissible
onDismiss={handleDismiss}
data-flx="app.app-layout.nagbars.legacy-price-opt-in-nagbar.nagbar"
>
<NagbarContent
isMobile={isMobile}
onDismiss={handleDismiss}
message={i18n._(
listPriceSwitch.billing_cycle === 'monthly'
? resolved.campaign.optInMonthlyMessage
: resolved.campaign.optInYearlyMessage,
{
premiumProductName: PREMIUM_PRODUCT_NAME,
currentPrice,
newPrice,
effectiveDate: getFormattedLongDate(listPriceSwitch.effective_at, locale),
},
)}
actions={
<NagbarButton
isMobile={isMobile}
onClick={handleOpenPremiumSettings}
data-flx="app.app-layout.nagbars.legacy-price-opt-in-nagbar.nagbar-button.open-premium-settings"
>
{i18n._(MANAGE_SUBSCRIPTION_DESCRIPTOR)}
</NagbarButton>
}
data-flx="app.app-layout.nagbars.legacy-price-opt-in-nagbar.nagbar-content"
/>
</Nagbar>
);
});
@@ -0,0 +1,80 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Nagbar} from '@app/features/app/components/layout/Nagbar';
import {NagbarButton} from '@app/features/app/components/layout/NagbarButton';
import {NagbarContent} from '@app/features/app/components/layout/NagbarContent';
import {NAGBAR_TONES, NagbarToneKind} from '@app/features/app/components/layout/NagbarTones';
import {PREMIUM_PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
import {resolvePriceAnnouncementCampaign} from '@app/features/premium/config/PriceAnnouncementCampaign';
import PremiumState from '@app/features/premium/state/PremiumState';
import {VIEW_PLANS_DESCRIPTOR} from '@app/features/premium/utils/PremiumMessageDescriptors';
import {formatMinorUnitPrice} from '@app/features/premium/utils/PricingUtils';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {modal} from '@app/features/ui/commands/ModalCommands';
import NagbarState from '@app/features/ui/state/Nagbar';
import {UserSettingsModal} from '@app/features/user/components/modals/UserSettingsModal';
import Users from '@app/features/user/state/Users';
import * as LocaleUtils from '@app/features/user/utils/LocaleUtils';
import {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {useCallback} from 'react';
export const PriceAnnouncementNagbar = observer(function PriceAnnouncementNagbar({isMobile}: {isMobile: boolean}) {
const {i18n} = useLingui();
const currentUser = Users.currentUser;
const premiumState = PremiumState.loadedForUserId === currentUser?.id ? PremiumState.state : null;
const resolved = resolvePriceAnnouncementCampaign(premiumState);
const campaignId = resolved?.campaign.id ?? null;
const handleOpenPlans = useCallback(() => {
if (campaignId != null) {
NagbarState.dismissPriceAnnouncement(campaignId);
}
ModalCommands.push(
modal(() => (
<UserSettingsModal
initialTab="plutonium"
data-flx="app.app-layout.nagbars.price-announcement-nagbar.handle-open-plans.user-settings-modal"
/>
)),
);
}, [campaignId]);
const handleDismiss = useCallback(() => {
if (campaignId == null) return;
NagbarState.dismissPriceAnnouncement(campaignId);
}, [campaignId]);
if (!premiumState || !resolved || premiumState.actual.has_active_paid_premium) return null;
const locale = LocaleUtils.getCurrentLocale();
const monthlyPrice = formatMinorUnitPrice(resolved.monthlyAmountMinor, resolved.currency, locale);
const yearlyPrice = formatMinorUnitPrice(resolved.yearlyAmountMinor, resolved.currency, locale);
if (monthlyPrice == null || yearlyPrice == null) return null;
return (
<Nagbar
isMobile={isMobile}
backgroundColor={NAGBAR_TONES[NagbarToneKind.BRAND].backgroundColor}
textColor={NAGBAR_TONES[NagbarToneKind.BRAND].textColor}
dismissible
onDismiss={handleDismiss}
data-flx="app.app-layout.nagbars.price-announcement-nagbar.nagbar"
>
<NagbarContent
isMobile={isMobile}
onDismiss={handleDismiss}
message={i18n._(resolved.campaign.announcementMessage, {
premiumProductName: PREMIUM_PRODUCT_NAME,
monthlyPrice,
yearlyPrice,
})}
actions={
<NagbarButton
isMobile={isMobile}
onClick={handleOpenPlans}
data-flx="app.app-layout.nagbars.price-announcement-nagbar.nagbar-button.open-plans"
>
{i18n._(VIEW_PLANS_DESCRIPTOR)}
</NagbarButton>
}
data-flx="app.app-layout.nagbars.price-announcement-nagbar.nagbar-content"
/>
</Nagbar>
);
});