Skip to content

Commit fb7e902

Browse files
feat(payments-next): Cancellation flow: Create page - Interstitial Offer
Because: * We need a cancel interstitial offer page as part of the churn intervention epic. This commit: * Creates /[locale]/subscriptions/[subscription_id]/offer page Closes #PAY-3371
1 parent d73eeae commit fb7e902

File tree

16 files changed

+415
-37
lines changed

16 files changed

+415
-37
lines changed
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import { headers } from 'next/headers';
6+
import { redirect } from 'next/navigation';
7+
8+
import { InterstitialOffer } from '@fxa/payments/ui';
9+
import { getInterstitialOfferContentAction } from '@fxa/payments/ui/actions';
10+
import { auth } from 'apps/payments/next/auth';
11+
import { config } from 'apps/payments/next/config';
12+
13+
export default async function InterstitialOfferPage({
14+
params,
15+
searchParams,
16+
}: {
17+
params: {
18+
locale: string;
19+
subscriptionId: string;
20+
};
21+
searchParams: Record<string, string> | undefined;
22+
}) {
23+
const { locale, subscriptionId } = params;
24+
const acceptLanguage = headers().get('accept-language');
25+
const session = await auth();
26+
if (!session?.user?.id) {
27+
const redirectToUrl = new URL(
28+
`${config.paymentsNextHostedUrl}/${locale}/subscriptions/landing`
29+
);
30+
redirectToUrl.search = new URLSearchParams(searchParams).toString();
31+
redirect(redirectToUrl.href);
32+
}
33+
34+
const uid = session.user.id;
35+
36+
let interstitialOfferContent;
37+
try {
38+
interstitialOfferContent = await getInterstitialOfferContentAction(
39+
uid,
40+
subscriptionId,
41+
acceptLanguage,
42+
locale
43+
);
44+
} catch (error) {
45+
redirect(`/${locale}/subscriptions/${subscriptionId}/cancel`);
46+
}
47+
48+
if (!interstitialOfferContent.isEligible || !interstitialOfferContent.pageContent) {
49+
redirect(`/${locale}/subscriptions/${subscriptionId}/cancel`);
50+
}
51+
52+
/*const pageContent2 = {
53+
currentInterval: SubplatInterval.Monthly,
54+
advertisedSavings: 50,
55+
ctaMessage: 'Save 50% by switching to a yearly plan',
56+
modalHeading1: 'Hey, before you go...',
57+
modalMessage: ['Protecting your real email is smart. Saving 50% while you do it is even smarter. Choose Relays annual plan and get both.'],
58+
upgradeButtonLabel: 'Switch to yearly',
59+
upgradeButtonUrl: 'http://localhost:3035/en/vpn/yearly/landing',
60+
webIcon: "https://cdn.accounts.firefox.com/product-icons/premium-relay.svg",
61+
productName: "Mozilla VPN",
62+
}*/
63+
64+
return (
65+
<InterstitialOffer
66+
subscriptionId={subscriptionId}
67+
locale={locale}
68+
//pageContent={pageContent2}
69+
pageContent={interstitialOfferContent.pageContent}
70+
/>
71+
)
72+
}

libs/payments/management/src/lib/churn-intervention.service.ts

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,17 @@ import {
2323
ChurnSubscriptionCustomerMismatchError,
2424
} from './churn-intervention.error';
2525

