feat(premium): native Nordic prices and subscribing during grace (#3274)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-08 04:21:54 +02:00
1 parent 0ef1cd14c3
commit 7a4aa42d4b
84 files changed
+7720 -4589

No files matched your search

@@ -62,6 +62,7 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
const [isGiftMode, setIsGiftMode] = useState(defaultGiftMode);
const giftSectionRef = useRef<HTMLDivElement | null>(null);
const perksSectionRef = useRef<HTMLDivElement | null>(null);
const pricingSectionRef = useRef<HTMLDivElement | null>(null);
const countryCode = GeoIP.countryCode;
const guilds = Guilds.getGuilds();
const visionaryGuild = useMemo(() => {
@@ -94,12 +95,9 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
handleCancelPendingSubscriptionChange,
} = useSubscriptionActions(countryCode);
const {loadingRejoinCommunity, handleCommunityButtonClick} = useCommunityActions(visionaryGuild);
const {loadingCheckout, handleSelectPlan} = useCheckoutActions(
priceIds,
countryCode,
subscriptionStatus.isGiftSubscription,
mobileLayoutState.enabled,
);
const {loadingCheckout, handleSelectPlan} = useCheckoutActions(priceIds, countryCode, mobileLayoutState.enabled, {
hasGiftTime: subscriptionStatus.isGiftSubscription,
});
useEffect(() => {
if (!currentUser?.id) return;
void PremiumCommands.refreshPremiumState(countryCode ?? undefined);
@@ -114,6 +112,7 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
const billingUnavailable =
!shouldShowPremiumFeatures() ||
(!arePremiumPurchasesAvailable() && !(hasBillingRelationship && canServiceStripeSubscriptions()));
const canSubscribe = purchasesAvailable && subscriptionStatus.canStartSubscription;
const purchaseDisabledTooltip = !isClaimed
? i18n._(CLAIM_ACCOUNT_TO_PURCHASE_PREMIUM_DESCRIPTOR, {premiumProductFullName: getPremiumProductFullName()})
: i18n._(VERIFY_EMAIL_TO_PURCHASE_PREMIUM_DESCRIPTOR, {premiumProductFullName: getPremiumProductFullName()});
@@ -127,6 +126,9 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
const scrollToPerks = useCallback(() => {
perksSectionRef.current?.scrollIntoView({behavior: 'auto', block: 'start'});
}, []);
const scrollToPricing = useCallback(() => {
pricingSectionRef.current?.scrollIntoView({behavior: 'auto', block: 'start'});
}, []);
const navigateToRedeemGift = useCallback(() => {
ComponentBus.dispatch('USER_SETTINGS_TAB_SELECT', {tab: 'gift_inventory'});
}, []);
@@ -227,6 +229,8 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
isVisionary={subscriptionStatus.isVisionary}
perksDisabled={subscriptionStatus.perksDisabled}
isGiftSubscription={subscriptionStatus.isGiftSubscription}
isGiftGrace={subscriptionStatus.isGiftGrace}
canSubscribe={canSubscribe}
storeSubscription={subscriptionStatus.storeSubscription}
premiumUntil={subscriptionStatus.actualPremiumUntil}
billingCycle={subscriptionStatus.billingCycle}
@@ -256,6 +260,7 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
loadingRejoinCommunity={loadingRejoinCommunity}
scrollToPerks={scrollToPerks}
navigateToRedeemGift={navigateToRedeemGift}
handleStartSubscription={scrollToPricing}
handleOpenCustomerPortal={handleOpenCustomerPortal}
handleReactivateSubscription={handleReactivateSubscription}
handleCancelSubscription={handleCancelSubscriptionConfirmed}
@@ -312,21 +317,23 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
</Button>
</section>
)
) : !subscriptionStatus.shouldShowPremiumCard ? (
<PricingSection
isGiftMode={isGiftMode && giftPurchasesAvailable}
giftPurchasesAvailable={giftPurchasesAvailable}
setIsGiftMode={setIsGiftMode}
monthlyPrice={monthlyPrice}
yearlyPrice={yearlyPrice}
giftMonthlyPrice={giftMonthlyPrice}
giftYearlyPrice={giftYearlyPrice}
loadingCheckout={loadingCheckout}
handleSelectPlan={handleSelectPlanGuarded}
purchaseDisabled={purchaseDisabled}
purchaseDisabledTooltip={purchaseDisabledTooltip}
data-flx="app.plutonium-content.pricing-section"
/>
) : canSubscribe ? (
<div ref={pricingSectionRef} data-flx="app.plutonium-content.pricing-anchor">
<PricingSection
isGiftMode={isGiftMode && giftPurchasesAvailable}
giftPurchasesAvailable={giftPurchasesAvailable}
setIsGiftMode={setIsGiftMode}
monthlyPrice={monthlyPrice}
yearlyPrice={yearlyPrice}
giftMonthlyPrice={giftMonthlyPrice}
giftYearlyPrice={giftYearlyPrice}
loadingCheckout={loadingCheckout}
handleSelectPlan={handleSelectPlanGuarded}
purchaseDisabled={purchaseDisabled}
purchaseDisabledTooltip={purchaseDisabledTooltip}
data-flx="app.plutonium-content.pricing-section"
/>
</div>
) : (
giftPurchasesAvailable && (
<GiftSection
@@ -355,7 +362,7 @@ export const PlutoniumContent = observer(({defaultGiftMode = false}: PlutoniumCo
</div>
</section>
</div>
{!subscriptionStatus.isPremium && purchasesAvailable && (
{canSubscribe && (
<BottomCTASection
isGiftMode={isGiftMode && giftPurchasesAvailable}
monthlyPrice={monthlyPrice}
@@ -75,6 +75,8 @@ interface SubscriptionCardProps {
isVisionary: boolean;
perksDisabled: boolean;
isGiftSubscription: boolean;
isGiftGrace: boolean;
canSubscribe: boolean;
storeSubscription: PremiumStoreSubscriptionState | null;
premiumUntil: Date | null;
billingCycle: string | null;
@@ -104,6 +106,7 @@ interface SubscriptionCardProps {
loadingRejoinCommunity: boolean;
scrollToPerks: () => void;
navigateToRedeemGift: () => void;
handleStartSubscription: () => void;
handleOpenCustomerPortal: () => void;
handleReactivateSubscription: () => void;
handleCancelSubscription: () => void;
@@ -144,6 +147,8 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
isVisionary,
perksDisabled,
isGiftSubscription,
isGiftGrace,
canSubscribe,
storeSubscription,
premiumUntil,
billingCycle,
@@ -173,6 +178,7 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
loadingRejoinCommunity,
scrollToPerks,
navigateToRedeemGift,
handleStartSubscription,
handleOpenCustomerPortal,
handleReactivateSubscription,
handleCancelSubscription,
@@ -497,6 +503,22 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
</Trans>
);
})()
) : isGiftGrace ? (
(() => {
const graceDate = graceEndDate ? getFormattedLongDate(graceEndDate, locale) : undefined;
return (
<Trans comment="Plutonium subscription card text shown in the short grace period after gifted premium time ends. {graceDate} is a date already formatted and localized by code; never write a date into the translation.">
Your gift time ended but{' '}
<PerksButton
onClick={scrollToPerks}
data-flx="app.plutonium.subscription-card.perks-button.scroll-to-perks--11"
/>{' '}
stay active until{' '}
<strong data-flx="app.plutonium.subscription-card.strong--19">{graceDate}</strong>. Subscribe
before then to keep them.
</Trans>
);
})()
) : isInGracePeriod ? (
(() => {
const graceDate = graceEndDate ? getFormattedLongDate(graceEndDate, locale) : undefined;
@@ -773,55 +795,102 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
>
{i18n._(MANAGE_SUBSCRIPTION_DESCRIPTOR)}
</Button>
) : isGiftSubscription || billingUnavailable ? (
wrapIfDisabled(
<Button
variant="primary"
onClick={navigateToRedeemGift}
small
className={styles.actionButton}
disabled={purchaseDisabled}
data-flx="app.plutonium.subscription-card.action-button.navigate-to-redeem-gift"
>
<Trans comment="Billing button for entering a premium gift code.">Redeem gift code</Trans>
</Button>,
'redeem-gift',
purchaseDisabled,
)
) : (
) : isGiftSubscription || isGiftGrace || billingUnavailable ? (
<>
{hasEverPurchased &&
{canSubscribe &&
!billingUnavailable &&
wrapIfDisabled(
<Button
variant={isFullyExpired || isInGracePeriod || premiumWillCancel ? 'primary' : 'secondary'}
onClick={shouldUseReactivateQuickAction ? handleReactivateSubscription : handleOpenCustomerPortal}
submitting={shouldUseReactivateQuickAction ? loadingReactivate : loadingPortal}
variant="primary"
onClick={handleStartSubscription}
small
className={styles.actionButton}
disabled={purchaseDisabled && shouldUseReactivateQuickAction}
data-flx="app.plutonium.subscription-card.action-button.reactivate-subscription"
disabled={purchaseDisabled}
data-flx="app.plutonium.subscription-card.action-button.start-subscription"
>
{isFullyExpired ? (
<Trans comment="Billing button for starting a new subscription after premium expired.">
Resubscribe
</Trans>
) : isInGracePeriod ? (
<Trans comment="Billing button for restarting a subscription during the grace period.">
Resubscribe
</Trans>
) : premiumWillCancel ? (
<Trans comment="Billing button that cancels a scheduled subscription cancellation.">
Reactivate
</Trans>
) : isVisionary ? (
<Trans comment="Billing button that opens the external customer portal.">Customer portal</Trans>
) : (
i18n._(MANAGE_SUBSCRIPTION_DESCRIPTOR)
)}
<Trans comment="Billing button that starts a recurring subscription for someone on gifted premium time.">
Subscribe
</Trans>
</Button>,
'manage-reactivate',
purchaseDisabled && shouldUseReactivateQuickAction,
'start-subscription',
purchaseDisabled,
)}
{wrapIfDisabled(
<Button
variant={canSubscribe && !billingUnavailable ? 'secondary' : 'primary'}
onClick={navigateToRedeemGift}
small
className={styles.actionButton}
disabled={purchaseDisabled}
data-flx="app.plutonium.subscription-card.action-button.navigate-to-redeem-gift"
>
<Trans comment="Billing button for entering a premium gift code.">Redeem gift code</Trans>
</Button>,
'redeem-gift',
purchaseDisabled,
)}
</>
) : (
<>
{(isFullyExpired || isInGracePeriod) && canSubscribe
? wrapIfDisabled(
<Button
variant="primary"
onClick={handleStartSubscription}
small
className={styles.actionButton}
disabled={purchaseDisabled}
data-flx="app.plutonium.subscription-card.action-button.resubscribe"
>
{hasEverPurchased ? (
<Trans comment="Billing button for starting a new subscription after premium ended.">
Resubscribe
</Trans>
) : (
<Trans comment="Billing button that starts a recurring subscription for someone on gifted premium time.">
Subscribe
</Trans>
)}
</Button>,
'resubscribe',
purchaseDisabled,
)
: hasEverPurchased &&
wrapIfDisabled(
<Button
variant={isFullyExpired || isInGracePeriod || premiumWillCancel ? 'primary' : 'secondary'}
onClick={
shouldUseReactivateQuickAction ? handleReactivateSubscription : handleOpenCustomerPortal
}
submitting={shouldUseReactivateQuickAction ? loadingReactivate : loadingPortal}
small
className={styles.actionButton}
disabled={purchaseDisabled && shouldUseReactivateQuickAction}
data-flx="app.plutonium.subscription-card.action-button.reactivate-subscription"
>
{isFullyExpired ? (
<Trans comment="Billing button for starting a new subscription after premium expired.">
Resubscribe
</Trans>
) : isInGracePeriod ? (
<Trans comment="Billing button for restarting a subscription during the grace period.">
Resubscribe
</Trans>
) : premiumWillCancel ? (
<Trans comment="Billing button that cancels a scheduled subscription cancellation.">
Reactivate
</Trans>
) : isVisionary ? (
<Trans comment="Billing button that opens the external customer portal.">
Customer portal
</Trans>
) : (
i18n._(MANAGE_SUBSCRIPTION_DESCRIPTOR)
)}
</Button>,
'manage-reactivate',
purchaseDisabled && shouldUseReactivateQuickAction,
)}
{isVisionary && (
<Button
variant="secondary"
@@ -132,11 +132,19 @@ const CHECKOUT_START_FAILED_BODY_DESCRIPTOR = msg({
message: 'Something went wrong while starting checkout. Please try again in a moment.',
comment: 'Body of the generic fallback error modal shown when creating a checkout session fails unexpectedly.',
});
const GIFT_SUBSCRIPTION_BLOCKS_RECURRING_TOAST_DESCRIPTOR = msg({
message:
"You're currently on a gift subscription. It won't renew. You can redeem more gift codes to extend it. Recurring subscriptions can be started after your gift time ends.",
const GIFT_TIME_KEPT_TITLE_DESCRIPTOR = msg({
message: 'Your gift time is kept',
comment:
'Error modal body shown when a gift-subscription user tries to start a recurring subscription. Explains the extension/redemption path.',
'Title of the confirmation shown before checkout when a user with gifted premium time starts a recurring subscription.',
});
const GIFT_TIME_KEPT_BODY_DESCRIPTOR = msg({
message: "You'll be charged now. Your remaining gift time is added after your paid period, so none of it is lost.",
comment:
'Body of the confirmation shown before checkout when a user with gifted premium time starts a recurring subscription. The subscription is charged immediately and the unused gift time is appended after the paid period.',
});
const CONTINUE_TO_CHECKOUT_DESCRIPTOR = msg({
message: 'Continue to checkout',
comment: 'Button in the gift time confirmation that proceeds to subscription checkout.',
});
const PRICING_NOT_LOADED_TOAST_DESCRIPTOR = msg({
message: 'Pricing is still loading.',
@@ -208,8 +216,8 @@ function alternativePaymentMethodForCurrency(
export const useCheckoutActions = (
priceIds: PriceIds | null,
countryCode: string | null,
isGiftSubscription: boolean,
mobileEnabled: boolean,
{hasGiftTime = false}: {hasGiftTime?: boolean} = {},
) => {
const {i18n} = useLingui();
const [loadingCheckout, setLoadingCheckout] = useState(false);
@@ -398,7 +406,7 @@ export const useCheckoutActions = (
const handleSelectPlan = useCallback(
async (plan: Plan) => {
if (loadingCheckout) return;
logger.info('Plan selected', {plan, isGiftSubscription});
logger.info('Plan selected', {plan, hasGiftTime});
const showCheckoutPlanErrorModal = (message: string, flxKey: string) => {
ModalCommands.push(
modal(() => (
@@ -410,13 +418,6 @@ export const useCheckoutActions = (
)),
);
};
if (isGiftSubscription && (plan === 'monthly' || plan === 'yearly')) {
showCheckoutPlanErrorModal(
i18n._(GIFT_SUBSCRIPTION_BLOCKS_RECURRING_TOAST_DESCRIPTOR),
'app.plutonium.use-checkout-actions.gift-subscription-blocked.generic-error-modal',
);
return;
}
if (!priceIds) {
logger.error('Price IDs not loaded yet');
showCheckoutPlanErrorModal(
@@ -498,34 +499,55 @@ export const useCheckoutActions = (
setLoadingCheckout(false);
}
};
const altPaymentMethod = alternativePaymentMethodForCurrency(priceIds.currency, isGift, plan);
if (altPaymentMethod && MANDATORY_LOCAL_PAYMENT_CURRENCIES.has(priceIds.currency ?? '')) {
await startCheckout({paymentMethod: altPaymentMethod});
return;
}
if (altPaymentMethod) {
const altPrompt = getAlternativePaymentMethodPrompt(priceIds.currency, altPaymentMethod);
const proceedToCheckout = async ({afterConfirm = false}: {afterConfirm?: boolean} = {}) => {
const altPaymentMethod = alternativePaymentMethodForCurrency(priceIds.currency, isGift, plan);
if (altPaymentMethod && MANDATORY_LOCAL_PAYMENT_CURRENCIES.has(priceIds.currency ?? '')) {
await startCheckout({skipMobilePrompt: afterConfirm, paymentMethod: altPaymentMethod});
return;
}
if (altPaymentMethod) {
const altPrompt = getAlternativePaymentMethodPrompt(priceIds.currency, altPaymentMethod);
ModalCommands.push(
modal(() => (
<ConfirmModal
title={i18n._(CHOOSE_PAYMENT_METHOD_MODAL_TITLE_DESCRIPTOR)}
description={altPrompt.description}
primaryText={altPrompt.primaryText}
primaryVariant="primary"
secondaryText={i18n._(USE_CARD_BUTTON_DESCRIPTOR)}
onPrimary={() => {
void startCheckout({skipMobilePrompt: true, paymentMethod: altPaymentMethod});
}}
onSecondary={() => {
void startCheckout({skipMobilePrompt: true});
}}
data-flx="app.plutonium.use-checkout-actions.handle-select-plan.confirm-modal--2"
/>
)),
);
return;
}
await startCheckout({skipMobilePrompt: afterConfirm});
};
if (hasGiftTime && !isGift) {
ModalCommands.push(
modal(() => (
<ConfirmModal
title={i18n._(CHOOSE_PAYMENT_METHOD_MODAL_TITLE_DESCRIPTOR)}
description={altPrompt.description}
primaryText={altPrompt.primaryText}
title={i18n._(GIFT_TIME_KEPT_TITLE_DESCRIPTOR)}
description={i18n._(GIFT_TIME_KEPT_BODY_DESCRIPTOR)}
primaryText={i18n._(CONTINUE_TO_CHECKOUT_DESCRIPTOR)}
primaryVariant="primary"
secondaryText={i18n._(USE_CARD_BUTTON_DESCRIPTOR)}
secondaryText={i18n._(CANCEL_DESCRIPTOR)}
onPrimary={() => {
void startCheckout({skipMobilePrompt: true, paymentMethod: altPaymentMethod});
void proceedToCheckout({afterConfirm: true});
}}
onSecondary={() => {
void startCheckout({skipMobilePrompt: true});
}}
data-flx="app.plutonium.use-checkout-actions.handle-select-plan.confirm-modal--2"
data-flx="app.plutonium.use-checkout-actions.handle-select-plan.gift-time-confirm-modal"
/>
)),
);
return;
}
await startCheckout();
await proceedToCheckout();
},
[
handleCheckoutError,
@@ -533,7 +555,7 @@ export const useCheckoutActions = (
priceIds,
countryCode,
getAlternativePaymentMethodPrompt,
isGiftSubscription,
hasGiftTime,
mobileEnabled,
i18n,
],
@@ -2,6 +2,7 @@
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
import {getPremiumGraceEndDate} from '@app/features/premium/utils/PremiumGrace';
import {classifyPremiumSubscription} from '@app/features/premium/utils/PremiumSubscriptionClassification';
import {getStoreOwnedSubscription} from '@app/features/premium/utils/PremiumUtils';
import type {User} from '@app/features/user/models/User';
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
@@ -27,6 +28,8 @@ export interface SubscriptionStatusInfo {
billingCycle: string | null;
actualPremiumUntil: Date | null;
isGiftSubscription: boolean;
isGiftGrace: boolean;
canStartSubscription: boolean;
storeSubscription: PremiumStoreSubscriptionState | null;
gracePeriodInfo: GracePeriodInfo;
shouldShowPremiumCard: boolean;
@@ -91,7 +94,6 @@ export const useSubscriptionStatus = (
: storeSubscription
? storeSubscription.billing_cycle
: (actual?.premium_billing_cycle ?? currentUser?.premiumBillingCycle ?? null);
const isGiftSubscription = Boolean(!billingCycle && hasPaidPremium && !isVisionary && premiumUntil);
const isStoreGrace = storeSubscription?.state === 'grace' && premiumGraceEndsAt != null;
const gracePeriodInfo = useMemo((): GracePeriodInfo => {
if (isVisionary) {
@@ -125,6 +127,18 @@ export const useSubscriptionStatus = (
return {isInGracePeriod, isExpired, graceEndDate, showExpiredState, isPaymentRecovery};
}, [premiumUntil, premiumGraceEndsAt, isVisionary, billingCycle, isStoreGrace]);
const {isInGracePeriod, isExpired: isFullyExpired, showExpiredState} = gracePeriodInfo;
const {isGiftSubscription, isGiftGrace, canStartSubscription} = classifyPremiumSubscription({
hasPaidPremium,
isVisionary,
hasStoreSubscription: storeSubscription != null,
billingCycle,
premiumUntil,
isInGracePeriod,
isFullyExpired,
billingKnown: !useScenarioOverride && premiumState != null,
storeOwnershipKnown: useScenarioOverride || premiumState != null,
billingSubscription: useScenarioOverride ? null : (premiumState?.billing.subscription ?? null),
});
const isPremium = useDeveloperOverride
? hasPaidPremium && !isFullyExpired && !perksDisabled
: (effective?.is_premium ?? currentUser?.isPremium() ?? false);
@@ -163,6 +177,8 @@ export const useSubscriptionStatus = (
billingCycle,
actualPremiumUntil: premiumUntil,
isGiftSubscription,
isGiftGrace,
canStartSubscription,
storeSubscription,
gracePeriodInfo,
shouldShowPremiumCard,