1 import type { ReactNode } from 'react';
2 import { useEffect, useRef, useState } from 'react';
4 import { c } from 'ttag';
6 import { Button } from '@proton/atoms';
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';
43 } from '@proton/shared/lib/constants';
44 import { replaceUrl } from '@proton/shared/lib/helpers/browser';
46 getHas2023OfferCoupon,
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';
67 redirect?: string | undefined;
69 searchParams: URLSearchParams;
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);
99 async function run() {
100 const status = await paymentsApi.statusExtendedAutomatic();
110 loadingSubscription ||
112 loadingOrganization ||
118 // Error in usage (this action is not meant to be shown if it cannot be triggered, so untranslated.
121 <LiteBox>Please contact the administrator of the organization to manage the subscription</LiteBox>,
123 className: 'flex justify-center items-center',
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') || '';
145 maybeType === 'upgrade'
146 ? getUpgradedPlan(subscription, app)
147 : (plans.find(({ Name, Type }) => Name === maybePlanName && Type === PLAN_TYPES.PLAN)?.Name as
151 const { bgClassName, logo } = (() => {
152 if ([PLANS.VPN, PLANS.VPN2024].includes(plan as any)) {
154 bgClassName: 'subscribe-account--vpn-bg',
155 logo: <Logo className="subscribe-account-logo" appName={APPS.PROTONVPN_SETTINGS} />,
159 if ([PLANS.DRIVE, PLANS.DRIVE_PRO].includes(plan as any)) {
161 bgClassName: 'subscribe-account--drive-bg',
162 logo: <Logo className="subscribe-account-logo" appName={APPS.PROTONDRIVE} />,
166 if ([PLANS.PASS, PLANS.PASS_PRO, PLANS.PASS_BUSINESS].includes(plan as any)) {
168 bgClassName: 'subscribe-account--pass-bg',
169 logo: <Logo className="subscribe-account-logo" appName={APPS.PROTONPASS} />,
173 if ([PLANS.MAIL, PLANS.MAIL_PRO].includes(plan as any)) {
175 bgClassName: 'subscribe-account--mail-bg',
176 logo: <Logo className="subscribe-account-logo" appName={APPS.PROTONMAIL} />,
181 bgClassName: 'subscribe-account--mail-bg',
184 <ProtonLogo color="brand" className="block sm:hidden" />
185 <ProtonLogo color="invert" className="hidden sm:block" />
191 const step = (() => {
192 if (maybeStart === 'compare') {
193 return SUBSCRIPTION_STEPS.PLAN_SELECTION;
195 if (maybeStart === 'checkout') {
196 return SUBSCRIPTION_STEPS.CHECKOUT;
198 if (maybeType === 'upgrade' && plan) {
199 return SUBSCRIPTION_STEPS.PLAN_SELECTION;
201 return user.isFree ? SUBSCRIPTION_STEPS.PLAN_SELECTION : SUBSCRIPTION_STEPS.CHECKOUT;
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) {
213 onceCloseRef.current = true;
215 replaceUrl(redirect);
218 broadcast({ type: MessageType.CLOSE });
221 const handleClose = () => {
222 handleNotify(SubscribeType.Closed);
225 const handleSuccess = () => {
226 handleNotify(SubscribeType.Subscribed);
229 const bf2023IsExpired = coupon?.toLocaleUpperCase() === COUPON_CODES.BLACK_FRIDAY_2023;
230 if (bf2023IsExpired) {
231 return <PromotionExpired />;
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 />;
262 if (isManagedExternally(subscription)) {
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} />
272 if (error.title && error.message) {
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>}
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">
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' }}
293 <div className="flex justify-center">
294 {type === SubscribeType.Subscribed || type === SubscribeType.Closed ? (
296 <SubscribeAccountDone type={type} />
299 <SubscriptionContainer
302 subscription={subscription}
305 organization={organization}
308 minimumCycle={parsedMinimumCycle}
309 currency={parsedCurrency}
312 disablePlanSelection={Boolean(disablePlanSelectionParam)}
313 disableCycleSelector={Boolean(disableCycleSelectorParam)}
315 onSubscribed={handleSuccess}
316 onUnsubscribed={handleSuccess}
317 onCancel={handleClose}
318 paymentsStatus={paymentsStatus}
320 // If the initial check completes, it's handled by the container itself
321 if (data.model.initialCheckComplete) {
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.`,
332 if (data.type === 'success') {
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)
340 setError(offerUnavailableError);
345 if (data.type === 'error') {
346 const message = getApiErrorMessage(data.error);
349 title: c('bf2023: Title').t`Offer unavailable`,
350 message: message || 'Unknown error',
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) {
358 ...offerUnavailableError,
359 error: defaultError.message,
363 setError(defaultError);
367 source: 'lite-subscribe',
369 render={({ onSubmit, title, content, footer, step }) => {
371 <LiteBox maxWidth={step === SUBSCRIPTION_STEPS.PLAN_SELECTION ? 72 : undefined}>
373 className="flex flex-nowrap shrink-0 items-start justify-space-between"
374 data-testid="lite:account-header"
379 <h1 className="text-bold text-4xl">{title}</h1>
381 className="color-weak text-break"
382 data-testid="lite:account-info"
390 <Tooltip title={c('Action').t`Close`}>
395 onClick={handleClose}
398 className="modal-close-icon"
400 alt={c('Action').t`Close`}
406 <form onSubmit={onSubmit}>
408 {footer && <div className="mt-8">{footer}</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">
425 title: MAIL_APP_NAME,
426 logo: <MailLogo variant="glyph-only" size={5} />,
429 title: CALENDAR_APP_NAME,
430 logo: <CalendarLogo variant="glyph-only" size={5} />,
433 title: DRIVE_APP_NAME,
434 logo: <DriveLogo variant="glyph-only" size={5} />,
438 logo: <VpnLogo variant="glyph-only" size={5} />,
441 title: PASS_APP_NAME,
442 logo: <PassLogo variant="glyph-only" size={5} />,
444 ].map(({ title, logo }) => {
446 <div key={title} className="" title={title}>
453 <div className="mb-6 color-invert opacity-70">
455 // translator: full sentence 'Proton. Privacy by default.'
456 c('Footer').t`${BRAND_NAME}. Privacy by default.`
467 const SubscribeAccountWithProviders = (props: Props) => {
469 <PaymentSwitcher loader={props.loader}>
470 <SubscribeAccount {...props} />
475 export default SubscribeAccountWithProviders;