26+
const cancelOfferCurrentIntervalToSubplatIntervalMap: Record< //where to put this? what to do with this?
27+
string,
28+
SubplatInterval
29+
> = {
30+
['daily']: SubplatInterval.Daily,
31+
['weekly']: SubplatInterval.Weekly,
32+
['monthly']: SubplatInterval.Monthly,
33+
['halfyearly']: SubplatInterval.HalfYearly,
34+
['yearly']: SubplatInterval.Yearly,
35+
};
36+
2637
@Injectable()
2738
export class ChurnInterventionService {
2839
constructor(
@@ -557,7 +568,82 @@ export class ChurnInterventionService {
557568
isEligible: true,
558569
reason: 'eligible',
559570
cmsCancelInterstitialOfferResult,
560-
};
571+
};/*
572+
return {
573+
isEligible: true,
574+
reason: 'eligible',
575+
cmsCancelInterstitialOfferResult: {
576+
offeringApiIdentifier: 'vpn',
577+
currentInterval: 'monthly',
578+
upgradeInterval: 'yearly',
579+
advertisedSavings: 50,
580+
ctaMessage: 'Save 50% by switching to a yearly plan',
581+
modalHeading1: 'Hey, before you go...',
582+
modalHeading2: 'abc 123',
583+
modalMessage: ['Protecting your real email is smart.', 'Saving 50% while you do it is even smarter.', 'Choose Relays annual plan and get both.'],
584+
productPageUrl: 'https://example.com/product',
585+
upgradeButtonLabel: 'Switch to yearly',
586+
upgradeButtonUrl: 'http://localhost:3035/en/vpn/yearly/landing',
587+
localizations: [],
588+
offering: {
589+
stripeProductId: 'prod_mock_123',
590+
defaultPurchase: {
591+
purchaseDetails: { webIcon: 'https://example.com/icon.png', localizations: [] },
592+
},
593+
},
594+
},
595+
};*/
596+
}
597+
598+
async getInterstitialOfferContent (args: { // TODO: Add tests for this if keeping
599+
uid: string;
600+
subscriptionId: string;
601+
acceptLanguage?: string | null;
602+
selectedLanguage?: string;
603+
}) {
604+
const cancelInterstitialOfferEligiblityResult =
605+
await this.determineCancelInterstitialOfferEligibility(args);
606+
if (cancelInterstitialOfferEligiblityResult.isEligible &&
607+
cancelInterstitialOfferEligiblityResult.cmsCancelInterstitialOfferResult)
608+
{
609+
const subscription = await this.subscriptionManager.retrieve(args.subscriptionId);
610+
const stripePriceId = subscription.items.data.at(0)?.price.id;
611+
if (!stripePriceId) {
612+
return {
613+
isEligible: false,
614+
pageContent: null
615+
}
616+
}
617+
const result = await this.productConfigurationManager.getPageContentByPriceIds([
618+
stripePriceId,
619+
]);
620+
const cmsPurchase = result.purchaseForPriceId(stripePriceId);
621+
const productName =
622+
cmsPurchase.purchaseDetails.localizations[0]?.productName ||
623+
cmsPurchase.purchaseDetails.productName;
624+
const webIcon = cmsPurchase.purchaseDetails.webIcon;
625+
const currentSubPlatInterval = cancelOfferCurrentIntervalToSubplatIntervalMap[cancelInterstitialOfferEligiblityResult.cmsCancelInterstitialOfferResult?.currentInterval];
626+
627+
return {
628+
isEligible: true,
629+
pageContent: {
630+
currentInterval: currentSubPlatInterval,
631+
advertisedSavings: cancelInterstitialOfferEligiblityResult.cmsCancelInterstitialOfferResult?.advertisedSavings,
632+
ctaMessage: cancelInterstitialOfferEligiblityResult.cmsCancelInterstitialOfferResult?.ctaMessage,
633+
modalHeading1: cancelInterstitialOfferEligiblityResult.cmsCancelInterstitialOfferResult?.modalHeading1,
634+
modalMessage: cancelInterstitialOfferEligiblityResult.cmsCancelInterstitialOfferResult?.modalMessage,
635+
upgradeButtonLabel: cancelInterstitialOfferEligiblityResult.cmsCancelInterstitialOfferResult?.upgradeButtonLabel,
636+
upgradeButtonUrl: cancelInterstitialOfferEligiblityResult.cmsCancelInterstitialOfferResult?.upgradeButtonUrl,
637+
webIcon,
638+
productName,
639+
}
640+
};
641+
} else {
642+
return {
643+
isEligible: false,
644+
pageContent: null
645+
}
646+
}
561647
}
562648

