Skip to content

Commit dba95bd

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 ca20d91 commit dba95bd

18 files changed

Lines changed: 661 additions & 48 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
## InterstitialOffer
2+
interstitial-offer-button-cancel-subscription = Continue to cancel
3+
4+
## Daily/Weekly/Monthly refers to the user's current subscription interval
5+
interstitial-offer-button-keep-current-interval-daily = Keep daily subscription
6+
interstitial-offer-button-keep-current-interval-weekly = Keep weekly subscription
7+
interstitial-offer-button-keep-current-interval-monthly = Keep monthly subscription
8+
interstitial-offer-button-keep-current-interval-halfyearly = Keep six-month subscription
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
## Error page
2+
interstitial-offer-error-subscription-not-found-heading = We couldn’t find an active subscription
3+
interstitial-offer-error-subscription-not-found-message = It looks like this subscription may no longer be active.
4+
5+
interstitial-offer-error-general-heading = Offer isn’t available
6+
interstitial-offer-error-general-message = It looks like this offer is not available at this time.
7+
8+
interstitial-offer-error-button-back-to-subscriptions = Back to subscriptions
9+
interstitial-offer-error-button-cancel-subscription = Continue to cancel
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
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+
import Link from 'next/link';
8+
import Image from 'next/image';
9+
import { getApp } from '@fxa/payments/ui/server';
10+
import { getInterstitialOfferContentAction } from '@fxa/payments/ui/actions';
11+
import { auth } from 'apps/payments/next/auth';
12+
import { config } from 'apps/payments/next/config';
13+
14+
export default async function InterstitialOfferErrorPage({
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+
26+
if (!config.churnInterventionConfig.enabled) {
27+
redirect(`/${locale}/subscriptions/landing`);
28+
}
29+
30+
const acceptLanguage = headers().get('accept-language');
31+
const session = await auth();
32+
if (!session?.user?.id) {
33+
const redirectToUrl = new URL(
34+
`${config.paymentsNextHostedUrl}/${locale}/subscriptions/landing`
35+
);
36+
redirectToUrl.search = new URLSearchParams(searchParams).toString();
37+
redirect(redirectToUrl.href);
38+
}
39+
40+
const uid = session.user.id;
41+
42+
let interstitialOfferContent;
43+
try {
44+
interstitialOfferContent = await getInterstitialOfferContentAction(
45+
uid,
46+
subscriptionId,
47+
acceptLanguage,
48+
locale
49+
);
50+
} catch (error) {
51+
notFound();
52+
}
53+
54+
if (!interstitialOfferContent) {
55+
notFound();
56+
}
57+
58+
if (interstitialOfferContent.isEligible && interstitialOfferContent.pageContent) {
59+
redirect(`/${locale}/subscriptions/${subscriptionId}/offer`);
60+
}
61+
62+
const { webIcon, productName} = interstitialOfferContent;
63+
const reason = interstitialOfferContent.reason ?? 'general_error';
64+
65+
if (webIcon && !productName) {
66+
throw new Error('Missing productName for interstitial offer icon');
67+
}
68+
69+
const l10n = getApp().getL10n(acceptLanguage, locale);
70+
71+
const getErrorContent = (reason: string) => {
72+
switch (reason) {
73+
case 'subscription_not_active':
74+
case 'subscription_not_found':
75+
return {
76+
heading: l10n.getString(
77+
'interstitial-offer-error-subscription-not-found-heading',
78+
'We couldn’t find an active subscription'
79+
),
80+
message: l10n.getString(
81+
'interstitial-offer-error-subscription-not-found-message',
82+
'It looks like this subscription may no longer be active.'
83+
),
84+
showContinueToCancelButton: false,
85+
};
86+
default:
87+
return {
88+
heading: l10n.getString(
89+
'interstitial-offer-error-general-heading',
90+
'Offer isn’t available'
91+
),
92+
message: l10n.getString(
93+
'interstitial-offer-error-general-message',
94+
'It looks like this offer is not available at this time.'
95+
),
96+
showContinueToCancelButton: true,
97+
};
98+
}
99+
};
100+
101+
const { heading, message, showContinueToCancelButton } = getErrorContent(reason);
102+
103+
return (
104+
<section
105+
className="flex justify-center min-h-[calc(100vh_-_4rem)] tablet:items-center tablet:min-h-[calc(100vh_-_5rem)]"
106+
aria-labelledby="error-heading"
107+
>
108+
<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)]">
109+
<div className="w-full flex flex-col items-center gap-6 text-center">
110+
{webIcon && (
111+
<Image
112+
src={webIcon}
113+
alt={productName ?? ''}
114+
height={64}
115+
width={64}
116+
/>
117+
)}
118+
<h1
119+
id="error-heading"
120+
className="font-bold self-stretch text-center font-header text-xl leading-8"
121+
>
122+
{heading}
123+
</h1>
124+
<p className="w-full self-stretch leading-7 text-lg text-grey-900 text-center tablet:text-start">
125+
{message}
126+
</p>
127+
</div>
128+
<div className="w-full flex flex-col gap-3 mt-10">
129+
<Link
130+
className="border box-border font-header h-14 items-center justify-center rounded-md text-white text-center font-bold py-4 px-6 bg-blue-500 hover:bg-blue-700 flex w-full"
131+
href={`/${locale}/subscriptions/landing`}
132+
>
133+
{l10n.getString(
134+
'interstitial-offer-error-button-back-to-subscriptions',
135+
'Back to subscriptions'
136+
)}
137+
</Link>
138+
{showContinueToCancelButton && (
139+
<Link
140+
className="border box-border font-header h-14 items-center justify-center rounded-md text-center font-bold py-4 px-6 bg-grey-10 border-grey-200 hover:bg-grey-50 flex w-full"
141+
href={`/${locale}/subscriptions/${subscriptionId}/cancel`}
142+
>
143+
<span>{l10n.getString(
144+
'interstitial-offer-error-button-cancel-subscription',
145+
'Continue to cancel'
146+
)}</span>
147+
</Link>
148+
)}
149+
</div>
150+
</div>
151+
</section>
152+
);
153+
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
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+
26+
if (!config.churnInterventionConfig.enabled) {
27+
redirect(`/${locale}/subscriptions/landing`);
28+
}
29+
30+
const acceptLanguage = headers().get('accept-language');
31+
const l10n = getApp().getL10n(acceptLanguage, locale);
32+
const session = await auth();
33+
if (!session?.user?.id) {
34+
const redirectToUrl = new URL(
35+
`${config.paymentsNextHostedUrl}/${locale}/subscriptions/landing`
36+
);
37+
redirectToUrl.search = new URLSearchParams(searchParams).toString();
38+
redirect(redirectToUrl.href);
39+
}
40+
41+
const uid = session.user.id;
42+
43+
let interstitialOfferContent;
44+
try {
45+
interstitialOfferContent = await getInterstitialOfferContentAction(
46+
uid,
47+
subscriptionId,
48+
acceptLanguage,
49+
locale
50+
);
51+
} catch (error) {
52+
redirect(`/${locale}/subscriptions/${subscriptionId}/offer/error`);
53+
}
54+
55+
if (!interstitialOfferContent.isEligible || !interstitialOfferContent.pageContent) {
56+
redirect(`/${locale}/subscriptions/${subscriptionId}/offer/error`);
57+
}
58+
59+
const {
60+
currentInterval,
61+
modalHeading1,
62+
modalMessage,
63+
upgradeButtonLabel,
64+
upgradeButtonUrl,
65+
webIcon,
66+
productName,
67+
} = interstitialOfferContent.pageContent;
68+
69+
const getKeepCurrentSubscriptionFtlIds = (interval: string) => {
70+
switch (interval) {
71+
case 'daily':
72+
return {
73+
ftlId: 'interstitial-offer-button-keep-current-interval-daily',
74+
fallbackText: 'Keep daily subscription',
75+
};
76+
case 'weekly':
77+
return {
78+
ftlId: 'interstitial-offer-button-keep-current-interval-weekly',
79+
fallbackText: 'Keep weekly subscription',
80+
};
81+
case 'halfyearly':
82+
return {
83+
ftlId: 'interstitial-offer-button-keep-current-interval-halfyearly',
84+
fallbackText: 'Keep six-month subscription',
85+
};
86+
case 'monthly':
87+
default:
88+
return {
89+
ftlId: 'interstitial-offer-button-keep-current-interval-monthly',
90+
fallbackText: 'Keep monthly subscription',
91+
};
92+
}
93+
};
94+
95+
const { ftlId, fallbackText } = getKeepCurrentSubscriptionFtlIds(currentInterval);
96+
const keepCurrentSubscriptionButtonText = l10n.getString(ftlId, fallbackText);
97+
98+
return (
99+
<section
100+
className="flex justify-center min-h-[calc(100vh_-_4rem)] tablet:items-center tablet:min-h-[calc(100vh_-_5rem)]"
101+
>
102+
<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)]">
103+
<div className="w-full flex flex-col items-center gap-6 text-center">
104+
<Image
105+
src={webIcon}
106+
alt={productName}
107+
height={64}
108+
width={64}
109+
/>
110+
<h1 className="font-bold self-stretch text-center font-header text-xl leading-8 ">
111+
{modalHeading1}
112+
</h1>
113+
</div>
114+
<p className="w-full self-stretch leading-7 text-lg text-grey-900">
115+
{modalMessage &&
116+
modalMessage.map((line, i) => (
117+
<p className="my-2" key={i}>
118+
{line}
119+
</p>
120+
))}
121+
</p>
122+
123+
<div className="w-full flex flex-col gap-3 mt-12">
124+
<Link
125+
className="border box-border font-header h-14 items-center justify-center rounded-md text-white text-center font-bold py-4 px-6 bg-blue-500 hover:bg-blue-700 flex w-full"
126+
href={upgradeButtonUrl}
127+
>
128+
{upgradeButtonLabel}
129+
</Link>
130+
<Link
131+
className="border box-border font-header h-14 items-center justify-center rounded-md text-center font-bold py-4 px-6 bg-grey-10 border-grey-200 hover:bg-grey-50 flex w-full"
132+
href={`/${locale}/subscriptions/landing`}
133+
>
134+
<span>{keepCurrentSubscriptionButtonText}</span>
135+
</Link>
136+
<Link
137+
className="border box-border font-header h-14 items-center justify-center rounded-md text-center font-bold py-4 px-6 bg-grey-10 border-grey-200 hover:bg-grey-50 flex w-full"
138+
href={`/${locale}/subscriptions/${subscriptionId}/cancel`}
139+
>
140+
<span>{l10n.getString(
141+
'interstitial-offer-button-cancel-subscription',
142+
'Continue to cancel'
143+
)}</span>
144+
</Link>
145+
</div>
146+
</div>
147+
</section>
148+
);
149+
}

0 commit comments

Comments
 (0)