Skip to content

Commit 5fc785f

Browse files
authored
Merge pull request #19908 from mozilla/PAY-3434
feat(payments-next): Create Cancel churn page
2 parents c32fa39 + dc746b5 commit 5fc785f

25 files changed

Lines changed: 1234 additions & 119 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
## Error page - churn cancel flow
2+
3+
churn-cancel-flow-error-offer-expired-title = This offer has expired
4+
churn-cancel-flow-error-offer-expired-message = There are currently no discounts available for this subscription. You can continue with cancellation if you’d like.
5+
churn-cancel-flow-error-button-continue-to-cancel = Continue to cancel
6+
churn-cancel-flow-error-page-button-back-to-subscriptions = Back to subscriptions
7+
churn-cancel-flow-error-already-canceling-title = Your subscription is set to end
8+
# $productName (String) - The name of the product to create subscription, e.g. Mozilla VPN
9+
# $currentPeriodEnd (Date) - The end date of the subscription's current billing period (e.g., September, 8, 2025)
10+
churn-cancel-flow-error-already-canceling-message = You’ll continue to have access to { $productName } until { $currentPeriodEnd }.
11+
churn-cancel-flow-error-page-button-keep-subscription = Keep subscription
12+
13+
##
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
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 Image from 'next/image';
7+
import Link from 'next/link';
8+
import { notFound, redirect } from 'next/navigation';
9+
10+
import { SubscriptionParams } from '@fxa/payments/ui';
11+
import { determineChurnCancelEligibilityAction } from '@fxa/payments/ui/actions';
12+
import { ChurnError, getApp } from '@fxa/payments/ui/server';
13+
import { getLocalizedDateString } from '@fxa/shared/l10n';
14+
import { auth } from 'apps/payments/next/auth';
15+
import { config } from 'apps/payments/next/config';
16+
17+
export default async function LoyaltyDiscountCancelErrorPage({
18+
params,
19+
searchParams,
20+
}: {
21+
params: SubscriptionParams;
22+
searchParams: Record<string, string> | undefined;
23+
}) {
24+
const { locale, subscriptionId } = params;
25+
26+
if (!config.churnInterventionConfig.enabled) {
27+
redirect(`/${locale}/subscriptions/${subscriptionId}/cancel`);
28+
}
29+
30+
const acceptLanguage = headers().get('accept-language');
31+
const l10n = getApp().getL10n(acceptLanguage, locale);
32+
33+
const session = await auth();
34+
if (!session?.user?.id) {
35+
const redirectToUrl = new URL(
36+
`${config.paymentsNextHostedUrl}/${locale}/subscriptions/landing`
37+
);
38+
redirectToUrl.search = new URLSearchParams(searchParams).toString();
39+
redirect(redirectToUrl.href);
40+
}
41+
42+
const uid = session.user.id;
43+
44+
const pageContent = await determineChurnCancelEligibilityAction(
45+
uid,
46+
subscriptionId,
47+
acceptLanguage
48+
);
49+
50+
if (!pageContent) {
51+
notFound();
52+
}
53+
54+
const { churnCancelContentEligibility } = pageContent;
55+
if (churnCancelContentEligibility.isEligible) {
56+
redirect(
57+
`/${locale}/subscriptions/${subscriptionId}/loyalty-discount/cancel`
58+
);
59+
}
60+
61+
const { cmsOfferingContent, reason } = churnCancelContentEligibility;
62+
if (!cmsOfferingContent) {
63+
notFound();
64+
}
65+
66+
const cancelContent = pageContent.cancelContent;
67+
68+
if (cancelContent.flowType !== 'cancel') {
69+
return (
70+
<ChurnError
71+
cmsOfferingContent={cmsOfferingContent}
72+
locale={locale}
73+
reason={reason}
74+
pageContent={cancelContent}
75+
subscriptionId={subscriptionId}
76+
/>
77+
);
78+
}
79+
80+
if (reason === 'no_churn_intervention_found') {
81+
const { productName, webIcon } = cmsOfferingContent;
82+
return (
83+
<section
84+
className="flex justify-center min-h-[calc(100vh_-_4rem)] tablet:items-center tablet:min-h-[calc(100vh_-_5rem)]"
85+
aria-labelledby="churn-cancel-flow-error-heading"
86+
>
87+
<div className="max-w-[480px] p-10 text-grey-600 tablet:bg-white tablet:rounded-xl tablet:border tablet:border-grey-200 tablet:shadow-[0_0_16px_0_rgba(0,0,0,0.08)]">
88+
<div className="flex flex-col items-center justify-center gap-4 text-center">
89+
<Image src={webIcon} alt={productName} height={64} width={64} />
90+
91+
<h1
92+
id="churn-cancel-flow-error-heading"
93+
className="font-bold leading-7 text-center text-xl"
94+
>
95+
{l10n.getString(
96+
'churn-cancel-flow-error-offer-expired-title',
97+
'This offer has expired'
98+
)}
99+
</h1>
100+
<div className="leading-6">
101+
<p className="my-2">
102+
{l10n.getString(
103+
'churn-cancel-flow-error-offer-expired-message',
104+
`There are currently no discounts available for this subscription. You can continue with cancellation if you’d like.`
105+
)}
106+
</p>
107+
</div>
108+
<div className="flex flex-col gap-3 w-full">
109+
<Link
110+
href={`/${locale}/subscriptions/${subscriptionId}/cancel`}
111+
className="border box-border flex font-bold font-header h-12 items-center justify-center rounded text-center py-2 px-5 bg-blue-500 border-blue-600 hover:bg-blue-700 text-white"
112+
>
113+
{l10n.getString(
114+
'churn-cancel-flow-error-button-continue-to-cancel',
115+
'Continue to cancel'
116+
)}
117+
</Link>
118+
<Link
119+
href={`/${locale}/subscriptions/landing`}
120+
className="border box-border flex font-bold font-header h-12 items-center justify-center rounded text-center py-2 px-5 bg-grey-10 border-grey-200 hover:bg-grey-50"
121+
>
122+
{l10n.getString(
123+
'churn-cancel-flow-error-page-button-back-to-subscriptions',
124+
'Back to subscriptions'
125+
)}
126+
</Link>
127+
</div>
128+
</div>
129+
</div>
130+
</section>
131+
);
132+
}
133+
134+
if (reason === 'already_canceling_at_period_end') {
135+
const { productName, webIcon } = cmsOfferingContent;
136+
const { currentPeriodEnd } = cancelContent;
137+
const currentPeriodEndLongFallback = getLocalizedDateString(
138+
currentPeriodEnd,
139+
false,
140+
locale
141+
);
142+
return (
143+
<section
144+
className="flex justify-center min-h-[calc(100vh_-_4rem)] tablet:items-center tablet:min-h-[calc(100vh_-_5rem)]"
145+
aria-labelledby="error-already-canceling-heading"
146+
>
147+
<div className="max-w-[480px] p-10 text-grey-600 tablet:bg-white tablet:rounded-xl tablet:border tablet:border-grey-200 tablet:shadow-[0_0_16px_0_rgba(0,0,0,0.08)]">
148+
<div className="flex flex-col items-center justify-center gap-4 text-center">
149+
<Image src={webIcon} alt={productName} height={64} width={64} />
150+
151+
<h1
152+
id="error-already-canceling-heading"
153+
className="font-bold leading-7 text-center text-xl"
154+
>
155+
{l10n.getString(
156+
'churn-cancel-flow-error-already-canceling-title',
157+
'Your subscription is set to end'
158+
)}
159+
</h1>
160+
<div className="leading-6">
161+
<p className="my-2">
162+
{l10n.getString(
163+
'churn-cancel-flow-error-already-canceling-message',
164+
{
165+
productName,
166+
currentPeriodEnd: currentPeriodEndLongFallback,
167+
},
168+
`You’ll continue to have access to ${productName} until ${currentPeriodEndLongFallback}.`
169+
)}
170+
</p>
171+
</div>
172+
<div className="flex flex-col gap-3 w-full">
173+
<Link
174+
href={`/${locale}/subscriptions/landing`}
175+
className="border box-border flex font-bold font-header h-12 items-center justify-center rounded text-center py-2 px-5 bg-blue-500 border-blue-600 hover:bg-blue-700 text-white"
176+
>
177+
{l10n.getString(
178+
'churn-cancel-flow-error-page-button-back-to-subscriptions',
179+
'Back to subscriptions'
180+
)}
181+
</Link>
182+
<Link
183+
href={`/${locale}/subscriptions/${subscriptionId}/stay-subscribed`}
184+
className="border box-border flex font-bold font-header h-12 items-center justify-center rounded text-center py-2 px-5 bg-grey-10 border-grey-200 hover:bg-grey-50"
185+
>
186+
{l10n.getString(
187+
'churn-cancel-flow-error-page-button-keep-subscription',
188+
'Keep subscription'
189+
)}
190+
</Link>
191+
</div>
192+
</div>
193+
</div>
194+
</section>
195+
);
196+
}
197+
198+
return (
199+
<ChurnError
200+
cmsOfferingContent={cmsOfferingContent}
201+
locale={locale}
202+
reason={reason}
203+
pageContent={cancelContent}
204+
subscriptionId={subscriptionId}
205+
/>
206+
);
207+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
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 { notFound, redirect } from 'next/navigation';
7+
8+
import { ChurnCancel, SubscriptionParams } from '@fxa/payments/ui';
9+
import { determineChurnCancelEligibilityAction } 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 LoyaltyDiscountCancelPage({
14+
params,
15+
searchParams,
16+
}: {
17+
params: SubscriptionParams;
18+
searchParams: Record<string, string> | undefined;
19+
}) {
20+
const { locale, subscriptionId } = params;
21+
22+
if (!config.churnInterventionConfig.enabled) {
23+
redirect(`/${locale}/subscriptions/${subscriptionId}/cancel`);
24+
}
25+
26+
const acceptLanguage = headers().get('accept-language');
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+
const pageContent = await determineChurnCancelEligibilityAction(
40+
uid,
41+
subscriptionId,
42+
acceptLanguage
43+
);
44+
45+
if (!pageContent) notFound();
46+
47+
const { churnCancelContentEligibility, cancelContent } = pageContent;
48+
const { cmsOfferingContent, reason, cmsChurnInterventionEntry } =
49+
churnCancelContentEligibility;
50+
const reasonStr = typeof reason === 'string' ? reason : undefined;
51+
const isAllowedCancelReason =
52+
reasonStr === 'eligible' || reasonStr === 'discount_already_applied';
53+
54+
if (!isAllowedCancelReason) {
55+
redirect(
56+
`/${locale}/subscriptions/${subscriptionId}/loyalty-discount/cancel/error`
57+
);
58+
}
59+
60+
if (!cancelContent || cancelContent.flowType !== 'cancel') {
61+
redirect(
62+
`/${locale}/subscriptions/${subscriptionId}/loyalty-discount/cancel/error`
63+
);
64+
}
65+
66+
if (!cmsChurnInterventionEntry) {
67+
redirect(
68+
`/${locale}/subscriptions/${subscriptionId}/loyalty-discount/cancel/error`
69+
);
70+
}
71+
72+
return (
73+
<ChurnCancel
74+
uid={uid}
75+
subscriptionId={subscriptionId}
76+
locale={locale}
77+
reason={reason}
78+
cmsChurnInterventionEntry={cmsChurnInterventionEntry}
79+
cmsOfferingContent={cmsOfferingContent}
80+
cancelContent={cancelContent}
81+
/>
82+
);
83+
}

apps/payments/next/app/[locale]/subscriptions/[subscriptionId]/loyalty-discount/stay-subscribed/error/page.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,14 @@ export default async function LoyaltyDiscountStaySubscribedErrorPage({
4747
notFound();
4848
}
4949

50-
const { cmsOfferingContent, reason } = pageContent;
50+
const { churnStaySubscribedEligibility } = pageContent;
51+
if (churnStaySubscribedEligibility.isEligible) {
52+
redirect(
53+
`/${locale}/subscriptions/${subscriptionId}/loyalty-discount/stay-subscribed`
54+
);
55+
}
5156

57+
const { cmsOfferingContent, reason } = churnStaySubscribedEligibility;
5258
if (!cmsOfferingContent) {
5359
notFound();
5460
}

apps/payments/next/app/[locale]/subscriptions/[subscriptionId]/loyalty-discount/stay-subscribed/page.tsx

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,6 @@ import { determineStaySubscribedEligibilityAction } from '@fxa/payments/ui/actio
1010
import { auth } from 'apps/payments/next/auth';
1111
import { config } from 'apps/payments/next/config';
1212

13-
enum ChurnStayErrorReason {
14-
DiscountAlreadyApplied = 'discount_already_applied',
15-
SubscriptionNotActive = 'subscription_not_active',
16-
GeneralError = 'general_error',
17-
RedemptionLimitExceeded = 'redemption_limit_exceeded',
18-
}
19-
2013
export default async function LoyaltyDiscountStaySubscribedPage({
2114
params,
2215
searchParams,
@@ -51,17 +44,16 @@ export default async function LoyaltyDiscountStaySubscribedPage({
5144

5245
if (!pageContent) notFound();
5346

54-
const { cmsOfferingContent, reason, staySubscribedContent } = pageContent;
47+
const { churnStaySubscribedEligibility, staySubscribedContent } = pageContent;
48+
const { cmsOfferingContent, reason, cmsChurnInterventionEntry } =
49+
churnStaySubscribedEligibility;
5550
const reasonStr = typeof reason === 'string' ? reason : undefined;
56-
const isErrorReason =
57-
!!reasonStr &&
58-
(Object.values(ChurnStayErrorReason) as string[]).includes(reasonStr);
5951
const isAllowedStayReason =
6052
reasonStr === 'eligible' ||
6153
reasonStr === 'no_churn_intervention_found' ||
6254
reasonStr === 'subscription_still_active';
6355

64-
if (isErrorReason) {
56+
if (!isAllowedStayReason) {
6557
redirect(
6658
`/${locale}/subscriptions/${subscriptionId}/loyalty-discount/stay-subscribed/error`
6759
);
@@ -71,17 +63,19 @@ export default async function LoyaltyDiscountStaySubscribedPage({
7163
!staySubscribedContent ||
7264
staySubscribedContent.flowType !== 'stay_subscribed'
7365
) {
74-
notFound();
66+
redirect(
67+
`/${locale}/subscriptions/${subscriptionId}/loyalty-discount/stay-subscribed/error`
68+
);
7569
}
7670

77-
if (reasonStr == null || isAllowedStayReason) {
71+
if (isAllowedStayReason) {
7872
return (
7973
<ChurnStaySubscribed
8074
uid={uid}
8175
subscriptionId={subscriptionId}
8276
locale={locale}
8377
reason={reason}
84-
cmsChurnInterventionEntry={pageContent.cmsChurnInterventionEntry}
78+
cmsChurnInterventionEntry={cmsChurnInterventionEntry}
8579
cmsOfferingContent={cmsOfferingContent}
8680
staySubscribedContent={staySubscribedContent}
8781
/>

0 commit comments

Comments
 (0)