Skip to content

Commit 7f5dbf2

Browse files
committed
Merge branch 'IA-1273-cancellation-flow-mail-plus' into 'main'
new cancellation flow for mail plus subscribers only See merge request web/clients!9825
2 parents ef7738e + 4e8adbc commit 7f5dbf2

27 files changed

+1028
-18
lines changed

applications/account/src/app/content/MainContainer.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { c } from 'ttag';
55

66
import {
77
AppLink,
8+
CancellationReminderSection,
89
CustomLogo,
910
FeatureCode,
1011
Logo,
@@ -32,6 +33,7 @@ import {
3233
} from '@proton/components';
3334
import ContactEmailsProvider from '@proton/components/containers/contacts/ContactEmailsProvider';
3435
import { getIsSectionAvailable, getSectionPath } from '@proton/components/containers/layout/helper';
36+
import { CANCEL_ROUTE } from '@proton/components/containers/payments/subscription/b2cCancellationFlow/helper';
3537
import { useIsSessionRecoveryAvailable, useShowThemeSelection } from '@proton/components/hooks';
3638
import { getPublicUserProtonAddressApps, getSSOVPNOnlyAccountApps } from '@proton/shared/lib/apps/apps';
3739
import { getAppFromPathnameSafe, getSlugFromApp } from '@proton/shared/lib/apps/slugHelper';
@@ -289,6 +291,9 @@ const MainContainer = () => {
289291
redirect={redirect}
290292
/>
291293
</Route>
294+
<Route path={`/${appSlug}${CANCEL_ROUTE}`}>
295+
<CancellationReminderSection app={app} />
296+
</Route>
292297
<Route path={`/${mailSlug}`}>
293298
<Suspense fallback={<PrivateMainAreaLoading />}>
294299
<MailSettingsRouter mailAppRoutes={routes.mail} redirect={redirect} />
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { c } from 'ttag';
2+
3+
import { CircleLoader } from '@proton/atoms/CircleLoader';
4+
import { ModalProps, ModalTwo, ModalTwoContent } from '@proton/components/components';
5+
6+
const CancelSubscriptionLoadingModal = (props: ModalProps) => {
7+
return (
8+
<ModalTwo {...props} data-testid="cancel-subscription-loading">
9+
<ModalTwoContent
10+
className="text-center h-custom flex flex-column items-center justify-center"
11+
style={{ '--h-custom': '18rem' }}
12+
>
13+
<CircleLoader size="large" />
14+
<p>{c('State').t`Cancelling your subscription, please wait`}</p>
15+
</ModalTwoContent>
16+
</ModalTwo>
17+
);
18+
};
19+
20+
export default CancelSubscriptionLoadingModal;

packages/components/containers/payments/subscription/DowngradeSubscriptionSection.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,27 @@ import { APP_NAMES } from '@proton/shared/lib/constants';
66
import noop from '@proton/utils/noop';
77

88
import { SettingsParagraph, SettingsSection } from '../../account';
9+
import { useFlag } from '../../unleash';
10+
import { useB2CCancellationFlow } from './b2cCancellationFlow';
911
import { useCancelSubscriptionFlow } from './cancelSubscription';
1012

1113
const DowngradeSubscriptionSection = ({ app }: { app: APP_NAMES }) => {
14+
const [submitting, withSubmitting] = useLoading();
15+
16+
const isNewCancellationFlowEnabled = useFlag('NewCancellationFlow');
17+
18+
const { redirectToCancellationFlow, hasAccess: hasAccessToNewCancellationFlow } = useB2CCancellationFlow();
1219
const { cancelSubscription, cancelSubscriptionModals, loadingCancelSubscription } = useCancelSubscriptionFlow({
1320
app,
1421
});
1522

16-
const [submitting, withSubmitting] = useLoading();
23+
const handleCancelClick = () => {
24+
if (hasAccessToNewCancellationFlow && isNewCancellationFlowEnabled) {
25+
redirectToCancellationFlow();
26+
} else {
27+
void withSubmitting(cancelSubscription().catch(noop));
28+
}
29+
};
1730

1831
return (
1932
<SettingsSection>
@@ -27,7 +40,7 @@ const DowngradeSubscriptionSection = ({ app }: { app: APP_NAMES }) => {
2740
shape="outline"
2841
disabled={loadingCancelSubscription}
2942
loading={submitting}
30-
onClick={() => withSubmitting(cancelSubscription().catch(noop))}
43+
onClick={handleCancelClick}
3144
data-testid="UnsubscribeButton"
3245
>
3346
{c('Action').t`Downgrade account`}

packages/components/containers/payments/subscription/FeedbackDowngradeModal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ const FeedbackDowngradeModal = ({
263263
/>
264264
</ModalContent>
265265
<ModalFooter>
266-
<Button data-testid="cancelFeedback" onClick={handleKeepSubscription}>{c('Action').t`Cancel`}</Button>
266+
<Button data-testid="cancelFeedback" onClick={handleKeepSubscription}>{c('Action').t`Skip`}</Button>
267267
<Button data-testid="submitFeedback" type="submit" color="norm">
268268
{c('Action').t`Submit`}
269269
</Button>

packages/components/containers/payments/subscription/PlanSelection.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { switchPlan } from '@proton/shared/lib/helpers/planIDs';
1616
import {
1717
getIpPricePerMonth,
1818
getOverriddenPricePerCycle,
19+
hasMail,
1920
hasMaximumCycle,
2021
} from '@proton/shared/lib/helpers/subscription';
2122
import {
@@ -45,7 +46,9 @@ import {
4546
SelectTwo,
4647
Tabs,
4748
VpnLogo,
49+
useSettingsLink,
4850
} from '../../../components';
51+
import { useFlag } from '../../unleash';
4952
import { VPNIntroPricingVariant } from '../../unleash/vpnIntroPricing';
5053
import CurrencySelector from '../CurrencySelector';
5154
import CycleSelector from '../CycleSelector';
@@ -54,6 +57,7 @@ import { ShortPlanLike, isShortPlanLike } from '../features/interface';
5457
import { getShortPlan, getVPNEnterprisePlan } from '../features/plan';
5558
import PlanCard from './PlanCard';
5659
import PlanCardFeatures, { PlanCardFeatureList, PlanCardFeaturesShort } from './PlanCardFeatures';
60+
import { CANCEL_ROUTE } from './b2cCancellationFlow/helper';
5761
import VpnEnterpriseAction from './helpers/VpnEnterpriseAction';
5862
import { getVPNPlanToUse } from './helpers/payment';
5963

@@ -218,9 +222,12 @@ const PlanSelection = ({
218222
PLANS.PASS_PLUS,
219223
].filter(isTruthy);
220224
const enabledProductB2BPlans = [PLANS.MAIL_PRO /*, PLANS.DRIVE_PRO*/];
225+
const goToSettings = useSettingsLink();
221226

222227
const alreadyHasMaxCycle = hasMaximumCycle(subscription);
223228

229+
const isNewCancellationFlowEnabled = useFlag('NewCancellationFlow');
230+
224231
function excludingCurrentPlanWithMaxCycle(plan: Plan | ShortPlanLike): boolean {
225232
if (isShortPlanLike(plan)) {
226233
return true;
@@ -369,6 +376,12 @@ const PlanSelection = ({
369376
price={price}
370377
features={featuresElement}
371378
onSelect={(planName) => {
379+
// Mail plus users selecting free plan are redirected to the cancellation reminder flow
380+
if (planName === PLANS.FREE && hasMail(subscription) && isNewCancellationFlowEnabled) {
381+
goToSettings(CANCEL_ROUTE);
382+
return;
383+
}
384+
372385
onChangePlanIDs(
373386
switchPlan({
374387
planIDs,
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { c } from 'ttag';
2+
3+
import { Button, ButtonLike } from '@proton/atoms/Button';
4+
import { Href } from '@proton/atoms/Href';
5+
import {
6+
Icon,
7+
IconName,
8+
ModalProps,
9+
ModalTwo,
10+
ModalTwoContent,
11+
ModalTwoFooter,
12+
ModalTwoHeader,
13+
SettingsLink,
14+
StripedItem,
15+
StripedList,
16+
} from '@proton/components/components';
17+
import { getKnowledgeBaseUrl } from '@proton/shared/lib/helpers/url';
18+
19+
import { ConfirmationModal } from './interface';
20+
21+
interface Props extends ModalProps, ConfirmationModal {
22+
ctaText: string;
23+
cancelSubscription: () => void;
24+
features: { icon: IconName; text: string }[];
25+
}
26+
27+
const CancelConfirmationModal = ({
28+
ctaText,
29+
features,
30+
description,
31+
cancelSubscription,
32+
learnMoreLink,
33+
...modalProps
34+
}: Props) => {
35+
return (
36+
<ModalTwo {...modalProps} data-testid="cancellation-reminder-confirmation">
37+
<ModalTwoHeader title={c('Subscription reminder').t`Cancel subscription?`} />
38+
<ModalTwoContent>
39+
<p className="m-0 mb-1">{description}</p>
40+
<Href className="mb-8" href={getKnowledgeBaseUrl(learnMoreLink)}>
41+
{c('Link').t`Learn more`}
42+
</Href>
43+
<p className="mb-4 mt-6 text-lg text-bold">{c('Subscription reminder')
44+
.t`When you cancel, you will lose access to`}</p>
45+
<StripedList alternate="odd" className="mt-0">
46+
{features.map(({ icon, text }) => (
47+
<StripedItem key={text} left={<Icon name={icon} className="color-primary" />}>
48+
{text}
49+
</StripedItem>
50+
))}
51+
</StripedList>
52+
</ModalTwoContent>
53+
<ModalTwoFooter className="flex justify-space-between">
54+
<Button onClick={cancelSubscription} shape="outline">{c('Subscription reminder')
55+
.t`Cancel subscription`}</Button>
56+
<ButtonLike
57+
as={SettingsLink}
58+
path="/dashboard"
59+
shape="solid"
60+
color="norm"
61+
className="flex flex-nowrap items-center justify-center"
62+
>
63+
<Icon name="brand-proton-mail-filled-plus" size={5} className="mr-1" />
64+
{ctaText}
65+
</ButtonLike>
66+
</ModalTwoFooter>
67+
</ModalTwo>
68+
);
69+
};
70+
71+
export default CancelConfirmationModal;
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { c } from 'ttag';
2+
3+
import { ButtonLike } from '@proton/atoms/Button';
4+
import { ModalProps, Prompt, SettingsLink } from '@proton/components/components';
5+
import { PLANS } from '@proton/shared/lib/constants';
6+
7+
interface Props extends ModalProps {
8+
text: string;
9+
plan: PLANS;
10+
}
11+
12+
const CancelRedirectionModal = ({ text, plan, ...props }: Props) => {
13+
return (
14+
<Prompt
15+
{...props}
16+
title={c('Subscription reminder').t`Subscription canceled`}
17+
data-testid="cancellation-reminder-redirection"
18+
buttons={[
19+
<ButtonLike as={SettingsLink} path={`/dashboard/upgrade?plan=${plan}&target=compare`} color="norm">{c(
20+
'Subscription reminder'
21+
).t`Resubscribe`}</ButtonLike>,
22+
<ButtonLike as={SettingsLink} path="/dashboard">{c('Subscription reminder').t`Close`}</ButtonLike>,
23+
]}
24+
>
25+
<p>{text}</p>
26+
</Prompt>
27+
);
28+
};
29+
30+
export default CancelRedirectionModal;
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { useEffect, useState } from 'react';
2+
3+
import { c } from 'ttag';
4+
5+
import { Button, ButtonLike } from '@proton/atoms/Button';
6+
import { Icon, SettingsLink, useModalState } from '@proton/components/components';
7+
import { SettingsSection } from '@proton/components/containers/account';
8+
import { useAppTitle, usePlans } from '@proton/components/hooks';
9+
import { APP_NAMES } from '@proton/shared/lib/constants';
10+
import { getStaticURL } from '@proton/shared/lib/helpers/url';
11+
12+
import { useCancelSubscriptionFlow } from '../cancelSubscription';
13+
import CancelConfirmationModal from './CancelConfirmationModal';
14+
import CancelRedirectionModal from './CancelRedirectionModal';
15+
import ReminderSectionFeatures from './ReminderSectionFeatures';
16+
import ReminderSectionPlan from './ReminderSectionPlan';
17+
import ReminderSectionStorage from './ReminderSectionStorage';
18+
import ReminderSectionTestimonials from './ReminderSectionTestimonials';
19+
import { getReminderPageConfig } from './reminderPageConfig';
20+
import useB2CCancellationFlow from './useB2CCancellationFlow';
21+
22+
interface Props {
23+
app: APP_NAMES;
24+
}
25+
26+
export const CancellationReminderSection = ({ app }: Props) => {
27+
const { hasAccess, plan, redirectToDashboard, subscription, setStartedCancellation } = useB2CCancellationFlow();
28+
const { cancelSubscription, cancelSubscriptionModals, loadingCancelSubscription } = useCancelSubscriptionFlow({
29+
app,
30+
});
31+
32+
const [planResult] = usePlans();
33+
const [cancelModalProps, setCancelModalOpen, cancelRenderModal] = useModalState();
34+
const [redirectModalProps, setRedirectModalOpen, redirectRenderModal] = useModalState();
35+
36+
const freePlan = planResult?.freePlan ?? undefined;
37+
const rewardedDrive = freePlan ? freePlan.MaxDriveSpace >= freePlan.MaxDriveRewardSpace : false;
38+
const rewardedMail = freePlan ? freePlan.MaxBaseSpace >= freePlan.MaxBaseRewardSpace : false;
39+
40+
const [config, setConfig] = useState<ReturnType<typeof getReminderPageConfig> | null>(
41+
getReminderPageConfig(rewardedDrive, rewardedMail, subscription, plan)
42+
);
43+
44+
useEffect(() => {
45+
const config = getReminderPageConfig(rewardedDrive, rewardedMail, subscription, plan);
46+
setConfig(config);
47+
}, [hasAccess]);
48+
49+
useAppTitle(c('Subscription reminder').t`Cancel subscription`);
50+
51+
if (!hasAccess || !config) {
52+
redirectToDashboard();
53+
return;
54+
}
55+
56+
const handleCancelSubscription = async () => {
57+
setCancelModalOpen(false);
58+
// We inform that the cancellation process was started to avoid redirection once finished
59+
setStartedCancellation(true);
60+
61+
const result = await cancelSubscription(true);
62+
if (result.status !== 'downgraded') {
63+
setStartedCancellation(false);
64+
return;
65+
}
66+
67+
setRedirectModalOpen(true);
68+
};
69+
70+
return (
71+
<>
72+
<div className="overflow-auto">
73+
<div className="container-section-sticky">
74+
<ReminderSectionPlan {...config.reminder} />
75+
<ReminderSectionTestimonials {...config.testimonials} />
76+
<ReminderSectionFeatures {...config.features} />
77+
<ReminderSectionStorage {...config.storage} />
78+
<SettingsSection className="container-section-sticky-section flex flex-column-reverse lg:flex-row gap-6 lg:gap-0 items-start justify-space-between">
79+
<Button
80+
shape="underline"
81+
className="color-weak"
82+
onClick={() => {
83+
setCancelModalOpen(true);
84+
}}
85+
>{c('Subscription reminder').t`I still want to cancel`}</Button>
86+
<div className="flex gap-2">
87+
<ButtonLike
88+
as="a"
89+
href={getStaticURL('/support')}
90+
target="_blank"
91+
shape="outline"
92+
disabled={loadingCancelSubscription}
93+
>{c('Subscription reminder').t`Have a question?`}</ButtonLike>
94+
<ButtonLike
95+
as={SettingsLink}
96+
path="/dashboard"
97+
shape="solid"
98+
color="norm"
99+
className="flex flex-nowrap items-center justify-center"
100+
>
101+
<Icon name="brand-proton-mail-filled-plus" size={5} className="mr-1" />
102+
{config.planCta}
103+
</ButtonLike>
104+
</div>
105+
</SettingsSection>
106+
</div>
107+
</div>
108+
{cancelRenderModal && (
109+
<CancelConfirmationModal
110+
{...cancelModalProps}
111+
ctaText={config.planCta}
112+
features={config.features.features}
113+
cancelSubscription={handleCancelSubscription}
114+
{...config.confirmationModal}
115+
/>
116+
)}
117+
{redirectRenderModal && (
118+
<CancelRedirectionModal {...redirectModalProps} text={config.redirectModal} plan={config.plan} />
119+
)}
120+
121+
{cancelSubscriptionModals}
122+
</>
123+
);
124+
};
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { Icon, StripedItem, StripedList } from '@proton/components/components';
2+
import { SettingsSection, SettingsSectionTitle } from '@proton/components/containers/account';
3+
4+
import { PlanConfigFeatures } from './interface';
5+
6+
const ReminderSectionFeatures = ({ title, features }: PlanConfigFeatures) => {
7+
return (
8+
<SettingsSection className="container-section-sticky-section">
9+
<SettingsSectionTitle>{title}</SettingsSectionTitle>
10+
<StripedList className="lg:w-2/3" alternate="odd">
11+
{features.map(({ icon, text }) => (
12+
<StripedItem key={text} left={<Icon name={icon} className="color-primary" />}>
13+
{text}
14+
</StripedItem>
15+
))}
16+
</StripedList>
17+
</SettingsSection>
18+
);
19+
};
20+
21+
export default ReminderSectionFeatures;

0 commit comments

Comments
 (0)