563649
async determineCancelChurnContentEligibility(args: {

libs/payments/ui/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export * from './lib/client/components/CheckoutCheckbox';
1515
export * from './lib/client/components/ChurnStaySubscribed';
1616
export * from './lib/client/components/CouponForm';
1717
export * from './lib/client/components/Header';
18+
export * from './lib/client/components/InterstitialOffer';
1819
export * from './lib/client/components/LoadingSpinner';
1920
export * from './lib/client/components/PageNotFound';
2021
export * from './lib/client/components/PaymentStateObserver';
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
'use server';
6+
7+
import { getApp } from '../nestapp/app';
8+
9+
export const getInterstitialOfferContentAction = async (
10+
uid: string,
11+
subscriptionId: string,
12+
acceptLanguage?: string | null,
13+
selectedLanguage?: string
14+
) => {
15+
const result = await getApp().getActionsService().getInterstitialOfferContent({
16+
uid,
17+
subscriptionId,
18+
acceptLanguage,
19+
selectedLanguage,
20+
});
21+
22+
return result;
23+
};

libs/payments/ui/src/lib/actions/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export { determineCancellationInterventionAction } from './determineCancellation
1313
export { fetchCMSData } from './fetchCMSData';
1414
export { getCartAction } from './getCart';
1515
export { getCartOrRedirectAction } from './getCartOrRedirect';
16+
export { getInterstitialOfferContentAction } from './getInterstitialOfferContent';
1617
export { getMetricsFlowAction } from './getMetricsFlow';
1718
export { getPayPalCheckoutToken } from './getPayPalCheckoutToken';
1819
export { getPayPalBillingAgreementId } from './getPayPalBillingAgreementId';
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
## InterstitialOffer
2+
interstitial-offer-button-keep-halfyearly-subscription = Keep six-month subscription
3+
.aria-label = Keep six-month subscription
4+
interstitial-offer-button-keep-current-interval-subscription = Keep { $currentInterval } subscription
5+
.aria-label = Keep { $currentInterval } subscription
6+
interstitial-offer-button-cancel-subscription = Cancel subscription
7+
.aria-label = Cancel your subscription to { $productName }
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
'use client';
6+
7+
import { SubplatInterval } from '@fxa/payments/customer';
8+
import Image from 'next/image';
9+
import Link from 'next/link';
10+
import { Localized } from '@fluent/react';
11+
12+
export interface InterstitialOfferProps {
13+
subscriptionId: string;
14+
locale: string;
15+
pageContent: {
16+
currentInterval: SubplatInterval;
17+
advertisedSavings: number;
18+
ctaMessage: string;
19+
modalHeading1: string;
20+
modalMessage: string[];
21+
upgradeButtonLabel: string;
22+
upgradeButtonUrl: string;
23+
webIcon: string;
24+
productName: string;
25+
};
26+
}
27+
28+
export function InterstitialOffer({
29+
subscriptionId,
30+
locale,
31+
pageContent,
32+
}: InterstitialOfferProps) {
33+
const {
34+
currentInterval,
35+
advertisedSavings,
36+
modalHeading1,
37+
modalMessage,
38+
upgradeButtonLabel,
39+
upgradeButtonUrl,
40+
webIcon,
41+
productName,
42+
} = pageContent;
43+
44+
return (
45+
<section
46+
className="flex justify-center min-h-[calc(100vh_-_4rem)] tablet:items-center tablet:min-h-[calc(100vh_-_5rem)]"
47+
>
48+
<div className="w-full max-w-[480px] flex flex-col justify-center items-center p-10 tablet:bg-white tablet:rounded-xl tablet:border tablet:border-grey-200 tablet:shadow-[0_0_16px_0_rgba(0,0,0,0.08)]">
49+
<div className="w-full flex flex-col items-center gap-6 text-center">
50+
<Image src={webIcon} alt={productName} height={64} width={64} />
51+
<h1 className="font-bold self-stretch text-center font-header text-xl leading-8 ">
52+
{modalHeading1}
53+
</h1>
54+
</div>
55+
<p className="w-full self-stretch leading-7 text-lg text-grey-900">
56+
{Array.isArray(modalMessage) ? modalMessage.join(' ') : modalMessage}
57+
</p>
58+
59+
<div className="w-full flex flex-col gap-3 mt-10 items-center self-stretch">
60+
<Link
61+
className="border box-border font-header h-14 items-center justify-center rounded-md text-white text-center py-2 px-5 bg-blue-500 hover:bg-blue-700 flex w-full"
62+
href={upgradeButtonUrl}
63+
aria-label={`Switch ${productName} to annual and save ${advertisedSavings}`}
64+
>
65+
{upgradeButtonLabel}
66+
</Link>
67+
<Link
68+
className="border box-border font-header h-14 items-center justify-center rounded-md text-center py-2 px-5 bg-grey-10 border-grey-200 hover:bg-grey-50 flex w-full"
69+
href={`/${locale}/subscriptions/landing`}
70+
aria-label={`Stay subscribed to ${productName}`}
71+
>
72+
{currentInterval === 'halfyearly' ? (
73+
<Localized
74+
id="interstitial-offer-button-keep-halfyearly-subscription"
75+
attrs={{ 'aria-label': true }}
76+
>
77+
<span>Keep six-month subscription</span>
78+
</Localized>
79+
) : (
80+
<Localized
81+
id="interstitial-offer-button-keep-current-interval-subscription"
82+
vars={{ currentInterval }}
83+
attrs={{ 'aria-label': true }}
84+
>
85+
<span>Keep {currentInterval} subscription</span>
86+
</Localized>
87+
)}
88+
</Link>
89+
<Link
90+
className="border box-border font-header h-14 items-center justify-center rounded-md text-center py-2 px-5 bg-grey-10 border-grey-200 hover:bg-grey-50 flex w-full"
91+
href={`/${locale}/subscriptions/${subscriptionId}/cancel`}
92+
aria-label={`Cancel your subscription to ${productName}`}
93+
>
94+
<Localized
95+
id="interstitial-offer-button-cancel-subscription"
96+
vars={{ productName }}
97+
attrs={{ 'aria-label': true }}
98+
>
99+
<span>Cancel subscription</span>
100+
</Localized>
101+
</Link>
102+
</div>
103+
</div>
104+
</section>
105+
);
106+
}

libs/payments/ui/src/lib/nestapp/nextjs-actions.service.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ import { GetChurnInterventionDataActionArgs } from './validators/GetChurnInterve
4646
import { GetPayPalCheckoutTokenArgs } from './validators/GetPayPalCheckoutTokenArgs';
4747
import { GetSubManPageContentActionArgs } from './validators/GetSubManPageContentActionArgs';
4848
import { GetSubManPageContentActionResult } from './validators/GetSubManPageContentActionResult';
49+
import { GetInterstitialOfferContentActionArgs } from './validators/GetInterstitialOfferContentActionArgs';//
50+
import { GetInterstitialOfferContentActionResult } from './validators/GetInterstitialOfferContentActionResult';
4951
import { RestartCartActionArgs } from './validators/RestartCartActionArgs';
5052
import { SetupCartActionArgs } from './validators/SetupCartActionArgs';
5153
import { UpdateCartActionArgs } from './validators/UpdateCartActionArgs';
@@ -692,6 +694,30 @@ export class NextJSActionsService {
692694
);
693695
}
694696

697+
@SanitizeExceptions()
698+
@NextIOValidator(
699+
GetInterstitialOfferContentActionArgs,
700+
GetInterstitialOfferContentActionResult
701+
)
702+
@WithTypeCachableAsyncLocalStorage()
703+
@CaptureTimingWithStatsD()
704+
async getInterstitialOfferContent(args: {
705+
uid: string;
706+
subscriptionId: string;
707+
acceptLanguage?: string | null;
708+
selectedLanguage?: string;
709+
}) {
710+
const result =
711+
await this.churnInterventionService.getInterstitialOfferContent({
712+
uid: args.uid,
713+
subscriptionId: args.subscriptionId,
714+
acceptLanguage: args.acceptLanguage,
715+
selectedLanguage: args.selectedLanguage
716+
});
717+
718+
return result;
719+
}
720+
695721
@SanitizeExceptions()
696722
@NextIOValidator(DetermineCurrencyActionArgs, DetermineCurrencyActionResult)
697723
@WithTypeCachableAsyncLocalStorage()

0 commit comments

Comments
 (0)