Remove payments components
[ProtonMail-WebClient.git] / applications / account / src / lite / actions / SubscribeAccount.tsx
blob7bb3f6abaa4644df1e248d39849060c8bb8be48f
1 import type { ReactNode } from 'react';
2 import { useEffect, useRef, useState } from 'react';
4 import { c } from 'ttag';
6 import { Button } from '@proton/atoms';
7 import {
8     CalendarLogo,
9     DriveLogo,
10     Icon,
11     Logo,
12     MailLogo,
13     PassLogo,
14     ProtonLogo,
15     SUBSCRIPTION_STEPS,
16     Tooltip,
17     VpnLogo,
18     useOrganization,
19     usePlans,
20     useSubscription,
21     useUser,
22 } from '@proton/components';
23 import PaymentSwitcher from '@proton/components/containers/payments/PaymentSwitcher';
24 import { InAppText } from '@proton/components/containers/payments/subscription/InAppPurchaseModal';
25 import SubscriptionContainer from '@proton/components/containers/payments/subscription/SubscriptionContainer';
26 import { usePaymentsApi } from '@proton/components/payments/react-extensions/usePaymentsApi';
27 import { type PaymentMethodStatusExtended } from '@proton/payments';
28 import { getApiError, getApiErrorMessage } from '@proton/shared/lib/api/helpers/apiErrorHelper';
29 import type { ProductParam } from '@proton/shared/lib/apps/product';
30 import {
31     APPS,
32     BRAND_NAME,
33     CALENDAR_APP_NAME,
34     COUPON_CODES,
35     CURRENCIES,
36     DRIVE_APP_NAME,
37     HTTP_STATUS_CODE,
38     MAIL_APP_NAME,
39     PASS_APP_NAME,
40     PLANS,
41     PLAN_TYPES,
42     VPN_APP_NAME,
43 } from '@proton/shared/lib/constants';
44 import { replaceUrl } from '@proton/shared/lib/helpers/browser';
45 import {
46     getHas2023OfferCoupon,
47     getPlan,
48     getUpgradedPlan,
49     getValidCycle,
50     isManagedExternally,
51 } from '@proton/shared/lib/helpers/subscription';
52 import type { Currency } from '@proton/shared/lib/interfaces';
53 import { FREE_PLAN } from '@proton/shared/lib/subscription/freePlans';
54 import { canPay } from '@proton/shared/lib/user/helpers';
55 import clsx from '@proton/utils/clsx';
57 import broadcast, { MessageType } from '../broadcast';
58 import LiteBox from '../components/LiteBox';
59 import PromotionAlreadyApplied from '../components/PromotionAlreadyApplied';
60 import PromotionExpired from '../components/PromotionExpired';
61 import SubscribeAccountDone from '../components/SubscribeAccountDone';
62 import { SubscribeType } from '../types/SubscribeType';
64 import './SubscribeAccount.scss';
66 interface Props {
67     redirect?: string | undefined;
68     fullscreen?: boolean;
69     searchParams: URLSearchParams;
70     app: ProductParam;
71     loader: ReactNode;
72     layout: (children: ReactNode, props: any) => ReactNode;
75 const plusPlans = [PLANS.VPN, PLANS.MAIL, PLANS.DRIVE, PLANS.PASS, PLANS.VPN_PASS_BUNDLE];
77 const SubscribeAccount = ({ app, redirect, searchParams, loader, layout }: Props) => {
78     const onceCloseRef = useRef(false);
79     const topRef = useRef<HTMLDivElement>(null);
80     const [user] = useUser();
82     const { Email, DisplayName, Name } = user;
83     const nameToDisplay = Email || DisplayName || Name;
85     const [type, setType] = useState<SubscribeType | undefined>(undefined);
87     const [subscription, loadingSubscription] = useSubscription();
88     const [plansResult, loadingPlans] = usePlans();
89     const plans = plansResult?.plans || [];
90     const freePlan = plansResult?.freePlan || FREE_PLAN;
91     const [organization, loadingOrganization] = useOrganization();
92     const [error, setError] = useState({ title: '', message: '', error: '' });
93     const [paymentsStatus, setStatus] = useState<PaymentMethodStatusExtended>();
94     const { paymentsApi } = usePaymentsApi();
96     const canEdit = canPay(user);
98     useEffect(() => {
99         async function run() {
100             const status = await paymentsApi.statusExtendedAutomatic();
101             setStatus(status);
102         }
104         void run();
105     }, []);
107     if (
108         !organization ||
109         !subscription ||
110         loadingSubscription ||
111         loadingPlans ||
112         loadingOrganization ||
113         !paymentsStatus
114     ) {
115         return loader;
116     }
118     // Error in usage (this action is not meant to be shown if it cannot be triggered, so untranslated.
119     if (!canEdit) {
120         return layout(
121             <LiteBox>Please contact the administrator of the organization to manage the subscription</LiteBox>,
122             {
123                 className: 'flex justify-center items-center',
124             }
125         );
126     }
128     const maybeStart = searchParams.get('start');
129     const maybeType = searchParams.get('type');
131     const cycleParam = parseInt(searchParams.get('cycle') as any, 10);
132     const parsedCycle = cycleParam ? getValidCycle(cycleParam) : undefined;
134     const minimumCycleParam = parseInt(searchParams.get('minimumCycle') as any, 10);
135     const parsedMinimumCycle = cycleParam ? getValidCycle(minimumCycleParam) : undefined;
137     const coupon = searchParams.get('coupon') || undefined;
139     const currencyParam = searchParams.get('currency')?.toUpperCase();
140     const parsedCurrency =
141         currencyParam && CURRENCIES.includes(currencyParam as any) ? (currencyParam as Currency) : undefined;
143     const maybePlanName = searchParams.get('plan') || '';
144     const plan =
145         maybeType === 'upgrade'
146             ? getUpgradedPlan(subscription, app)
147             : (plans.find(({ Name, Type }) => Name === maybePlanName && Type === PLAN_TYPES.PLAN)?.Name as
148                   | PLANS
149                   | undefined);
151     const { bgClassName, logo } = (() => {
152         if ([PLANS.VPN, PLANS.VPN2024].includes(plan as any)) {
153             return {
154                 bgClassName: 'subscribe-account--vpn-bg',
155                 logo: <Logo className="subscribe-account-logo" appName={APPS.PROTONVPN_SETTINGS} />,
156             };
157         }
159         if ([PLANS.DRIVE, PLANS.DRIVE_PRO].includes(plan as any)) {
160             return {
161                 bgClassName: 'subscribe-account--drive-bg',
162                 logo: <Logo className="subscribe-account-logo" appName={APPS.PROTONDRIVE} />,
163             };
164         }
166         if ([PLANS.PASS, PLANS.PASS_PRO, PLANS.PASS_BUSINESS].includes(plan as any)) {
167             return {
168                 bgClassName: 'subscribe-account--pass-bg',
169                 logo: <Logo className="subscribe-account-logo" appName={APPS.PROTONPASS} />,
170             };
171         }
173         if ([PLANS.MAIL, PLANS.MAIL_PRO].includes(plan as any)) {
174             return {
175                 bgClassName: 'subscribe-account--mail-bg',
176                 logo: <Logo className="subscribe-account-logo" appName={APPS.PROTONMAIL} />,
177             };
178         }
180         return {
181             bgClassName: 'subscribe-account--mail-bg',
182             logo: (
183                 <>
184                     <ProtonLogo color="brand" className="block sm:hidden" />
185                     <ProtonLogo color="invert" className="hidden sm:block" />
186                 </>
187             ),
188         };
189     })();
191     const step = (() => {
192         if (maybeStart === 'compare') {
193             return SUBSCRIPTION_STEPS.PLAN_SELECTION;
194         }
195         if (maybeStart === 'checkout') {
196             return SUBSCRIPTION_STEPS.CHECKOUT;
197         }
198         if (maybeType === 'upgrade' && plan) {
199             return SUBSCRIPTION_STEPS.PLAN_SELECTION;
200         }
201         return user.isFree ? SUBSCRIPTION_STEPS.PLAN_SELECTION : SUBSCRIPTION_STEPS.CHECKOUT;
202     })();
204     const disableCycleSelectorParam = searchParams.get('disableCycleSelector');
205     const disablePlanSelectionParam = searchParams.get('disablePlanSelection');
206     const hideClose = Boolean(searchParams.get('hideClose'));
208     const handleNotify = (type: SubscribeType) => {
209         if (onceCloseRef.current) {
210             return;
211         }
212         setType(type);
213         onceCloseRef.current = true;
214         if (redirect) {
215             replaceUrl(redirect);
216             return;
217         }
218         broadcast({ type: MessageType.CLOSE });
219     };
221     const handleClose = () => {
222         handleNotify(SubscribeType.Closed);
223     };
225     const handleSuccess = () => {
226         handleNotify(SubscribeType.Subscribed);
227     };
229     const bf2023IsExpired = coupon?.toLocaleUpperCase() === COUPON_CODES.BLACK_FRIDAY_2023;
230     if (bf2023IsExpired) {
231         return <PromotionExpired />;
232     }
234     const activeSubscription = subscription?.UpcomingSubscription ?? subscription;
235     const activeSubscriptionPlan = getPlan(activeSubscription);
236     const activeSubscriptionSameCoupon = !!coupon && activeSubscription?.CouponCode === coupon;
237     const takingSameOffer =
238         !!activeSubscription &&
239         !!activeSubscriptionPlan &&
240         activeSubscriptionPlan.Name === plan &&
241         activeSubscription.Cycle === parsedCycle &&
242         activeSubscriptionSameCoupon;
244     const isOfferPlusPlan = !!maybePlanName && plusPlans.some((planName) => planName === plan);
245     const isOfferBundlePlan = !!maybePlanName && plan === PLANS.BUNDLE;
247     const isBundleDowngrade =
248         activeSubscriptionPlan?.Name === PLANS.BUNDLE && isOfferPlusPlan && activeSubscriptionSameCoupon;
250     const isFamilyDowngrade =
251         activeSubscriptionPlan?.Name === PLANS.FAMILY &&
252         (isOfferPlusPlan || isOfferBundlePlan) &&
253         activeSubscriptionSameCoupon;
255     const isVisionaryDowngrade =
256         activeSubscriptionPlan?.Name === PLANS.VISIONARY && !!maybePlanName && plan !== PLANS.VISIONARY;
258     if (takingSameOffer || isBundleDowngrade || isFamilyDowngrade || isVisionaryDowngrade) {
259         return <PromotionAlreadyApplied />;
260     }
262     if (isManagedExternally(subscription)) {
263         return (
264             <div className="h-full flex flex-column justify-center items-center bg-norm text-center">
265                 <div className="max-w-custom p-11" style={{ '--max-w-custom': '33.3rem' }}>
266                     <InAppText subscription={subscription} />
267                 </div>
268             </div>
269         );
270     }
272     if (error.title && error.message) {
273         return (
274             <div className="h-full flex flex-column justify-center items-center bg-norm text-center">
275                 <h1 className="text-bold text-2xl mb-2">{error.title}</h1>
276                 <div>{error.message}</div>
277                 {error.error && <div className="mt-2 color-weak text-sm">{error.error}</div>}
278             </div>
279         );
280     }
282     return (
283         <div className={clsx(bgClassName, 'h-full overflow-auto')} data-testid="lite:subscribe-account">
284             <div className="min-h-custom flex flex-column flex-nowrap" style={{ '--min-h-custom': '100vh' }}>
285                 <div className="flex-auto">
286                     <div
287                         className={clsx('mb-0 sm:mb-4 pb-0 p-4 sm:pb-6 sm:p-6 m-auto max-w-custom')}
288                         style={{ '--max-w-custom': '74rem' }}
289                         ref={topRef}
290                     >
291                         {logo}
292                     </div>
293                     <div className="flex justify-center">
294                         {type === SubscribeType.Subscribed || type === SubscribeType.Closed ? (
295                             <LiteBox>
296                                 <SubscribeAccountDone type={type} />
297                             </LiteBox>
298                         ) : (
299                             <SubscriptionContainer
300                                 topRef={topRef}
301                                 app={app}
302                                 subscription={subscription}
303                                 plans={plans}
304                                 freePlan={freePlan}
305                                 organization={organization}
306                                 step={step}
307                                 cycle={parsedCycle}
308                                 minimumCycle={parsedMinimumCycle}
309                                 currency={parsedCurrency}
310                                 plan={plan}
311                                 coupon={coupon}
312                                 disablePlanSelection={Boolean(disablePlanSelectionParam)}
313                                 disableCycleSelector={Boolean(disableCycleSelectorParam)}
314                                 disableThanksStep
315                                 onSubscribed={handleSuccess}
316                                 onUnsubscribed={handleSuccess}
317                                 onCancel={handleClose}
318                                 paymentsStatus={paymentsStatus}
319                                 onCheck={(data) => {
320                                     // If the initial check completes, it's handled by the container itself
321                                     if (data.model.initialCheckComplete) {
322                                         return;
323                                     }
325                                     const offerUnavailableError = {
326                                         title: c('bf2023: Title').t`Offer unavailable`,
327                                         message: c('bf2023: info')
328                                             .t`Sorry, this offer is not available with your current plan.`,
329                                         error: '',
330                                     };
332                                     if (data.type === 'success') {
333                                         if (
334                                             // Ignore visionary since it doesn't require a BF coupon
335                                             !data.model.planIDs[PLANS.VISIONARY] &&
336                                             // Tried to apply the BF coupon, but the API responded without it.
337                                             getHas2023OfferCoupon(coupon?.toUpperCase()) &&
338                                             !getHas2023OfferCoupon(data.result.Coupon?.Code)
339                                         ) {
340                                             setError(offerUnavailableError);
341                                         }
342                                         return;
343                                     }
345                                     if (data.type === 'error') {
346                                         const message = getApiErrorMessage(data.error);
348                                         let defaultError = {
349                                             title: c('bf2023: Title').t`Offer unavailable`,
350                                             message: message || 'Unknown error',
351                                             error: '',
352                                         };
354                                         const { status } = getApiError(data.error);
355                                         // Getting a 400 means the user's current subscription is not compatible with the new plan, so we assume it's an offer
356                                         if (status === HTTP_STATUS_CODE.BAD_REQUEST) {
357                                             defaultError = {
358                                                 ...offerUnavailableError,
359                                                 error: defaultError.message,
360                                             };
361                                         }
363                                         setError(defaultError);
364                                     }
365                                 }}
366                                 metrics={{
367                                     source: 'lite-subscribe',
368                                 }}
369                                 render={({ onSubmit, title, content, footer, step }) => {
370                                     return (
371                                         <LiteBox maxWidth={step === SUBSCRIPTION_STEPS.PLAN_SELECTION ? 72 : undefined}>
372                                             <div
373                                                 className="flex flex-nowrap shrink-0 items-start justify-space-between"
374                                                 data-testid="lite:account-header"
375                                             >
376                                                 <div>
377                                                     {title && (
378                                                         <>
379                                                             <h1 className="text-bold text-4xl">{title}</h1>
380                                                             <div
381                                                                 className="color-weak text-break"
382                                                                 data-testid="lite:account-info"
383                                                             >
384                                                                 {nameToDisplay}
385                                                             </div>
386                                                         </>
387                                                     )}
388                                                 </div>
389                                                 {!hideClose && (
390                                                     <Tooltip title={c('Action').t`Close`}>
391                                                         <Button
392                                                             className="shrink-0"
393                                                             icon
394                                                             shape="ghost"
395                                                             onClick={handleClose}
396                                                         >
397                                                             <Icon
398                                                                 className="modal-close-icon"
399                                                                 name="cross-big"
400                                                                 alt={c('Action').t`Close`}
401                                                             />
402                                                         </Button>
403                                                     </Tooltip>
404                                                 )}
405                                             </div>
406                                             <form onSubmit={onSubmit}>
407                                                 <div>{content}</div>
408                                                 {footer && <div className="mt-8">{footer}</div>}
409                                             </form>
410                                         </LiteBox>
411                                     );
412                                 }}
413                             />
414                         )}
415                     </div>
416                 </div>
418                 <div className="my-8 hidden sm:block">
419                     <div className="px-4 pt-4 sm:pt-12 pb-4 m-auto max-w-custom" style={{ '--max-w-custom': '52rem' }}>
420                         <footer className="text-sm">
421                             <div className="mb-1">
422                                 <div className="flex gap-1">
423                                     {[
424                                         {
425                                             title: MAIL_APP_NAME,
426                                             logo: <MailLogo variant="glyph-only" size={5} />,
427                                         },
428                                         {
429                                             title: CALENDAR_APP_NAME,
430                                             logo: <CalendarLogo variant="glyph-only" size={5} />,
431                                         },
432                                         {
433                                             title: DRIVE_APP_NAME,
434                                             logo: <DriveLogo variant="glyph-only" size={5} />,
435                                         },
436                                         {
437                                             title: VPN_APP_NAME,
438                                             logo: <VpnLogo variant="glyph-only" size={5} />,
439                                         },
440                                         {
441                                             title: PASS_APP_NAME,
442                                             logo: <PassLogo variant="glyph-only" size={5} />,
443                                         },
444                                     ].map(({ title, logo }) => {
445                                         return (
446                                             <div key={title} className="" title={title}>
447                                                 {logo}
448                                             </div>
449                                         );
450                                     })}
451                                 </div>
452                             </div>
453                             <div className="mb-6 color-invert opacity-70">
454                                 {
455                                     // translator: full sentence 'Proton. Privacy by default.'
456                                     c('Footer').t`${BRAND_NAME}. Privacy by default.`
457                                 }
458                             </div>
459                         </footer>
460                     </div>
461                 </div>
462             </div>
463         </div>
464     );
467 const SubscribeAccountWithProviders = (props: Props) => {
468     return (
469         <PaymentSwitcher loader={props.loader}>
470             <SubscribeAccount {...props} />
471         </PaymentSwitcher>
472     );
475 export default SubscribeAccountWithProviders;