Skip to content

Commit b54e666

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 95b45d6 commit b54e666

File tree

16 files changed

+383
-22
lines changed

16 files changed

+383
-22
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
## InterstitialOffer
2+
interstitial-offer-button-cancel-subscription = Cancel subscription
3+
## $productName (String) - Name of the product (e.g. Mozilla VPN)
4+
interstitial-offer-button-cancel-subscription-aria = Cancel your subscription to { $productName }
5+
6+
## Daily/Weekly/Monthly refers to the user's current subscription interval
7+
interstitial-offer-button-keep-current-interval-daily = Keep daily subscription
8+
interstitial-offer-button-keep-current-interval-weekly = Keep weekly subscription
9+
interstitial-offer-button-keep-current-interval-monthly = Keep monthly subscription
10+
interstitial-offer-button-keep-current-interval-halfyearly = Keep six-month subscription
11+
12+
interstitial-offer-button-keep-current-interval-daily-aria = Keep daily subscription
13+
interstitial-offer-button-keep-current-interval-weekly-aria = Keep weekly subscription
14+
interstitial-offer-button-keep-current-interval-monthly-aria = Keep monthly subscription
15+
interstitial-offer-button-keep-current-interval-halfyearly-aria = Keep six-month subscription
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
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+
import { getApp } from '@fxa/payments/ui/server';
8+
import { getInterstitialOfferContentAction } from '@fxa/payments/ui/actions';
9+
import { auth } from 'apps/payments/next/auth';
10+
import { config } from 'apps/payments/next/config';
11+
import Image from 'next/image';
12+
import Link from 'next/link';
13+
14+
export default async function InterstitialOfferPage({
15+
params,
16+
searchParams,
17+
}: {
18+
params: {
19+
locale: string;
20+
subscriptionId: string;
21+
};
22+
searchParams: Record<string, string> | undefined;
23+
}) {
24+
const { locale, subscriptionId } = params;
25+
const acceptLanguage = headers().get('accept-language');
26+
const l10n = getApp().getL10n(acceptLanguage, locale);
27+
28+
const session = await auth();
29+
if (!session?.user?.id) {
30+
const redirectToUrl = new URL(
31+
`${config.paymentsNextHostedUrl}/${locale}/subscriptions/landing`
32+
);
33+
redirectToUrl.search = new URLSearchParams(searchParams).toString();
34+
redirect(redirectToUrl.href);
35+
}
36+
37+
const uid = session.user.id;
38+
39+
let interstitialOfferContent;
40+
try {
41+
interstitialOfferContent = await getInterstitialOfferContentAction(
42+
uid,
43+
subscriptionId,
44+
acceptLanguage,
45+
locale
46+
);
47+
} catch (error) {
48+
redirect(`/${locale}/subscriptions/${subscriptionId}/cancel`);
49+
}
50+
51+
if (!interstitialOfferContent.isEligible || !interstitialOfferContent.pageContent) {
52+
redirect(`/${locale}/subscriptions/${subscriptionId}/cancel`);
53+
}
54+
55+
const {
56+
currentInterval,
57+
advertisedSavings,
58+
modalHeading1,
59+
modalMessage,
60+
upgradeButtonLabel,
61+
upgradeButtonUrl,
62+
webIcon,
63+
productName,
64+
} = interstitialOfferContent.pageContent;
65+
66+
const getKeepCurrentSubscriptionFtlId = (interval: string) => {
67+
switch (interval) {
68+
case 'daily':
69+
return 'interstitial-offer-button-keep-current-interval-daily';
70+
case 'weekly':
71+
return 'interstitial-offer-button-keep-current-interval-weekly';
72+
case 'monthly':
73+
default:
74+
return 'interstitial-offer-button-keep-current-interval-monthly';
75+
}
76+
};
77+
78+
const getKeepCurrentSubscriptionAriaFtlId = (interval: string) => {
79+
switch (interval) {
80+
case 'daily':
81+
return 'interstitial-offer-button-keep-current-interval-daily-aria';
82+
case 'weekly':
83+
return 'interstitial-offer-button-keep-current-interval-weekly-aria';
84+
case 'monthly':
85+
default:
86+
return 'interstitial-offer-button-keep-current-interval-monthly-aria';
87+
}
88+
};
89+
90+
const keepCurrentSubscriptionButtonText =
91+
currentInterval === 'halfyearly'
92+
? l10n.getString(
93+
'interstitial-offer-button-keep-current-interval-halfyearly',
94+
'Keep six-month subscription'
95+
)
96+
: l10n.getString(
97+
getKeepCurrentSubscriptionFtlId(currentInterval),
98+
`Keep ${currentInterval} subscription`
99+
);
100+
const keepCurrentSubscriptionButtonAria =
101+
currentInterval === 'halfyearly'
102+
? l10n.getString(
103+
'interstitial-offer-button-keep-current-interval-halfyearly-aria',
104+
'Keep six-month subscription'
105+
)
106+
: l10n.getString(
107+
getKeepCurrentSubscriptionAriaFtlId(currentInterval),
108+
`Keep ${currentInterval} subscription`
109+
);
110+
111+
return (
112+
<section
113+
className="flex justify-center min-h-[calc(100vh_-_4rem)] tablet:items-center tablet:min-h-[calc(100vh_-_5rem)]"
114+
>
115+
<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)]">
116+
<div className="w-full flex flex-col items-center gap-6 text-center">
117+
<Image src={webIcon} alt={productName} height={64} width={64} />
118+
<h1 className="font-bold self-stretch text-center font-header text-xl leading-8 ">
119+
{modalHeading1}
120+
</h1>
121+
</div>
122+
<p className="w-full self-stretch leading-7 text-lg text-grey-900">
123+
{Array.isArray(modalMessage) ? modalMessage.join(' ') : modalMessage}
124+
</p>
125+
126+
<div className="w-full flex flex-col gap-3 mt-10 items-center self-stretch">
127+
<Link
128+
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"
129+
href={upgradeButtonUrl}
130+
aria-label={`Switch ${productName} to annual and save ${advertisedSavings}`}
131+
>
132+
{upgradeButtonLabel}
133+
</Link>
134+
<Link
135+
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"
136+
href={`/${locale}/subscriptions/landing`}
137+
aria-label={keepCurrentSubscriptionButtonAria}
138+
>
139+
<span>{keepCurrentSubscriptionButtonText}</span>
140+
</Link>
141+
<Link
142+
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"
143+
href={`/${locale}/subscriptions/${subscriptionId}/cancel`}
144+
aria-label={l10n.getString(
145+
'interstitial-offer-button-cancel-subscription-aria',
146+
{ productName },
147+
`Cancel your subscription to ${productName}`
148+
)}
149+
>
150+
<span>{l10n.getString(
151+
'interstitial-offer-button-cancel-subscription',
152+
'Cancel subscription'
153+
)}</span>
154+
</Link>
155+
</div>
156+
</div>
157+
</section>
158+
);
159+
}

