mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
feat(api): retire prices safely and add a self-serve switch (#2637)
This commit is contained in:
1 parent
d79cd99050
commit
4a93b677af
77 files changed
+9242
-2542
No files matched your search
+165
-6
@@ -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>
|
||||
|
||||
+4
-1
@@ -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),
|
||||
};
|
||||
};
|
||||
+60
@@ -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 (
|
||||
|
||||
+88
@@ -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>
|
||||
);
|
||||
});
|
||||
+80
@@ -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>
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user