Skip to content

Commit c162981

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 21275b2 commit c162981

File tree

16 files changed

+556
-23
lines changed

16 files changed

+556
-23
lines changed
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
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
9+
10+
11+
## Error page
12+
interstitial-offer-error-subscription-not-found-heading = Subscription not found
13+
interstitial-offer-error-subscription-not-found-message = We were unable to find your subscription. Please contact support if you need assistance.
14+
15+
interstitial-offer-error-configuration-heading = Unable to load offer
16+
interstitial-offer-error-configuration-message = We encountered an issue loading this offer. Please try again or contact support.
17+
18+
interstitial-offer-error-no-offer-heading = No offers available
19+
interstitial-offer-error-no-offer-message = There are currently no upgrade offers available for your subscription
20+
21+
interstitial-offer-error-not-eligible-heading = Not eligible for upgrade
22+
interstitial-offer-error-not-eligible-message = Your subscription is not eligible for an upgrade offer at this time.
23+
24+
interstitial-offer-error-general-heading = Something went wrong
25+
interstitial-offer-error-general-message = Something went wrong. Please try again or contact support.
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
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+
const acceptLanguage = headers().get('accept-language');
26+
27+
const session = await auth();
28+
if (!session?.user?.id) {
29+
const redirectToUrl = new URL(
30+
`${config.paymentsNextHostedUrl}/${locale}/subscriptions/landing`
31+
);
32+
redirectToUrl.search = new URLSearchParams(searchParams).toString();
33+
redirect(redirectToUrl.href);
34+
}
35+
36+
const uid = session.user.id;
37+
38+
const interstitialOfferContent = await getInterstitialOfferContentAction(
39+
uid,
40+
subscriptionId,
41+
acceptLanguage,
42+
locale
43+
);
44+
45+
if (!interstitialOfferContent) {
46+
notFound();
47+
}
48+
49+
if (interstitialOfferContent.isEligible && interstitialOfferContent.pageContent) {
50+
redirect(`/${locale}/subscriptions/${subscriptionId}/offer`);
51+
}
52+
53+
const { reason, webIcon, productName} = interstitialOfferContent;
54+
55+
if (!reason || !webIcon || !productName) {
56+
notFound();
57+
}
58+
59+
const l10n = getApp().getL10n(acceptLanguage, locale);
60+
61+
const getErrorContent = (reason: string) => {
62+
switch (reason) {
63+
case 'subscription_not_found':
64+
return {
65+
heading: l10n.getString(
66+
'interstitial-offer-error-subscription-not-found-heading',
67+
'Subscription not found'
68+
),
69+
message: l10n.getString(
70+
'interstitial-offer-error-subscription-not-found-message',
71+
'We were unable to find your subscription. Please contact support if you need assistance.'
72+
),
73+
};
74+
case 'current_interval_not_found':
75+
case 'stripe_price_id_not_found':
76+
case 'offering_id_not_found':
77+
return {
78+
heading: l10n.getString(
79+
'interstitial-offer-error-configuration-heading',
80+
'Unable to load offer'
81+
),
82+
message: l10n.getString(
83+
'interstitial-offer-error-configuration-message',
84+
'We encountered an issue loading this offer. Please try again or contact support.'
85+
),
86+
};
87+
case 'no_cancel_interstitial_offer_found':
88+
case 'no_upgrade_plan_found':
89+
return {
90+
heading: l10n.getString(
91+
'interstitial-offer-error-no-offer-heading',
92+
'No offers available'
93+
),
94+
message: l10n.getString(
95+
'interstitial-offer-error-no-offer-message',
96+
'There are currently no upgrade offers available for your subscription.'
97+
),
98+
};
99+
case 'not_eligible_for_upgrade_interval':
100+
return {
101+
heading: l10n.getString(
102+
'interstitial-offer-error-not-eligible-heading',
103+
'Not eligible for upgrade'
104+
),
105+
message: l10n.getString(
106+
'interstitial-offer-error-not-eligible-message',
107+
'Your subscription is not eligible for an upgrade offer at this time.'
108+
),
109+
};
110+
case 'general_error':
111+
default:
112+
return {
113+
heading: l10n.getString(
114+
'interstitial-offer-error-general-heading',
115+
'Something went wrong'
116+
),
117+
message: l10n.getString(
118+
'interstitial-offer-error-general-message',
119+
'Something went wrong. Please try again or contact support.'
120+
),
121+
};
122+
}
123+
};
124+
125+
const { heading, message } = getErrorContent(reason);
126+
127+
return (
128+
<section
129+
className="flex justify-center min-h-[calc(100vh_-_4rem)] tablet:items-center tablet:min-h-[calc(100vh_-_5rem)]"
130+
aria-labelledby="error-heading"
131+
>
132+
<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)]">
133+
<div className="w-full flex flex-col items-center gap-6 text-center">
134+
{webIcon && (
135+
<Image
136+
src={webIcon}
137+
alt={productName}
138+
height={64}
139+
width={64}
140+
/>
141+
)}
142+
<h1
143+
id="error-heading"
144+
className="font-bold self-stretch text-center font-header text-xl leading-8"
145+
>
146+
{heading}
147+
</h1>
148+
<p className="w-full self-stretch leading-7 text-lg text-grey-900 text-center tablet:text-left">
149+
{message}
150+
</p>
151+
</div>
152+
153+
<div className="w-full flex flex-col gap-3 mt-12">
154+
<Link
155+
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"
156+
href={`/${locale}/subscriptions/landing`}
157+
>
158+
{l10n.getString(
159+
'interstitial-offer-error-button-manage-subscriptions',
160+
'Manage subscriptions'
161+
)}
162+
</Link>
163+
</div>
164+
</div>
165+
</section>
166+
);
167+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
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}/offer/error`);
53+
}
54+
55+
const {
56+
currentInterval,
57+
modalHeading1,
58+
modalMessage,
59+
upgradeButtonLabel,
60+
upgradeButtonUrl,
61+
webIcon,
62+
productName,
63+
} = interstitialOfferContent.pageContent;
64+
65+
const getKeepCurrentSubscriptionFtlIds = (interval: string) => {
66+
switch (interval) {
67+
case 'daily':
68+
return {
69+
ftlId: 'interstitial-offer-button-keep-current-interval-daily',
70+
fallbackText: 'Keep daily subscription',
71+
};
72+
case 'weekly':
73+
return {
74+
ftlId: 'interstitial-offer-button-keep-current-interval-weekly',
75+
fallbackText: 'Keep weekly subscription',
76+
};
77+
case 'halfyearly':
78+
return {
79+
ftlId: 'interstitial-offer-button-keep-current-interval-halfyearly',
80+
fallbackText: 'Keep six-month subscription',
81+
};
82+
case 'monthly':
83+
default:
84+
return {
85+
ftlId: 'interstitial-offer-button-keep-current-interval-monthly',
86+
fallbackText: 'Keep monthly subscription',
87+
};
88+
}
89+
};
90+
91+
const { ftlId, fallbackText } = getKeepCurrentSubscriptionFtlIds(currentInterval);
92+
const keepCurrentSubscriptionButtonText = l10n.getString(ftlId, fallbackText);
93+
94+
return (
95+
<section
96+
className="flex justify-center min-h-[calc(100vh_-_4rem)] tablet:items-center tablet:min-h-[calc(100vh_-_5rem)]"
97+
>
98+
<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)]">
99+
<div className="w-full flex flex-col items-center gap-6 text-center">
100+
<Image
101+
src={webIcon}
102+
alt={productName}
103+
height={64}
104+
width={64}
105+
/>
106+
<h1 className="font-bold self-stretch text-center font-header text-xl leading-8 ">
107+
{modalHeading1}
108+
</h1>
109+
</div>
110+
<p className="w-full self-stretch leading-7 text-lg text-grey-900">
111+
{modalMessage &&
112+
modalMessage.map((line, i) => (
113+
<p className="my-2" key={i}>
114+
{line}
115+
</p>
116+
))}
117+
</p>
118+
119+
<div className="w-full flex flex-col gap-3 mt-12">
120+
<Link
121+
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"
122+
href={upgradeButtonUrl}
123+
>
124+
{upgradeButtonLabel}
125+
</Link>
126+
<Link
127+
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"
128+
href={`/${locale}/subscriptions/landing`}
129+
>
130+
<span>{keepCurrentSubscriptionButtonText}</span>
131+
</Link>
132+
<Link
133+
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"
134+
href={`/${locale}/subscriptions/${subscriptionId}/cancel`}
135+
>
136+
<span>{l10n.getString(
137+
'interstitial-offer-button-cancel-subscription',
138+
'Continue to cancel'
139+
)}</span>
140+
</Link>
141+
</div>
142+
</div>
143+
</section>
144+
);
145+
}

0 commit comments

Comments
 (0)