libs/accounts/email-renderer/src/templates/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export * as fraudulentAccountDeletion from './fraudulentAccountDeletion';
4848
export * as inactiveAccountFirstWarning from './inactiveAccountFirstWarning';
4949
export * as inactiveAccountSecondWarning from './inactiveAccountSecondWarning';
5050
export * as inactiveAccountFinalWarning from './inactiveAccountFinalWarning';
51-
export * as subscriptionAccountFinishSetup from './subscriptionAccountFinishSetup';
51+
//export * as subscriptionAccountFinishSetup from './subscriptionAccountFinishSetup';
5252
export * as subscriptionAccountReminderFirst from './subscriptionAccountReminderFirst';
5353
export * as subscriptionAccountReminderSecond from './subscriptionAccountReminderSecond';
5454
export * as subscriptionReactivation from './subscriptionReactivation';

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

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -421,7 +421,7 @@ export class ChurnInterventionService {
421421
subscriptionId: string;
422422
acceptLanguage?: string | null;
423423
selectedLanguage?: string;
424-
}) {
424+
}) {///*
425425
const upgradeInterval = SubplatInterval.Yearly;
426426
const subscription = await this.subscriptionManager.retrieve(
427427
args.subscriptionId
@@ -558,6 +558,35 @@ export class ChurnInterventionService {
558558
reason: 'eligible',
559559
cmsCancelInterstitialOfferResult,
560560
};
561+
//*/
562+
/*
563+
return {
564+
isEligible: true,
565+
reason: 'eligible',
566+
cmsCancelInterstitialOfferResult: {
567+
offeringApiIdentifier: 'vpn',
568+
currentInterval: 'monthly',
569+
upgradeInterval: 'yearly',
570+
advertisedSavings: 50,
571+
ctaMessage: 'Save 50% by switching to a yearly plan',
572+
modalHeading1: 'Hey, before you go...',
573+
modalHeading2: 'abc 123',
574+
modalMessage: ['Protecting your real email is smart.', 'Saving 50% while you do it is even smarter.', 'Choose Relays annual plan and get both.'],
575+
productPageUrl: 'https://example.com/product',
576+
upgradeButtonLabel: 'Switch to yearly',
577+
upgradeButtonUrl: 'http://localhost:3035/en/vpn/yearly/landing',
578+
localizations: [],
579+
offering: {
580+
stripeProductId: 'prod_mock_123',
581+
defaultPurchase: {
582+
purchaseDetails: {
583+
webIcon: 'https://cdn.accounts.firefox.com/product-icons/premium-relay.svg',
584+
productName: 'Relay'
585+
},
586+
},
587+
},
588+
},
589+
};//*/
561590
}
562591

563592
async determineCancelChurnContentEligibility(args: {
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';

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

Lines changed: 49 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,53 @@ 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.determineCancelInterstitialOfferEligibility({
712+
uid: args.uid,
713+
subscriptionId: args.subscriptionId,
714+
acceptLanguage: args.acceptLanguage,
715+
selectedLanguage: args.selectedLanguage
716+
});
717+
718+
if (result.isEligible &&
719+
result.cmsCancelInterstitialOfferResult &&
720+
result.cmsCancelInterstitialOfferResult.offering.defaultPurchase.purchaseDetails
721+
) {
722+
return {
723+
isEligible: true,
724+
pageContent: {
725+
currentInterval: result.cmsCancelInterstitialOfferResult.currentInterval,
726+
advertisedSavings: result.cmsCancelInterstitialOfferResult.advertisedSavings,
727+
ctaMessage: result.cmsCancelInterstitialOfferResult.ctaMessage,
728+
modalHeading1: result.cmsCancelInterstitialOfferResult.modalHeading1,
729+
modalMessage: result.cmsCancelInterstitialOfferResult.modalMessage,
730+
upgradeButtonLabel: result.cmsCancelInterstitialOfferResult.upgradeButtonLabel,
731+
upgradeButtonUrl: result.cmsCancelInterstitialOfferResult.upgradeButtonUrl,
732+
webIcon: result.cmsCancelInterstitialOfferResult.offering.defaultPurchase.purchaseDetails.webIcon,
733+
productName: result.cmsCancelInterstitialOfferResult.offering.defaultPurchase.purchaseDetails.productName,
734+
}
735+
}
736+
} else {
737+
return {
738+
isEligible: false,
739+
pageContent: null
740+
}
741+
}
742+
}
743+
695744
@SanitizeExceptions()
696745
@NextIOValidator(DetermineCurrencyActionArgs, DetermineCurrencyActionResult)
697746
@WithTypeCachableAsyncLocalStorage()

libs/payments/ui/src/lib/nestapp/validators/DetermineCancellationInterventionActionResult.ts

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,12 @@
44

55
import {
66
IsArray,
7-
IsEnum,
87
IsNumber,
98
IsOptional,
109
IsString,
1110
ValidateNested,
1211
} from 'class-validator';
1312
import { Type } from 'class-transformer';
14-
import {
15-
Enum_Cancelinterstitialoffer_Currentinterval,
16-
Enum_Cancelinterstitialoffer_Upgradeinterval,
17-
} from '../../../../../../shared/cms/src/__generated__/graphql';
1813

1914
export class CmsChurnInterventionEntryResult {
2015
@IsString()
@@ -66,12 +61,18 @@ export class CmsChurnInterventionEntryResult {
6661
export class CmsPurchaseDetailsLocalizationObject {
6762
@IsString()
6863
webIcon!: string;
64+
65+
@IsString()
66+
productName!: string;
6967
}
7068

7169
export class CmsPurchaseDetailsDataObject {
7270
@IsString()
7371
webIcon!: string;
7472

73+
@IsString()
74+
productName!: string;
75+
7576
@IsArray()
7677
@ValidateNested({ each: true })
7778
@Type(() => CmsPurchaseDetailsLocalizationObject)
@@ -98,13 +99,13 @@ export class CmsCancelInterstitialOfferPartialResult {
9899
@IsOptional()
99100
offeringApiIdentifier?: string;
100101

101-
@IsEnum(Enum_Cancelinterstitialoffer_Currentinterval)
102+
@IsString()
102103
@IsOptional()
103-
currentInterval?: Enum_Cancelinterstitialoffer_Currentinterval;
104+
currentInterval?: string;
104105

105-
@IsEnum(Enum_Cancelinterstitialoffer_Upgradeinterval)
106+
@IsString()
106107
@IsOptional()
107-
upgradeInterval?: Enum_Cancelinterstitialoffer_Upgradeinterval;
108+
upgradeInterval?: string;
108109

109110
@IsNumber()
110111
@IsOptional()
@@ -148,11 +149,11 @@ export class CmsCancelInterstitialOfferResult {
148149
@IsString()
149150
offeringApiIdentifier!: string;
150151

151-
@IsEnum(Enum_Cancelinterstitialoffer_Currentinterval)
152-
currentInterval!: Enum_Cancelinterstitialoffer_Currentinterval;
152+
@IsString()
153+
currentInterval!: string;
153154

154-
@IsEnum(Enum_Cancelinterstitialoffer_Upgradeinterval)
155-
upgradeInterval!: Enum_Cancelinterstitialoffer_Upgradeinterval;
155+
@IsString()
156+
upgradeInterval!: string;
156157

157158
@IsNumber()
158159
advertisedSavings!: number;

0 commit comments

Comments
 (0)