Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/ProtonMail/WebClients.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRichard <richard@protonmail.com>2022-09-07 16:53:07 +0300
committerRichard <richard@protonmail.com>2022-09-28 11:01:54 +0300
commitd08ede1944f50d8a463afdfb9ed1b866d38dba1a (patch)
tree176a7ef34f573137122e6dc185db88150103bab1
parenta1cc98a48f144a6ed0b217625365a50e715c5000 (diff)
Add new offer structureproton-mail@5.0.9.4
-rw-r--r--applications/mail/src/app/components/header/MailHeader.test.tsx8
-rw-r--r--packages/components/components/topnavbar/TopNavbarOffer.tsx43
-rw-r--r--packages/components/components/topnavbar/TopNavbarUpgradeButton.tsx44
-rw-r--r--packages/components/components/topnavbar/TopNavbarUpsell.tsx16
-rw-r--r--packages/components/components/topnavbar/index.ts1
-rw-r--r--packages/components/containers/features/FeaturesContext.ts3
-rw-r--r--packages/components/containers/heading/PrivateHeader.tsx39
-rw-r--r--packages/components/containers/heading/TopNavbarListItemBlackFridayButton.tsx57
-rw-r--r--packages/components/containers/heading/usePromotionOffer.tsx90
-rw-r--r--packages/components/containers/index.ts1
-rw-r--r--packages/components/containers/offers/Offer.scss92
-rw-r--r--packages/components/containers/offers/README.md41
-rw-r--r--packages/components/containers/offers/components/OfferCountdown.tsx46
-rw-r--r--packages/components/containers/offers/components/OfferDisableButton.tsx27
-rw-r--r--packages/components/containers/offers/components/OfferFooter.tsx40
-rw-r--r--packages/components/containers/offers/components/OfferHeader.tsx19
-rw-r--r--packages/components/containers/offers/components/OfferModal.tsx58
-rw-r--r--packages/components/containers/offers/components/ProtonLogos.tsx12
-rw-r--r--packages/components/containers/offers/components/deal/Deal.helpers.tsx33
-rw-r--r--packages/components/containers/offers/components/deal/Deal.tsx55
-rw-r--r--packages/components/containers/offers/components/deal/DealCTA.tsx26
-rw-r--r--packages/components/containers/offers/components/deal/DealContext.tsx27
-rw-r--r--packages/components/containers/offers/components/deal/DealFeatures.tsx31
-rw-r--r--packages/components/containers/offers/components/deal/DealPrice.tsx28
-rw-r--r--packages/components/containers/offers/components/deal/DealPriceInfos.tsx37
-rw-r--r--packages/components/containers/offers/components/deal/DealTitle.tsx19
-rw-r--r--packages/components/containers/offers/components/deal/Deals.tsx27
-rw-r--r--packages/components/containers/offers/helpers/getDealPrices.ts37
-rw-r--r--packages/components/containers/offers/helpers/getOfferRedirectionParams.ts29
-rw-r--r--packages/components/containers/offers/hooks/useFetchOffer.ts54
-rw-r--r--packages/components/containers/offers/hooks/useOfferConfig.ts34
-rw-r--r--packages/components/containers/offers/hooks/useOfferFlags.ts44
-rw-r--r--packages/components/containers/offers/hooks/useOnSelectDeal.ts24
-rw-r--r--packages/components/containers/offers/hooks/useVisitedOffer.ts19
-rw-r--r--packages/components/containers/offers/index.ts2
-rw-r--r--packages/components/containers/offers/interface.ts71
-rw-r--r--packages/components/containers/offers/operations/goUnlimited2022/Layout.tsx26
-rw-r--r--packages/components/containers/offers/operations/goUnlimited2022/configuration.ts28
-rw-r--r--packages/components/containers/offers/operations/goUnlimited2022/index.ts2
-rw-r--r--packages/components/containers/offers/operations/goUnlimited2022/useOffer.ts37
-rw-r--r--packages/components/containers/offers/operations/specialOffer2022/Layout.tsx26
-rw-r--r--packages/components/containers/offers/operations/specialOffer2022/configuration.ts25
-rw-r--r--packages/components/containers/offers/operations/specialOffer2022/index.ts2
-rw-r--r--packages/components/containers/offers/operations/specialOffer2022/useOffer.tsx32
-rw-r--r--packages/components/containers/payments/DiscountBadge.tsx3
-rw-r--r--packages/components/containers/payments/subscription/BlackFridayModal.scss193
-rw-r--r--packages/components/containers/payments/subscription/BlackFridayModal.tsx364
-rw-r--r--packages/components/containers/payments/subscription/index.ts1
-rw-r--r--packages/components/hooks/index.ts3
-rw-r--r--packages/components/hooks/useBlackFridayPeriod.ts23
-rw-r--r--packages/components/hooks/useLastSubscriptionEnd.ts40
-rw-r--r--packages/components/hooks/useProductPayerPeriod.ts23
-rw-r--r--packages/shared/lib/constants.ts3
-rw-r--r--packages/shared/lib/helpers/blackfriday.ts25
-rw-r--r--packages/shared/lib/helpers/subscription.ts8
-rw-r--r--packages/shared/lib/interfaces/Subscription.ts8
56 files changed, 1283 insertions, 823 deletions
diff --git a/applications/mail/src/app/components/header/MailHeader.test.tsx b/applications/mail/src/app/components/header/MailHeader.test.tsx
index 156c75b3be..ccde8fc41b 100644
--- a/applications/mail/src/app/components/header/MailHeader.test.tsx
+++ b/applications/mail/src/app/components/header/MailHeader.test.tsx
@@ -148,14 +148,6 @@ describe('MailHeader', () => {
assertAppLink(upgradeLabel, '/mail/upgrade?ref=upsell_mail-button-1');
});
-
- it('should show upgrade button', async () => {
- const { getByText } = await setup();
-
- const upgradeLabel = getByText('Upgrade');
-
- assertAppLink(upgradeLabel, '/mail/upgrade?ref=upsell_mail-button-1');
- });
});
describe('Search features', () => {
diff --git a/packages/components/components/topnavbar/TopNavbarOffer.tsx b/packages/components/components/topnavbar/TopNavbarOffer.tsx
new file mode 100644
index 0000000000..eb98d2dd30
--- /dev/null
+++ b/packages/components/components/topnavbar/TopNavbarOffer.tsx
@@ -0,0 +1,43 @@
+import { useEffect } from 'react';
+
+import { c } from 'ttag';
+
+import { Icon, useModalState } from '@proton/components';
+import useOfferFlags from '@proton/components/containers/offers/hooks/useOfferFlags';
+import { OfferConfig } from '@proton/components/containers/offers/interface';
+
+import { OfferModal } from '../../containers';
+import TopNavbarListItem from './TopNavbarListItem';
+import TopNavbarListItemButton from './TopNavbarListItemButton';
+
+interface Props {
+ offerConfig: OfferConfig;
+}
+
+const TopNavbarOffer = ({ offerConfig }: Props) => {
+ const [offerModalProps, setOfferModalOpen, renderOfferModal] = useModalState();
+ const { isVisited, loading } = useOfferFlags(offerConfig);
+
+ useEffect(() => {
+ if (!loading && offerConfig.autoPopUp && !isVisited) {
+ setOfferModalOpen(true);
+ }
+ }, [loading]);
+
+ return (
+ <>
+ <TopNavbarListItem noShrink>
+ <TopNavbarListItemButton
+ as="button"
+ type="button"
+ icon={<Icon name="bag-percent" />}
+ text={offerConfig.getCTAContent?.() || c('specialoffer: Action').t`Special Offer`}
+ onClick={() => setOfferModalOpen(true)}
+ />
+ </TopNavbarListItem>
+ {renderOfferModal && <OfferModal offerConfig={offerConfig} modalProps={offerModalProps} />}
+ </>
+ );
+};
+
+export default TopNavbarOffer;
diff --git a/packages/components/components/topnavbar/TopNavbarUpgradeButton.tsx b/packages/components/components/topnavbar/TopNavbarUpgradeButton.tsx
new file mode 100644
index 0000000000..43a2d9e688
--- /dev/null
+++ b/packages/components/components/topnavbar/TopNavbarUpgradeButton.tsx
@@ -0,0 +1,44 @@
+import { useLocation } from 'react-router-dom';
+
+import { c } from 'ttag';
+
+import { Icon, SettingsLink, useConfig, useSubscription, useUser } from '@proton/components';
+import { APPS, APPS_CONFIGURATION } from '@proton/shared/lib/constants';
+import { isTrial } from '@proton/shared/lib/helpers/subscription';
+
+import TopNavbarListItem from './TopNavbarListItem';
+import TopNavbarListItemButton from './TopNavbarListItemButton';
+
+const TopNavbarUpgradeButton = () => {
+ const [user] = useUser();
+ const [subscription] = useSubscription();
+ const location = useLocation();
+ const { APP_NAME } = useConfig();
+
+ const isVPN = APP_NAME === APPS.PROTONVPN_SETTINGS;
+ const upgradePathname = isVPN ? '/dashboard' : '/upgrade';
+ const appDomain = isVPN ? 'vpn-settings' : APPS_CONFIGURATION[APP_NAME].subdomain;
+ // We want to have metrics from where the user has clicked on the upgrade button
+ const upgradeUrl = `${upgradePathname}?ref=upsell_${appDomain}-button-1`;
+ const displayUpgradeButton = (user.isFree || isTrial(subscription)) && !location.pathname.endsWith(upgradePathname);
+
+ if (displayUpgradeButton) {
+ return (
+ <TopNavbarListItem noShrink collapsedOnDesktop={false}>
+ <TopNavbarListItemButton
+ as={SettingsLink}
+ shape="outline"
+ color="norm"
+ text={c('specialoffer: Link').t`Upgrade`}
+ icon={<Icon name="arrow-up-big-line" />}
+ path={upgradeUrl}
+ title={c('specialoffer: Link').t`Go to subscription plans`}
+ />
+ </TopNavbarListItem>
+ );
+ }
+
+ return null;
+};
+
+export default TopNavbarUpgradeButton;
diff --git a/packages/components/components/topnavbar/TopNavbarUpsell.tsx b/packages/components/components/topnavbar/TopNavbarUpsell.tsx
new file mode 100644
index 0000000000..aea9b8ee14
--- /dev/null
+++ b/packages/components/components/topnavbar/TopNavbarUpsell.tsx
@@ -0,0 +1,16 @@
+import { useOfferConfig } from '@proton/components';
+
+import TopNavbarOffer from './TopNavbarOffer';
+import TopNavbarUpgradeButton from './TopNavbarUpgradeButton';
+
+const TopNavbarUpsell = () => {
+ const offerConfig = useOfferConfig();
+
+ if (offerConfig) {
+ return <TopNavbarOffer offerConfig={offerConfig} />;
+ }
+
+ return <TopNavbarUpgradeButton />;
+};
+
+export default TopNavbarUpsell;
diff --git a/packages/components/components/topnavbar/index.ts b/packages/components/components/topnavbar/index.ts
index 69a3c94c63..1ec2da6c0b 100644
--- a/packages/components/components/topnavbar/index.ts
+++ b/packages/components/components/topnavbar/index.ts
@@ -3,3 +3,4 @@ export { default as TopNavbarList } from './TopNavbarList';
export { default as TopNavbarListItem } from './TopNavbarListItem';
export { default as TopNavbarListItemButton } from './TopNavbarListItemButton';
export { default as TopNavbarListItemSearchButton } from './TopNavbarListItemSearchButton';
+export { default as TopNavbarUpsell } from './TopNavbarUpsell';
diff --git a/packages/components/containers/features/FeaturesContext.ts b/packages/components/containers/features/FeaturesContext.ts
index dd08aaa780..42f822fae4 100644
--- a/packages/components/containers/features/FeaturesContext.ts
+++ b/packages/components/containers/features/FeaturesContext.ts
@@ -83,6 +83,9 @@ export enum FeatureCode {
SpotlightAutoAddedInvites = 'SpotlightAutoAddedInvites',
ContextFiltering = 'ContextFiltering',
EasySwitchGmailNewScope = 'EasySwitchGmailNewScope',
+ Offers = 'Offers',
+ OfferGoUnlimited2022 = 'OfferGoUnlimited2022',
+ OfferSpecialOffer2022 = 'OfferSpecialOffer2022',
TrustedDeviceRecovery = 'TrustedDeviceRecovery',
BulkUserUpload = 'BulkUserUpload',
DriveBeta = 'DriveBeta',
diff --git a/packages/components/containers/heading/PrivateHeader.tsx b/packages/components/containers/heading/PrivateHeader.tsx
index e8b1f160a7..752cdc2d8d 100644
--- a/packages/components/containers/heading/PrivateHeader.tsx
+++ b/packages/components/containers/heading/PrivateHeader.tsx
@@ -1,19 +1,14 @@
import { ReactNode } from 'react';
-import { useLocation } from 'react-router-dom';
import { c } from 'ttag';
import { Vr } from '@proton/atoms';
-import { APPS, APPS_CONFIGURATION } from '@proton/shared/lib/constants';
-import { AppLink, Hamburger, Icon, SettingsLink } from '../../components';
+import { AppLink, Hamburger, Icon } from '../../components';
import Header, { Props as HeaderProps } from '../../components/header/Header';
-import { TopNavbar, TopNavbarList, TopNavbarListItem } from '../../components/topnavbar';
+import { TopNavbar, TopNavbarList, TopNavbarListItem, TopNavbarUpsell } from '../../components/topnavbar';
import TopNavbarListItemButton from '../../components/topnavbar/TopNavbarListItemButton';
-import { useConfig, useUser } from '../../hooks';
import { AppsDropdown } from '../app';
-import TopNavbarListItemBlackFridayButton from './TopNavbarListItemBlackFridayButton';
-import usePromotionOffer from './usePromotionOffer';
interface Props extends HeaderProps {
logo?: ReactNode;
@@ -48,11 +43,6 @@ const PrivateHeader = ({
onToggleExpand,
title,
}: Props) => {
- const [user] = useUser();
- const { APP_NAME } = useConfig();
- const offer = usePromotionOffer();
- const location = useLocation();
-
if (backUrl) {
return (
<Header>
@@ -72,12 +62,6 @@ const PrivateHeader = ({
);
}
- const isVPN = APP_NAME === APPS.PROTONVPN_SETTINGS;
- const upgradePathname = isVPN ? '/dashboard' : '/upgrade';
- const appDomain = isVPN ? 'vpn-settings' : APPS_CONFIGURATION[APP_NAME].subdomain;
- // We want to have metrics from where the user has clicked on the upgrade button
- const upgradeUrl = `${upgradePathname}?ref=upsell_${appDomain}-button-1`;
-
return (
<Header>
<div className="logo-container flex flex-justify-space-between flex-align-items-center flex-nowrap no-mobile">
@@ -90,24 +74,7 @@ const PrivateHeader = ({
<TopNavbar>
<TopNavbarList>
{isNarrow && searchDropdown ? <TopNavbarListItem>{searchDropdown}</TopNavbarListItem> : null}
- {offer ? (
- <TopNavbarListItem noShrink>
- <TopNavbarListItemBlackFridayButton offer={offer} />
- </TopNavbarListItem>
- ) : null}
- {user.isFree && !location.pathname.endsWith(upgradePathname) && (
- <TopNavbarListItem noShrink collapsedOnDesktop={false}>
- <TopNavbarListItemButton
- as={SettingsLink}
- shape="outline"
- color="norm"
- text={c('Link').t`Upgrade`}
- icon={<Icon name="arrow-up-big-line" />}
- path={upgradeUrl}
- title={c('Link').t`Go to subscription plans`}
- />
- </TopNavbarListItem>
- )}
+ <TopNavbarUpsell />
{feedbackButton ? <TopNavbarListItem noShrink>{feedbackButton}</TopNavbarListItem> : null}
{contactsButton ? <TopNavbarListItem noShrink>{contactsButton}</TopNavbarListItem> : null}
{settingsButton ? <TopNavbarListItem noShrink>{settingsButton}</TopNavbarListItem> : null}
diff --git a/packages/components/containers/heading/TopNavbarListItemBlackFridayButton.tsx b/packages/components/containers/heading/TopNavbarListItemBlackFridayButton.tsx
deleted file mode 100644
index 354aee5ad7..0000000000
--- a/packages/components/containers/heading/TopNavbarListItemBlackFridayButton.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import { c } from 'ttag';
-
-import { APPS } from '@proton/shared/lib/constants';
-
-import { Icon, useSettingsLink } from '../../components';
-import TopNavbarListItemButton, {
- TopNavbarListItemButtonProps,
-} from '../../components/topnavbar/TopNavbarListItemButton';
-import { useConfig, useModals } from '../../hooks';
-import { BlackFridayModal } from '../payments';
-import { EligibleOffer } from '../payments/interface';
-
-interface Props extends Omit<TopNavbarListItemButtonProps<'button'>, 'icon' | 'text' | 'as'> {
- offer: EligibleOffer;
-}
-
-const TopNavbarListItemBlackFridayButton = ({ offer }: Props) => {
- const { APP_NAME } = useConfig();
- const { createModal } = useModals();
- const isVPN = APP_NAME === APPS.PROTONVPN_SETTINGS;
- const hasRedDot = isVPN || offer.name === 'black-friday';
- const text = c('blackfriday: VPNspecialoffer Promo title, need to be short').t`Special offer`;
- const settingsLink = useSettingsLink();
-
- return (
- <TopNavbarListItemButton
- as="button"
- type="button"
- title={text}
- hasRedDot={hasRedDot}
- icon={<Icon name="bag-percent" />}
- text={text}
- onClick={() => {
- createModal(
- <BlackFridayModal
- offer={offer}
- onSelect={({ offer, plan, cycle, currency, couponCode }) => {
- const params = new URLSearchParams();
- params.set('cycle', `${cycle}`);
- params.set('currency', currency);
- if (couponCode) {
- params.set('coupon', couponCode);
- }
- params.set('plan', plan);
- params.set('type', 'offer');
- params.set('edit', 'disable');
- params.set('offer', offer.name);
- settingsLink(`/dashboard?${params.toString()}`);
- }}
- />
- );
- }}
- />
- );
-};
-
-export default TopNavbarListItemBlackFridayButton;
diff --git a/packages/components/containers/heading/usePromotionOffer.tsx b/packages/components/containers/heading/usePromotionOffer.tsx
deleted file mode 100644
index 8b92a19f71..0000000000
--- a/packages/components/containers/heading/usePromotionOffer.tsx
+++ /dev/null
@@ -1,90 +0,0 @@
-import { useEffect, useState } from 'react';
-
-import useIsMounted from '@proton/hooks/useIsMounted';
-import { getLastCancelledSubscription } from '@proton/shared/lib/api/payments';
-import { BLACK_FRIDAY, CYCLE, PLANS } from '@proton/shared/lib/constants';
-import { toMap } from '@proton/shared/lib/helpers/object';
-import { LatestSubscription } from '@proton/shared/lib/interfaces';
-
-import { useApi, useBlackFridayPeriod, useLoading, usePlans, useSubscription, useUser } from '../../hooks';
-import { EligibleOffer } from '../payments/interface';
-import { getBlackFridayEligibility } from '../payments/subscription/helpers';
-
-const usePromotionOffer = (): EligibleOffer | undefined => {
- const api = useApi();
- const [{ isFree, isDelinquent, canPay }] = useUser();
- const [plans = [], loadingPlans] = usePlans();
- const [subscription, loadingSubscription] = useSubscription();
- const [latestSubscription, setLatestSubscription] = useState<LatestSubscription | undefined>(undefined);
- const isBlackFridayPeriod = useBlackFridayPeriod();
- const [loading, withLoading] = useLoading();
-
- const plansMap = toMap(plans, 'Name');
-
- const loadingDependencies = loading || loadingPlans || loadingSubscription;
-
- const hasBlackFridayOffer =
- !loadingDependencies &&
- !!plans.length &&
- !!subscription &&
- !!latestSubscription &&
- canPay &&
- !isDelinquent &&
- isBlackFridayPeriod &&
- getBlackFridayEligibility(subscription, latestSubscription);
-
- const blackFridayOffer: EligibleOffer | undefined = hasBlackFridayOffer
- ? {
- name: 'black-friday' as const,
- isVPNOnly: true,
- plans: [
- {
- name: '',
- cycle: CYCLE.TWO_YEARS,
- plan: PLANS.VPNPLUS,
- planIDs: { [plansMap.vpnplus.ID]: 1 },
- couponCode: BLACK_FRIDAY.COUPON_CODE,
- popular: true,
- },
- {
- name: '',
- cycle: CYCLE.YEARLY,
- plan: PLANS.VPNPLUS,
- planIDs: { [plansMap.vpnplus.ID]: 1 },
- couponCode: BLACK_FRIDAY.COUPON_CODE,
- },
- {
- name: '',
- cycle: CYCLE.MONTHLY,
- plan: PLANS.VPNPLUS,
- planIDs: { [plansMap.vpnplus.ID]: 1 },
- couponCode: BLACK_FRIDAY.COUPON_CODE,
- },
- ],
- }
- : undefined;
-
- const isMounted = useIsMounted();
-
- useEffect(() => {
- // Only fetching this during the black friday period
- if (!isBlackFridayPeriod) {
- return;
- }
- if (!isFree) {
- setLatestSubscription({ LastSubscriptionEnd: 0 });
- return;
- }
- const run = async () => {
- const result = await api<LatestSubscription>(getLastCancelledSubscription()).catch(() => undefined);
- if (isMounted()) {
- setLatestSubscription(result);
- }
- };
- withLoading(run());
- }, [isBlackFridayPeriod, isFree]);
-
- return blackFridayOffer;
-};
-
-export default usePromotionOffer;
diff --git a/packages/components/containers/index.ts b/packages/components/containers/index.ts
index 9bfef3d1f9..81322f822d 100644
--- a/packages/components/containers/index.ts
+++ b/packages/components/containers/index.ts
@@ -42,6 +42,7 @@ export * from './modals';
export * from './notification';
export * from './notifications';
export * from './onboarding';
+export * from './offers';
export * from './organization';
export * from './overview';
export * from './password';
diff --git a/packages/components/containers/offers/Offer.scss b/packages/components/containers/offers/Offer.scss
new file mode 100644
index 0000000000..a33a9476ce
--- /dev/null
+++ b/packages/components/containers/offers/Offer.scss
@@ -0,0 +1,92 @@
+@import '~@proton/styles/scss/config';
+
+$amount-size: 36;
+
+.offer {
+ &-modal {
+ &.modal-two-dialog--large {
+ // only increasing size for large case = when 3 plans
+ --size: #{em(1000)}; // to define
+ }
+
+ & .modal-two-header-title {
+ inline-size: 100%;
+ padding-inline-start: em(36); // to compensate close button
+ }
+ }
+
+ &-proton-logo {
+ margin-block-start: em(-12); // magic number to align with close button
+ @include respond-to($breakpoint-small) {
+ inline-size: em(30); // just to avoid having images going to another next line on mobile
+ }
+ }
+ @include respond-to($breakpoint-small) {
+ &-proton-logo {
+ inline-size: em(30); // just to avoid having images going to another next line on mobile
+ margin-block-start: em(-12); // magic number to align with close button on mobile
+ }
+ }
+
+ &-countdown {
+ &-number {
+ font-variant: tabular-nums;
+ min-inline-size: 2em;
+ }
+ }
+
+ &-plan-container {
+ flex: 1;
+ max-inline-size: 25em;
+
+ &:not(:first-child) {
+ margin-inline-start: 1em;
+ @include respond-to($breakpoint-small) {
+ margin-inline-start: 0;
+ }
+ }
+ @include respond-to($breakpoint-small) {
+ max-inline-size: none;
+ }
+ }
+
+ &-plan {
+ padding-inline: em(24);
+
+ &.is-focused {
+ border-color: var(--field-focus);
+ box-shadow: 0 0 0 #{$fields-focus-ring-size} var(--field-highlight);
+ }
+ }
+
+ &-percentage {
+ inset-inline-start: 50%;
+ transform: translateX(-50%) translateY(-50%);
+
+ [dir='rtl'] & {
+ transform: translateX(50%) translateY(-50%);
+ }
+
+ border-radius: 1em;
+ z-index: 1;
+ }
+
+ &-monthly-price {
+ display: block;
+
+ .amount,
+ .currency {
+ font-size: em($amount-size);
+ font-weight: var(--font-weight-bold);
+ }
+
+ .suffix {
+ margin-inline-start: 0.5em;
+ color: var(--text-weak);
+ }
+ }
+
+ &-features:empty {
+ display: none;
+ }
+}
diff --git a/packages/components/containers/offers/README.md b/packages/components/containers/offers/README.md
new file mode 100644
index 0000000000..7be0fb3182
--- /dev/null
+++ b/packages/components/containers/offers/README.md
@@ -0,0 +1,41 @@
+# Offers
+
+The goal was to give developer a platform allowing them to create offers operations with a single, well typed, config file. And if customisation is needed, components are modular enough to give the needed flexibility to make it in a fast and reusable way.
+
+## Create a new offer
+
+In order to create a new offer, few steps are needed.
+
+### Create FeatureCode
+
+In `FeaturesContext.ts`, insert a new FeatureCode for the offer. **FeatureCode name must respect naming conventions**:
+
+- PascalCase
+- Prefixed by `Offer`
+- Suffixed by year
+- Ex: `OfferBlackFriday2022`
+
+### Add an offer-id
+
+Add an offer ID (for frontend purpose only) in `interface.ts` `OfferId` union type.
+
+### Create operation folder
+
+Create a new folder inside `operations`. Name should be same as featureFlag without the prefix and camelCased.
+
+Ex: If FF is `OfferBlackFriday2022` folder name will be `blackFriday2022`.
+
+Then create and fill `configuration.ts`, `useOffer.ts`, `Layout.tsx` and `index.ts` files.
+
+Be carefull with naming exports in `index.ts`.
+
+Ex:
+
+```ts
+export { default as blackFriday2022Config } from './configuration';
+export { default as useBlackFriday2022 } from './useOffer';
+```
+
+### Add operation to main hook
+
+Import the config and the hook in `useOfferConfig`.
diff --git a/packages/components/containers/offers/components/OfferCountdown.tsx b/packages/components/containers/offers/components/OfferCountdown.tsx
new file mode 100644
index 0000000000..4cf6e1980a
--- /dev/null
+++ b/packages/components/containers/offers/components/OfferCountdown.tsx
@@ -0,0 +1,46 @@
+import { fromUnixTime } from 'date-fns';
+import { c, msgid } from 'ttag';
+
+import useDateCountdown from '@proton/hooks/useDateCountdown';
+import isTruthy from '@proton/utils/isTruthy';
+
+interface Props {
+ periodEnd: number;
+}
+
+const Countdown = ({ periodEnd }: Props) => {
+ const endDate = fromUnixTime(periodEnd);
+ const countdownProps = useDateCountdown(endDate);
+ const { expired, seconds, minutes, hours, days } = countdownProps;
+
+ if (expired) {
+ return null;
+ }
+
+ return (
+ <div className="mt1 text-center">
+ {[
+ days > 0
+ ? c('specialoffer: Countdown unit').ngettext(msgid`${days} day`, `${days} days`, days)
+ : undefined,
+ c('specialoffer: Countdown unit').ngettext(msgid`${hours} hour`, `${hours} hours`, hours),
+ c('specialoffer: Countdown unit').ngettext(msgid`${minutes} minute`, `${minutes} minutes`, minutes),
+ c('specialoffer: Countdown unit').ngettext(msgid`${seconds} second`, `${seconds} seconds`, seconds),
+ ]
+ .filter(isTruthy)
+ .map((value) => {
+ const [number, unit] = value.split(' ');
+ return (
+ <span className="inline-flex flex-column flex-nowrap flex-align-items-center mr1" key={unit}>
+ <span className="bg-weak text-bold w2e offer-countdown-number py0-25 rounded">
+ {number}
+ </span>
+ <span className="text-nowrap color-weak">{unit}</span>
+ </span>
+ );
+ })}
+ </div>
+ );
+};
+
+export default Countdown;
diff --git a/packages/components/containers/offers/components/OfferDisableButton.tsx b/packages/components/containers/offers/components/OfferDisableButton.tsx
new file mode 100644
index 0000000000..c6d8ad4319
--- /dev/null
+++ b/packages/components/containers/offers/components/OfferDisableButton.tsx
@@ -0,0 +1,27 @@
+import { c } from 'ttag';
+
+import { Button } from '@proton/components/components';
+import { useLoading } from '@proton/components/hooks';
+
+import useOfferFlags from '../hooks/useOfferFlags';
+import { OfferLayoutProps } from '../interface';
+
+const OfferDisableButton = (props: OfferLayoutProps) => {
+ const { handleHide } = useOfferFlags(props.offer);
+
+ const [loading, withLoading] = useLoading();
+
+ return (
+ <Button
+ shape="underline"
+ color="norm"
+ loading={loading}
+ onClick={async () => {
+ await withLoading(handleHide());
+ props.onCloseModal?.();
+ }}
+ >{c('specialoffer: Action').t`Don't show this again`}</Button>
+ );
+};
+
+export default OfferDisableButton;
diff --git a/packages/components/containers/offers/components/OfferFooter.tsx b/packages/components/containers/offers/components/OfferFooter.tsx
new file mode 100644
index 0000000000..332f23b767
--- /dev/null
+++ b/packages/components/containers/offers/components/OfferFooter.tsx
@@ -0,0 +1,40 @@
+import { forwardRef } from 'react';
+
+import { CurrencySelector, useUser } from '@proton/components';
+
+import { OfferLayoutProps } from '../interface';
+import OfferDisableButton from './OfferDisableButton';
+
+interface Props extends OfferLayoutProps {
+ children?: React.ReactNode;
+}
+
+const OfferFooter = forwardRef<HTMLDivElement, Props>((props, ref) => {
+ const { children, currency, onChangeCurrency } = props;
+ const [user] = useUser();
+
+ return (
+ <footer ref={ref}>
+ {user.isFree ? (
+ <div className="my1 text-center offers-currency-selector">
+ <CurrencySelector
+ id="offers-currency-selector"
+ mode="buttons"
+ currency={currency}
+ onSelect={onChangeCurrency}
+ />
+ </div>
+ ) : null}
+ {props.offer.canBeDisabled ? (
+ <div className="mb1 text-center">
+ <OfferDisableButton {...props} />
+ </div>
+ ) : null}
+ {children}
+ </footer>
+ );
+});
+
+OfferFooter.displayName = 'OfferFooter';
+
+export default OfferFooter;
diff --git a/packages/components/containers/offers/components/OfferHeader.tsx b/packages/components/containers/offers/components/OfferHeader.tsx
new file mode 100644
index 0000000000..56e5fa2775
--- /dev/null
+++ b/packages/components/containers/offers/components/OfferHeader.tsx
@@ -0,0 +1,19 @@
+import { forwardRef } from 'react';
+
+import { OfferLayoutProps } from '../interface';
+import OfferCountdown from './OfferCountdown';
+
+interface Props extends OfferLayoutProps {
+ children: React.ReactNode;
+}
+
+const OfferHeader = forwardRef<HTMLDivElement, Props>(({ children, offer }, ref) => (
+ <header ref={ref}>
+ {children}
+ {offer.periodEnd !== undefined && <OfferCountdown periodEnd={offer.periodEnd} />}
+ </header>
+));
+
+OfferHeader.displayName = 'OfferHeader';
+
+export default OfferHeader;
diff --git a/packages/components/containers/offers/components/OfferModal.tsx b/packages/components/containers/offers/components/OfferModal.tsx
new file mode 100644
index 0000000000..df509ab4fa
--- /dev/null
+++ b/packages/components/containers/offers/components/OfferModal.tsx
@@ -0,0 +1,58 @@
+import { useState } from 'react';
+
+import { CircleLoader, ModalProps, ModalTwo, ModalTwoContent, ModalTwoHeader, useUser } from '@proton/components';
+import { DEFAULT_CURRENCY } from '@proton/shared/lib/constants';
+import { Currency } from '@proton/shared/lib/interfaces';
+import noop from '@proton/utils/noop';
+
+import useFetchOffer from '../hooks/useFetchOffer';
+import useOnSelectDeal from '../hooks/useOnSelectDeal';
+import useVisitedOffer from '../hooks/useVisitedOffer';
+import { OfferConfig } from '../interface';
+import ProtonLogos from './ProtonLogos';
+
+import '../Offer.scss';
+
+interface Props extends ModalProps {
+ offerConfig: OfferConfig;
+ modalProps: ModalProps;
+}
+
+const OfferModal = ({ offerConfig, modalProps }: Props) => {
+ useVisitedOffer(offerConfig);
+ const [user] = useUser();
+ const defaultCurrency = user?.Currency || DEFAULT_CURRENCY;
+ const [currency, updateCurrency] = useState<Currency>(defaultCurrency);
+ const { onClose: handleCloseModal } = modalProps;
+
+ const offer = useFetchOffer({
+ offerConfig,
+ currency: defaultCurrency,
+ onError: handleCloseModal,
+ });
+
+ const handleOnSelectDeal = useOnSelectDeal(handleCloseModal);
+
+ return (
+ <ModalTwo className="offer-modal" {...modalProps} size={offerConfig.deals.length > 1 ? 'large' : 'medium'}>
+ <ModalTwoHeader title={<ProtonLogos />} />
+ <ModalTwoContent>
+ {!offer ? (
+ <div className="text-center">
+ <CircleLoader size="large" className="mxauto flex mb2" />
+ </div>
+ ) : (
+ <offer.layout
+ offer={offer}
+ currency={currency}
+ onChangeCurrency={updateCurrency}
+ onSelectDeal={handleOnSelectDeal}
+ onCloseModal={handleCloseModal || noop}
+ />
+ )}
+ </ModalTwoContent>
+ </ModalTwo>
+ );
+};
+
+export default OfferModal;
diff --git a/packages/components/containers/offers/components/ProtonLogos.tsx b/packages/components/containers/offers/components/ProtonLogos.tsx
new file mode 100644
index 0000000000..1cd17e8341
--- /dev/null
+++ b/packages/components/containers/offers/components/ProtonLogos.tsx
@@ -0,0 +1,12 @@
+import { CalendarLogo, DriveLogo, MailLogo, VpnLogo } from '@proton/components/components';
+
+const ProtonLogos = () => (
+ <div className="text-center">
+ <MailLogo variant="glyph-only" className="offer-proton-logo" size={60} />
+ <CalendarLogo variant="glyph-only" className="offer-proton-logo" size={60} />
+ <DriveLogo variant="glyph-only" className="offer-proton-logo" size={60} />
+ <VpnLogo variant="glyph-only" className="offer-proton-logo" size={60} />
+ </div>
+);
+
+export default ProtonLogos;
diff --git a/packages/components/containers/offers/components/deal/Deal.helpers.tsx b/packages/components/containers/offers/components/deal/Deal.helpers.tsx
new file mode 100644
index 0000000000..f5b1c060c3
--- /dev/null
+++ b/packages/components/containers/offers/components/deal/Deal.helpers.tsx
@@ -0,0 +1,33 @@
+import { ReactElement } from 'react';
+
+import { c } from 'ttag';
+
+import { CYCLE } from '@proton/shared/lib/constants';
+
+const { MONTHLY, YEARLY, TWO_YEARS } = CYCLE;
+
+export const getDealBilledDescription = (cycle: CYCLE, amount: ReactElement): string | string[] | null => {
+ switch (cycle) {
+ case MONTHLY:
+ return c('specialoffer: Offers').jt`Billed at ${amount} for the first month.`;
+ case YEARLY:
+ return c('specialoffer: Offers').jt`Billed at ${amount} for the first year.`;
+ case TWO_YEARS:
+ return c('specialoffer: Offers').jt`Billed at ${amount} for the first 2 years.`;
+ default:
+ return null;
+ }
+};
+
+export const getDealDuration = (cycle: CYCLE): string | null => {
+ switch (cycle) {
+ case MONTHLY:
+ return c('specialoffer: Offers').t`for 1 month`;
+ case YEARLY:
+ return c('specialoffer: Offers').t`for 12 months`;
+ case TWO_YEARS:
+ return c('specialoffer: Offers').t`for 24 months`;
+ default:
+ return null;
+ }
+};
diff --git a/packages/components/containers/offers/components/deal/Deal.tsx b/packages/components/containers/offers/components/deal/Deal.tsx
new file mode 100644
index 0000000000..0e46224ebd
--- /dev/null
+++ b/packages/components/containers/offers/components/deal/Deal.tsx
@@ -0,0 +1,55 @@
+import { forwardRef } from 'react';
+
+import { c } from 'ttag';
+
+import clsx from '@proton/utils/clsx';
+
+import type { Offer, OfferLayoutProps } from '../../interface';
+import { DealProvider } from './DealContext';
+
+interface Props extends OfferLayoutProps {
+ deal: Offer['deals'][number];
+ children: React.ReactNode;
+}
+
+const Deal = forwardRef<HTMLDivElement, Props>(({ children, ...props }: Props, ref) => {
+ const { popular, prices, cycle } = props.deal;
+
+ const { withCoupon = 0, withoutCouponMonthly = 0 } = prices || {};
+ const withCouponMonthly = withCoupon / cycle;
+ const percentage = 100 - Math.round((withCouponMonthly * 100) / withoutCouponMonthly);
+
+ return (
+ <DealProvider {...props}>
+ <div
+ ref={ref}
+ className={clsx([
+ 'relative flex flex-item-fluid offer-plan-container on-mobile-mt1',
+ popular && 'offer-plan-container--mostPopular',
+ ])}
+ >
+ {percentage ? (
+ <span
+ className={clsx([
+ 'text-semibold absolute text-center offer-percentage py0-25 px1',
+ popular ? 'bg-primary' : 'bg-weak color-weak border border-norm',
+ ])}
+ >
+ {c('specialoffer: Offers').jt`Save ${percentage}%`}
+ </span>
+ ) : null}
+ <div
+ className={clsx([
+ 'offer-plan w100 border rounded p1 mb1 flex flex-column flex-align-items-center flex-justify-end',
+ popular && 'border-primary is-focused',
+ ])}
+ >
+ {children}
+ </div>
+ </div>
+ </DealProvider>
+ );
+});
+Deal.displayName = 'Deal';
+
+export default Deal;
diff --git a/packages/components/containers/offers/components/deal/DealCTA.tsx b/packages/components/containers/offers/components/deal/DealCTA.tsx
new file mode 100644
index 0000000000..2d133a0ce2
--- /dev/null
+++ b/packages/components/containers/offers/components/deal/DealCTA.tsx
@@ -0,0 +1,26 @@
+import { c } from 'ttag';
+
+import { Button } from '@proton/components/components';
+
+import { useDealContext } from './DealContext';
+
+const DealCTA = () => {
+ const { deal, onSelectDeal, offer, currency } = useDealContext();
+ const { popular } = deal;
+
+ return (
+ <Button
+ color="norm"
+ shape={popular ? 'solid' : 'outline'}
+ className="mb1"
+ fullWidth
+ onClick={() => {
+ onSelectDeal(offer, deal, currency);
+ }}
+ >
+ {deal.getCTAContent?.() || c('specialoffer: Offers').t`Get the deal`}
+ </Button>
+ );
+};
+
+export default DealCTA;
diff --git a/packages/components/containers/offers/components/deal/DealContext.tsx b/packages/components/containers/offers/components/deal/DealContext.tsx
new file mode 100644
index 0000000000..c37b67fad5
--- /dev/null
+++ b/packages/components/containers/offers/components/deal/DealContext.tsx
@@ -0,0 +1,27 @@
+import { createContext, useContext } from 'react';
+
+import { Offer, OfferLayoutProps } from '../../interface';
+
+interface DealProps extends OfferLayoutProps {
+ deal: Offer['deals'][number];
+}
+
+const DealContext = createContext<DealProps | undefined>(undefined);
+
+interface ProviderProps extends DealProps {
+ children: React.ReactNode;
+}
+
+export const DealProvider = ({ children, ...props }: ProviderProps) => (
+ <DealContext.Provider value={{ ...props }}>{children}</DealContext.Provider>
+);
+
+export const useDealContext = () => {
+ const context = useContext(DealContext);
+
+ if (context === undefined) {
+ throw new Error('Deal context is not set');
+ }
+
+ return context;
+};
diff --git a/packages/components/containers/offers/components/deal/DealFeatures.tsx b/packages/components/containers/offers/components/deal/DealFeatures.tsx
new file mode 100644
index 0000000000..4199bc02c6
--- /dev/null
+++ b/packages/components/containers/offers/components/deal/DealFeatures.tsx
@@ -0,0 +1,31 @@
+import { Icon, Info, StripedItem, StripedList } from '@proton/components/components';
+import clsx from '@proton/utils/clsx';
+
+import { useDealContext } from './DealContext';
+
+const DealFeatures = () => {
+ const { deal } = useDealContext();
+ return deal.features ? (
+ <div className="flex-item-fluid-auto w100 no-mobile">
+ <StripedList alternate="odd">
+ {deal.features.map((feature, index) => (
+ <StripedItem
+ key={index}
+ left={
+ !!feature.icon ? (
+ <Icon className="color-success" name={feature.icon} size={20} />
+ ) : undefined
+ }
+ >
+ <span className={clsx(['text-left', feature.disabled && 'color-disabled'])}>
+ {feature.name}
+ </span>
+ {!!feature.tooltip && <Info className="ml0-5" title={feature.tooltip} />}
+ </StripedItem>
+ ))}
+ </StripedList>
+ </div>
+ ) : null;
+};
+
+export default DealFeatures;
diff --git a/packages/components/containers/offers/components/deal/DealPrice.tsx b/packages/components/containers/offers/components/deal/DealPrice.tsx
new file mode 100644
index 0000000000..d30dfcb0ed
--- /dev/null
+++ b/packages/components/containers/offers/components/deal/DealPrice.tsx
@@ -0,0 +1,28 @@
+import { c } from 'ttag';
+
+import { Price } from '@proton/components/components';
+
+import { useDealContext } from '../deal/DealContext';
+
+const DealPrice = () => {
+ const {
+ deal: { prices, cycle },
+ currency,
+ } = useDealContext();
+ const { withCoupon = 0 } = prices || {};
+
+ return (
+ <div className="mb1 mt1 text-center">
+ <Price
+ currency={currency}
+ className="offer-monthly-price color-norm"
+ suffix={c('specialoffer: Offers').t`/ month`}
+ isDisplayedInSentence
+ >
+ {withCoupon / cycle}
+ </Price>
+ </div>
+ );
+};
+
+export default DealPrice;
diff --git a/packages/components/containers/offers/components/deal/DealPriceInfos.tsx b/packages/components/containers/offers/components/deal/DealPriceInfos.tsx
new file mode 100644
index 0000000000..907753929a
--- /dev/null
+++ b/packages/components/containers/offers/components/deal/DealPriceInfos.tsx
@@ -0,0 +1,37 @@
+import { c } from 'ttag';
+
+import { Price } from '@proton/components/components';
+
+import { getDealBilledDescription } from './Deal.helpers';
+import { useDealContext } from './DealContext';
+
+const DealPriceInfos = () => {
+ const {
+ deal: { cycle, prices },
+ currency,
+ } = useDealContext();
+ const { withCoupon = 0, withoutCouponMonthly = 0 } = prices || {};
+
+ const amountDue = (
+ <Price key={'deal-amount'} currency={currency} isDisplayedInSentence>
+ {withCoupon}
+ </Price>
+ );
+
+ const regularPrice = (
+ <span className="text-strike" key={'deal-regular-price'}>
+ <Price currency={currency}>{withoutCouponMonthly * cycle}</Price>
+ </span>
+ );
+
+ return (
+ <div className="mb0-5 w100">
+ <small className="w100 color-weak text-left">
+ <span className="block">{getDealBilledDescription(cycle, amountDue)}</span>
+ <span className="block">{c('specialoffer: Offers').jt`Standard price ${regularPrice}`}</span>
+ </small>
+ </div>
+ );
+};
+
+export default DealPriceInfos;
diff --git a/packages/components/containers/offers/components/deal/DealTitle.tsx b/packages/components/containers/offers/components/deal/DealTitle.tsx
new file mode 100644
index 0000000000..cabfcf02d6
--- /dev/null
+++ b/packages/components/containers/offers/components/deal/DealTitle.tsx
@@ -0,0 +1,19 @@
+import { PLAN_NAMES } from '@proton/shared/lib/constants';
+
+import { getDealDuration } from './Deal.helpers';
+import { useDealContext } from './DealContext';
+
+const DealTitle = () => {
+ const {
+ deal: { planName, cycle },
+ } = useDealContext();
+
+ return (
+ <div className="offer-plan-namePeriod">
+ <strong className="offer-plan-name block text-center text-2xl mt0-5 mb0">{PLAN_NAMES[planName]}</strong>
+ <span className="color-weak block text-center">{getDealDuration(cycle)}</span>
+ </div>
+ );
+};
+
+export default DealTitle;
diff --git a/packages/components/containers/offers/components/deal/Deals.tsx b/packages/components/containers/offers/components/deal/Deals.tsx
new file mode 100644
index 0000000000..df03c59e3c
--- /dev/null
+++ b/packages/components/containers/offers/components/deal/Deals.tsx
@@ -0,0 +1,27 @@
+import { OfferLayoutProps } from '../../interface';
+import Deal from './Deal';
+import DealCTA from './DealCTA';
+import DealFeatures from './DealFeatures';
+import DealPrice from './DealPrice';
+import DealPriceInfos from './DealPriceInfos';
+import DealTitle from './DealTitle';
+
+const Deals = (props: OfferLayoutProps) => {
+ return (
+ <div className="offer-wrapper flex flex-nowrap flex-justify-space-around on-mobile-flex-column mt3">
+ {props.offer.deals.map((deal, index) => (
+ <Deal key={index} {...props} deal={deal}>
+ <DealTitle />
+ <DealPrice />
+ <DealCTA />
+ <div className="offer-features flex-item-fluid-auto w100 mb1">
+ <DealFeatures />
+ </div>
+ <DealPriceInfos />
+ </Deal>
+ ))}
+ </div>
+ );
+};
+
+export default Deals;
diff --git a/packages/components/containers/offers/helpers/getDealPrices.ts b/packages/components/containers/offers/helpers/getDealPrices.ts
new file mode 100644
index 0000000000..c03b7bcc84
--- /dev/null
+++ b/packages/components/containers/offers/helpers/getDealPrices.ts
@@ -0,0 +1,37 @@
+import { checkSubscription } from '@proton/shared/lib/api/payments';
+import { CYCLE } from '@proton/shared/lib/constants';
+import { Api, Currency, SubscriptionCheckResponse } from '@proton/shared/lib/interfaces';
+
+import { OfferConfig } from '../interface';
+
+const getDealPrices = async (api: Api, offerConfig: OfferConfig, currency: Currency) =>
+ Promise.all(
+ offerConfig.deals.map(({ planIDs, cycle, couponCode }) => {
+ return Promise.all([
+ api<SubscriptionCheckResponse>(
+ checkSubscription({
+ Plans: planIDs,
+ CouponCode: couponCode,
+ Currency: currency,
+ Cycle: cycle,
+ })
+ ),
+ api<SubscriptionCheckResponse>(
+ checkSubscription({
+ Plans: planIDs,
+ Currency: currency,
+ Cycle: cycle,
+ })
+ ),
+ api<SubscriptionCheckResponse>(
+ checkSubscription({
+ Plans: planIDs,
+ Currency: currency,
+ Cycle: CYCLE.MONTHLY,
+ })
+ ),
+ ]);
+ })
+ );
+
+export default getDealPrices;
diff --git a/packages/components/containers/offers/helpers/getOfferRedirectionParams.ts b/packages/components/containers/offers/helpers/getOfferRedirectionParams.ts
new file mode 100644
index 0000000000..bb17c6f479
--- /dev/null
+++ b/packages/components/containers/offers/helpers/getOfferRedirectionParams.ts
@@ -0,0 +1,29 @@
+import { Currency } from '@proton/shared/lib/interfaces';
+
+import { Deal, Offer } from '../interface';
+
+interface Props {
+ offer: Offer;
+ deal: Deal;
+ currency: Currency;
+}
+
+const getOfferRedirectionParams = ({ offer, deal, currency }: Props): URLSearchParams => {
+ const { cycle, couponCode, planName } = deal;
+
+ const params = new URLSearchParams();
+ params.set('cycle', `${cycle}`);
+ params.set('currency', currency);
+ if (couponCode) {
+ params.set('coupon', couponCode);
+ }
+ params.set('plan', planName);
+ params.set('type', 'offer');
+ params.set('edit', 'disable'); // Disable the possibility to edit the configuration in the subscription modal
+ params.set('offer', offer.ID);
+ params.set('ref', offer.ref); // Used by data team
+
+ return params;
+};
+
+export default getOfferRedirectionParams;
diff --git a/packages/components/containers/offers/hooks/useFetchOffer.ts b/packages/components/containers/offers/hooks/useFetchOffer.ts
new file mode 100644
index 0000000000..8948c092b0
--- /dev/null
+++ b/packages/components/containers/offers/hooks/useFetchOffer.ts
@@ -0,0 +1,54 @@
+import { useEffect, useState } from 'react';
+
+import { useApi, useLoading } from '@proton/components/hooks';
+import { Currency } from '@proton/shared/lib/interfaces';
+
+import getDealPrices from '../helpers/getDealPrices';
+import { Offer, OfferConfig } from '../interface';
+
+interface Props {
+ offerConfig: OfferConfig;
+ currency: Currency;
+ onError?: () => void;
+}
+
+function useFetchOffer({ offerConfig, currency, onError }: Props): Offer | undefined {
+ const api = useApi();
+ const [loading, withLoading] = useLoading();
+ const [offer, setOffer] = useState<Offer>();
+
+ useEffect(() => {
+ const updateOfferPrices = async () => {
+ try {
+ const result = await getDealPrices(api, offerConfig, currency);
+
+ // We make an offer based on offerConfig + fetched results above
+ const offer: Offer = {
+ ...offerConfig,
+ deals: offerConfig.deals.map((deal, index) => {
+ const [withCoupon, withoutCoupon, withoutCouponMonthly] = result[index];
+
+ return {
+ ...deal,
+ prices: {
+ withCoupon: withCoupon.Amount + (withCoupon.CouponDiscount || 0),
+ withoutCoupon: withoutCoupon.Amount + (withoutCoupon.CouponDiscount || 0), // BUNDLE discount can be applied
+ withoutCouponMonthly: withoutCouponMonthly.Amount,
+ },
+ };
+ }),
+ };
+ setOffer(offer);
+ } catch (error) {
+ onError?.();
+ throw error;
+ }
+ };
+
+ void withLoading(updateOfferPrices());
+ }, [currency]);
+
+ return loading ? undefined : offer;
+}
+
+export default useFetchOffer;
diff --git a/packages/components/containers/offers/hooks/useOfferConfig.ts b/packages/components/containers/offers/hooks/useOfferConfig.ts
new file mode 100644
index 0000000000..8aaddb9b58
--- /dev/null
+++ b/packages/components/containers/offers/hooks/useOfferConfig.ts
@@ -0,0 +1,34 @@
+import { useFeatures } from '@proton/components/hooks';
+
+import { FeatureCode } from '../../features';
+import { OfferConfig, OfferId, Operation, OperationsMap } from '../interface';
+import { goUnlimited2022Config, useGoUnlimited2022 } from '../operations/goUnlimited2022';
+import { specialOffer2022Config, useSpecialOffer2022 } from '../operations/specialOffer2022';
+
+const configs: Record<OfferId, OfferConfig> = {
+ 'go-unlimited-2022': goUnlimited2022Config,
+ 'special-offer-2022': specialOffer2022Config,
+};
+
+const OFFERS_FEATURE_FLAGS = Object.values(configs).map(({ featureCode }) => featureCode);
+
+const useOfferConfig = (): OfferConfig | undefined => {
+ // Preload FF to avoid single API requests
+ useFeatures([FeatureCode.Offers, ...OFFERS_FEATURE_FLAGS]);
+
+ const goUnlimited2022 = useGoUnlimited2022();
+ const specialOffer2022 = useSpecialOffer2022();
+
+ const operations: OperationsMap = {
+ 'go-unlimited-2022': goUnlimited2022,
+ 'special-offer-2022': specialOffer2022,
+ };
+
+ const validOffer: Operation | undefined = Object.values(operations).find(
+ ({ isLoading, isValid }) => isLoading === false && isValid
+ );
+
+ return validOffer?.config;
+};
+
+export default useOfferConfig;
diff --git a/packages/components/containers/offers/hooks/useOfferFlags.ts b/packages/components/containers/offers/hooks/useOfferFlags.ts
new file mode 100644
index 0000000000..4fca1471d1
--- /dev/null
+++ b/packages/components/containers/offers/hooks/useOfferFlags.ts
@@ -0,0 +1,44 @@
+import { useFeature } from '@proton/components/hooks';
+import { hasBit, setBit } from '@proton/shared/lib/helpers/bitset';
+
+import { FeatureCode } from '../../features';
+import { OfferConfig, OfferGlobalFeatureCodeValue, OfferUserFeatureCodeValue } from '../interface';
+
+const { Default, Visited, Hide } = OfferUserFeatureCodeValue;
+
+const useOfferFlags = (config: OfferConfig) => {
+ const { feature: globalFlag, loading: globalFlagLoading } = useFeature<OfferGlobalFeatureCodeValue>(
+ FeatureCode.Offers
+ );
+ const {
+ feature: userFlag,
+ loading: userFlagLoading,
+ update: userFlagUpdate,
+ } = useFeature<OfferUserFeatureCodeValue>(config.featureCode);
+
+ const userFlagValue = userFlag?.Value || Default;
+
+ return {
+ loading: globalFlagLoading || userFlagLoading,
+ isActive: globalFlag?.Value?.[config.ID] === true && !hasBit(userFlagValue, Hide),
+ isVisited: hasBit(userFlagValue, Visited),
+ handleHide: () => {
+ const nextValue = setBit(userFlagValue, Hide);
+ if (nextValue === userFlagValue) {
+ return;
+ }
+
+ return userFlagUpdate(nextValue);
+ },
+ handleVisit: () => {
+ const nextValue = setBit(userFlagValue, Visited);
+ if (nextValue === userFlagValue) {
+ return;
+ }
+
+ return userFlagUpdate(nextValue);
+ },
+ };
+};
+
+export default useOfferFlags;
diff --git a/packages/components/containers/offers/hooks/useOnSelectDeal.ts b/packages/components/containers/offers/hooks/useOnSelectDeal.ts
new file mode 100644
index 0000000000..7bde796158
--- /dev/null
+++ b/packages/components/containers/offers/hooks/useOnSelectDeal.ts
@@ -0,0 +1,24 @@
+import { useCallback } from 'react';
+
+import { useSettingsLink } from '@proton/components/components';
+import { Currency } from '@proton/shared/lib/interfaces';
+
+import getOfferRedirectionParams from '../helpers/getOfferRedirectionParams';
+import { Deal, Offer } from '../interface';
+
+const useSelectDeal = (callback?: () => void) => {
+ const goToSettingsLink = useSettingsLink();
+
+ const handleOnSelectDeal = useCallback(
+ (offer: Offer, deal: Deal, currency: Currency) => {
+ const urlSearchParams = getOfferRedirectionParams({ offer, deal, currency });
+ callback?.();
+ goToSettingsLink(`/dashboard?${urlSearchParams.toString()}`);
+ },
+ [callback]
+ );
+
+ return handleOnSelectDeal;
+};
+
+export default useSelectDeal;
diff --git a/packages/components/containers/offers/hooks/useVisitedOffer.ts b/packages/components/containers/offers/hooks/useVisitedOffer.ts
new file mode 100644
index 0000000000..35d7fc1591
--- /dev/null
+++ b/packages/components/containers/offers/hooks/useVisitedOffer.ts
@@ -0,0 +1,19 @@
+import { useEffect } from 'react';
+
+import { OfferConfig } from '../interface';
+import useOfferFlags from './useOfferFlags';
+
+/**
+ * Mark the offer as visited
+ */
+const useVisitedOffer = (offerConfig: OfferConfig) => {
+ const { handleVisit, isVisited, loading } = useOfferFlags(offerConfig);
+
+ useEffect(() => {
+ if (!loading && !isVisited) {
+ void handleVisit();
+ }
+ }, [loading]);
+};
+
+export default useVisitedOffer;
diff --git a/packages/components/containers/offers/index.ts b/packages/components/containers/offers/index.ts
new file mode 100644
index 0000000000..c27bf204e0
--- /dev/null
+++ b/packages/components/containers/offers/index.ts
@@ -0,0 +1,2 @@
+export { default as useOfferConfig } from './hooks/useOfferConfig';
+export { default as OfferModal } from './components/OfferModal';
diff --git a/packages/components/containers/offers/interface.ts b/packages/components/containers/offers/interface.ts
new file mode 100644
index 0000000000..c25ebdaa67
--- /dev/null
+++ b/packages/components/containers/offers/interface.ts
@@ -0,0 +1,71 @@
+import { JSXElementConstructor } from 'react';
+
+import type { FeatureCode, IconName } from '@proton/components';
+import type { COUPON_CODES, CYCLE, PLANS } from '@proton/shared/lib/constants';
+import type { Currency, PlanIDs } from '@proton/shared/lib/interfaces';
+
+export type OfferId = 'go-unlimited-2022' | 'special-offer-2022';
+
+export type OfferGlobalFeatureCodeValue = Record<OfferId, boolean>;
+
+export enum OfferUserFeatureCodeValue {
+ Default = 0,
+ Visited = 1,
+ Hide = 2,
+}
+
+export interface OfferLayoutProps {
+ currency: Currency;
+ offer: Offer;
+ onChangeCurrency: (currency: Currency) => void;
+ onSelectDeal: (offer: Offer, deal: Deal, current: Currency) => void;
+ onCloseModal: () => void;
+}
+
+export interface Operation {
+ config: OfferConfig;
+ isValid: boolean;
+ isLoading: boolean;
+}
+
+export type OperationsMap = Record<OfferId, Operation>;
+
+export interface OfferConfig {
+ ID: OfferId;
+ featureCode: FeatureCode;
+ ref: string;
+ autoPopUp?: boolean;
+ canBeDisabled?: boolean;
+ deals: Deal[];
+ layout: JSXElementConstructor<OfferLayoutProps>;
+ /** Displays countdown if present */
+ periodEnd?: number;
+ getCTAContent?: () => string;
+}
+
+interface Feature {
+ disabled?: boolean;
+ icon?: IconName;
+ name: string;
+ tooltip?: string;
+}
+
+export interface Deal {
+ couponCode?: COUPON_CODES;
+ cycle: CYCLE;
+ features?: Feature[];
+ getCTAContent?: () => string;
+ planIDs: PlanIDs; // planIDs used to subscribe
+ planName: PLANS; // plan display in the deal
+ popular?: boolean;
+}
+
+interface Prices {
+ withCoupon: number;
+ withoutCoupon: number;
+ withoutCouponMonthly: number;
+}
+
+export interface Offer extends OfferConfig {
+ deals: (Deal & { prices: Prices })[];
+}
diff --git a/packages/components/containers/offers/operations/goUnlimited2022/Layout.tsx b/packages/components/containers/offers/operations/goUnlimited2022/Layout.tsx
new file mode 100644
index 0000000000..6fb9c17c91
--- /dev/null
+++ b/packages/components/containers/offers/operations/goUnlimited2022/Layout.tsx
@@ -0,0 +1,26 @@
+import { c } from 'ttag';
+
+import OfferFooter from '../../components/OfferFooter';
+import OfferHeader from '../../components/OfferHeader';
+import Deals from '../../components/deal/Deals';
+import { OfferLayoutProps } from '../../interface';
+
+const Layout = (props: OfferLayoutProps) => {
+ return (
+ <>
+ <OfferHeader {...props}>
+ <h1 className="h2 text-center text-bold">{c('specialoffer: Title')
+ .t`Upgrade and save more with Proton Unlimited`}</h1>
+ </OfferHeader>
+
+ <Deals {...props} />
+
+ <OfferFooter {...props}>
+ <p className="text-sm text-center mb1 color-weak">{c('specialoffer: Footer')
+ .t`This subscription will automatically renew every 2 years at the same rate until it is cancelled.`}</p>
+ </OfferFooter>
+ </>
+ );
+};
+
+export default Layout;
diff --git a/packages/components/containers/offers/operations/goUnlimited2022/configuration.ts b/packages/components/containers/offers/operations/goUnlimited2022/configuration.ts
new file mode 100644
index 0000000000..d199e5b4e1
--- /dev/null
+++ b/packages/components/containers/offers/operations/goUnlimited2022/configuration.ts
@@ -0,0 +1,28 @@
+import { c } from 'ttag';
+
+import { FeatureCode } from '@proton/components/containers/features';
+import { CYCLE, PLANS } from '@proton/shared/lib/constants';
+
+import { OfferConfig } from '../../interface';
+import Layout from './Layout';
+
+const config: OfferConfig = {
+ ID: 'go-unlimited-2022',
+ ref: 'go_unlimited-modal-1',
+ featureCode: FeatureCode.OfferGoUnlimited2022,
+ canBeDisabled: true,
+ deals: [
+ {
+ planName: PLANS.BUNDLE,
+ planIDs: {
+ [PLANS.BUNDLE]: 1,
+ },
+ cycle: CYCLE.TWO_YEARS,
+ popular: true,
+ },
+ ],
+ layout: Layout,
+ getCTAContent: () => c('specialoffer: Action').t`Go Unlimited`,
+};
+
+export default config;
diff --git a/packages/components/containers/offers/operations/goUnlimited2022/index.ts b/packages/components/containers/offers/operations/goUnlimited2022/index.ts
new file mode 100644
index 0000000000..c2c6e6934d
--- /dev/null
+++ b/packages/components/containers/offers/operations/goUnlimited2022/index.ts
@@ -0,0 +1,2 @@
+export { default as goUnlimited2022Config } from './configuration';
+export { default as useGoUnlimited2022 } from './useOffer';
diff --git a/packages/components/containers/offers/operations/goUnlimited2022/useOffer.ts b/packages/components/containers/offers/operations/goUnlimited2022/useOffer.ts
new file mode 100644
index 0000000000..d2db8abc6a
--- /dev/null
+++ b/packages/components/containers/offers/operations/goUnlimited2022/useOffer.ts
@@ -0,0 +1,37 @@
+import { addDays, fromUnixTime, isBefore } from 'date-fns';
+
+import { useConfig, useSubscription, useUser } from '@proton/components/hooks';
+import { APPS, PLANS } from '@proton/shared/lib/constants';
+import { getPlan, hasBlackFridayDiscount, isTrial } from '@proton/shared/lib/helpers/subscription';
+import { isExternal } from '@proton/shared/lib/helpers/subscription';
+
+import useOfferFlags from '../../hooks/useOfferFlags';
+import { Operation } from '../../interface';
+import config from './configuration';
+
+const useOffer = (): Operation => {
+ const [user, userLoading] = useUser();
+ const [subscription, loading] = useSubscription();
+ const plan = getPlan(subscription);
+ const { isActive, loading: flagsLoading } = useOfferFlags(config);
+ const { APP_NAME } = useConfig();
+ const isMailOrAccountSettings = APP_NAME === APPS.PROTONMAIL || APP_NAME === APPS.PROTONACCOUNT;
+ const isLoading = flagsLoading || userLoading || loading;
+ const createDate = fromUnixTime(subscription?.CreateTime || subscription?.PeriodStart); // Subscription.CreateTime is not yet available
+ const notTrial = !isTrial(subscription);
+
+ const isValid =
+ [PLANS.MAIL, PLANS.VPN].includes(plan?.Name as PLANS) &&
+ notTrial &&
+ isBefore(createDate, addDays(new Date(), -7)) &&
+ !hasBlackFridayDiscount(subscription) &&
+ user.canPay &&
+ isMailOrAccountSettings &&
+ !user.isDelinquent &&
+ !isExternal(subscription) &&
+ isActive;
+
+ return { isValid, config, isLoading };
+};
+
+export default useOffer;
diff --git a/packages/components/containers/offers/operations/specialOffer2022/Layout.tsx b/packages/components/containers/offers/operations/specialOffer2022/Layout.tsx
new file mode 100644
index 0000000000..e78928bc2b
--- /dev/null
+++ b/packages/components/containers/offers/operations/specialOffer2022/Layout.tsx
@@ -0,0 +1,26 @@
+import { c } from 'ttag';
+
+import OfferFooter from '../../components/OfferFooter';
+import OfferHeader from '../../components/OfferHeader';
+import Deals from '../../components/deal/Deals';
+import { OfferLayoutProps } from '../../interface';
+
+const Layout = (props: OfferLayoutProps) => {
+ return (
+ <>
+ <OfferHeader {...props}>
+ <h1 className="h2 text-center text-bold">{c('specialoffer: Title')
+ .t`Save more with 1 year of Proton Unlimited`}</h1>
+ </OfferHeader>
+
+ <Deals {...props} />
+
+ <OfferFooter {...props}>
+ <p className="text-sm text-center mb1 color-weak">{c('specialoffer: Footer')
+ .t`This subscription will automatically renew every year at the same rate until it is cancelled.`}</p>
+ </OfferFooter>
+ </>
+ );
+};
+
+export default Layout;
diff --git a/packages/components/containers/offers/operations/specialOffer2022/configuration.ts b/packages/components/containers/offers/operations/specialOffer2022/configuration.ts
new file mode 100644
index 0000000000..d510cefe45
--- /dev/null
+++ b/packages/components/containers/offers/operations/specialOffer2022/configuration.ts
@@ -0,0 +1,25 @@
+import { FeatureCode } from '@proton/components/containers/features';
+import { CYCLE, PLANS } from '@proton/shared/lib/constants';
+
+import { OfferConfig } from '../../interface';
+import Layout from './Layout';
+
+const config: OfferConfig = {
+ ID: 'special-offer-2022',
+ ref: 'special_offer-modal-1',
+ featureCode: FeatureCode.OfferSpecialOffer2022,
+ canBeDisabled: true,
+ deals: [
+ {
+ planName: PLANS.BUNDLE,
+ planIDs: {
+ [PLANS.BUNDLE]: 1,
+ },
+ cycle: CYCLE.YEARLY,
+ popular: true,
+ },
+ ],
+ layout: Layout,
+};
+
+export default config;
diff --git a/packages/components/containers/offers/operations/specialOffer2022/index.ts b/packages/components/containers/offers/operations/specialOffer2022/index.ts
new file mode 100644
index 0000000000..615d25b91f
--- /dev/null
+++ b/packages/components/containers/offers/operations/specialOffer2022/index.ts
@@ -0,0 +1,2 @@
+export { default as specialOffer2022Config } from './configuration';
+export { default as useSpecialOffer2022 } from './useOffer';
diff --git a/packages/components/containers/offers/operations/specialOffer2022/useOffer.tsx b/packages/components/containers/offers/operations/specialOffer2022/useOffer.tsx
new file mode 100644
index 0000000000..ccc26444f9
--- /dev/null
+++ b/packages/components/containers/offers/operations/specialOffer2022/useOffer.tsx
@@ -0,0 +1,32 @@
+import { useConfig, useSubscription, useUser } from '@proton/components/hooks';
+import { APPS, PLANS } from '@proton/shared/lib/constants';
+import { getPlan, hasBlackFridayDiscount } from '@proton/shared/lib/helpers/subscription';
+import { isExternal } from '@proton/shared/lib/helpers/subscription';
+
+import useOfferFlags from '../../hooks/useOfferFlags';
+import { Operation } from '../../interface';
+import config from './configuration';
+
+const useOffer = (): Operation => {
+ const [user, userLoading] = useUser();
+ const [subscription, loading] = useSubscription();
+ const plan = getPlan(subscription);
+ const { isActive, loading: flagsLoading } = useOfferFlags(config);
+ const { APP_NAME } = useConfig();
+ const isMailOrAccountSettings = APP_NAME === APPS.PROTONMAIL || APP_NAME === APPS.PROTONACCOUNT;
+ const isLoading = flagsLoading || userLoading || loading;
+
+ const isValid =
+ plan?.Name === PLANS.BUNDLE &&
+ subscription?.Cycle === 1 &&
+ !hasBlackFridayDiscount(subscription) &&
+ user.canPay &&
+ isMailOrAccountSettings &&
+ !user.isDelinquent &&
+ !isExternal(subscription) &&
+ isActive;
+
+ return { isValid, config, isLoading };
+};
+
+export default useOffer;
diff --git a/packages/components/containers/payments/DiscountBadge.tsx b/packages/components/containers/payments/DiscountBadge.tsx
index 23784918ed..3f3a258b0c 100644
--- a/packages/components/containers/payments/DiscountBadge.tsx
+++ b/packages/components/containers/payments/DiscountBadge.tsx
@@ -7,9 +7,8 @@ import { APPS, BLACK_FRIDAY, COUPON_CODES } from '@proton/shared/lib/constants';
import { Badge } from '../../components';
-const vpnAppName = getAppName(APPS.PROTONVPN_SETTINGS);
-
const { BUNDLE, PROTONTEAM, BLACK_FRIDAY_2018, BLACK_FRIDAY_2019, BLACK_FRIDAY_2020 } = COUPON_CODES;
+const vpnAppName = getAppName(APPS.PROTONVPN_SETTINGS);
interface Props {
code: string;
diff --git a/packages/components/containers/payments/subscription/BlackFridayModal.scss b/packages/components/containers/payments/subscription/BlackFridayModal.scss
deleted file mode 100644
index e394a0c78c..0000000000
--- a/packages/components/containers/payments/subscription/BlackFridayModal.scss
+++ /dev/null
@@ -1,193 +0,0 @@
-@import '~@proton/styles/scss/config';
-
-$amount-size: 48;
-$price-size: 21;
-
-// overrides for dark/light mode => same design
-.blackfriday-mail-modal.modal {
- background: transparent url(./bg-mountains.svg) 50% 50%;
- background-size: cover;
- inline-size: rem(800);
- max-inline-size: rem(800);
-
- .modal-content-inner,
- .modal-title,
- .modal-header {
- color: white;
- }
-
- .modal-content-inner,
- .scrollshadow-static {
- background: transparent;
- }
-
- .scrollshadow-sticky {
- &--top {
- background-image: none;
- }
-
- &--bottom {
- inset-block-end: 0;
- background-image: none;
- }
- }
-
- .modal-close-icon {
- fill: white;
- }
-
- .blackfriday-currency-selector {
- // Overrides for specific case -_-v
- --border-norm: #505560;
- --background-norm: transparent;
- }
-
- .blackfriday-currency-selector button {
- color: white;
- }
-
- .modal-header {
- padding-block-end: 0.5em;
- }
-
- .modal-title {
- text-align: center;
- padding-inline-end: 0;
- }
-
- // no dark theme
- .blackfriday-plan {
- background-color: white;
- color: #262a33;
- }
-
- // VPN color override, if you want it to be applied in all cases, replace by .blackfriday-mail-modal
- &.blackfriday-mail-modal--vpn {
- background-image: none;
- background-color: #171f3b;
-
- --primary: #4da358 !important;
- --primary-contrast: white !important;
- --interaction-norm: #4da358 !important;
- --interaction-norm-hover: #3e8447 !important;
- --interaction-norm-active: #2e7037 !important;
- --interaction-norm-contrast: white !important;
-
- .blackfriday-standardPrice .price {
- text-decoration: none;
- }
- }
-
- @include respond-to($breakpoint-medium) {
- inline-size: 98%;
- max-inline-size: 98%;
- }
- @include respond-to(750, 'height') {
- max-block-size: 90%;
- }
- @include respond-to($breakpoint-small) {
- max-block-size: 98%;
- }
-}
-
-// then regular styles
-.blackfriday-mostPopular {
- border-start-start-radius: var(--border-radius-md);
- border-start-end-radius: var(--border-radius-md);
- border-end-end-radius: 0;
- border-end-start-radius: 0;
- inset-inline: 0;
- inset-block-end: 100%;
-}
-
-.blackfriday-percentage {
- inset-inline-start: 50%;
- transform: translateX(-50%) translateY(-50%);
-
- [dir='rtl'] & {
- transform: translateX(50%) translateY(-50%);
- }
-
- border-radius: 1em;
- padding-block: 0.1em;
- padding-inline: 0.75em;
- z-index: 1;
-}
-
-.blackfriday-monthly-price.blackfriday-monthly-price {
- display: block;
-
- .suffix {
- display: block;
- }
-
- .amount {
- font-size: em($amount-size);
- font-weight: var(--font-weight-bold);
- }
-
- .currency {
- font-size: em($price-size);
- font-weight: var(--font-weight-bold);
- }
-
- .decimal {
- font-size: em($price-size, $amount-size);
- font-weight: var(--font-weight-bold);
- }
-}
-
-.blackfriday-plan-container {
- flex: 1;
- max-inline-size: 25em;
-
- &:not(:first-child) {
- margin-inline-start: 1em;
- @include respond-to($breakpoint-small) {
- margin-inline-start: 0;
- }
- }
- @include respond-to($breakpoint-small) {
- max-inline-size: none;
- }
-}
-
-// needed only if several plans are aside
-// .blackfriday-plan-name {
-// min-block-size: 3em;
-// @include respond-to($breakpoint-small) {
-// min-block-size: 0;
-// }
-// }
-
-.blackfriday-protonDrive-free {
- display: inline-block;
- border-radius: 1em;
-}
-
-.blackfriday-standardPrice .price {
- text-decoration: line-through;
-}
-
-// some tiny modifications
-.blackfriday-plan-container--mostPopular {
- .blackfriday-plan {
- border-start-start-radius: 0;
- border-start-end-radius: 0;
- }
-
- .blackfriday-percentage {
- inset-block-start: -2.8em;
- z-index: 1;
- }
-}
-
-@include respond-to($breakpoint-small) {
- .blackfriday-plan-container {
- margin-block-end: 2em;
- }
-
- .blackfriday-plan-container--productPayer {
- margin-block-end: 0;
- }
-}
diff --git a/packages/components/containers/payments/subscription/BlackFridayModal.tsx b/packages/components/containers/payments/subscription/BlackFridayModal.tsx
deleted file mode 100644
index ce498ac8c1..0000000000
--- a/packages/components/containers/payments/subscription/BlackFridayModal.tsx
+++ /dev/null
@@ -1,364 +0,0 @@
-import { ReactNode, useEffect, useState } from 'react';
-
-import { c } from 'ttag';
-
-import { checkSubscription } from '@proton/shared/lib/api/payments';
-import { getAppName } from '@proton/shared/lib/apps/helper';
-import { APPS, CYCLE, DEFAULT_CURRENCY, DEFAULT_CYCLE } from '@proton/shared/lib/constants';
-import { getKnowledgeBaseUrl } from '@proton/shared/lib/helpers/url';
-import { Currency, Cycle, PlanIDs, SubscriptionCheckResponse } from '@proton/shared/lib/interfaces';
-
-import { Button, CircleLoader, FormModal, Href, Info, Price } from '../../../components';
-import { classnames } from '../../../helpers';
-import { useApi, useLoading } from '../../../hooks';
-import CurrencySelector from '../CurrencySelector';
-import { EligibleOffer } from '../interface';
-
-import './BlackFridayModal.scss';
-
-const { MONTHLY, YEARLY, TWO_YEARS } = CYCLE;
-
-export interface Bundle {
- planIDs: PlanIDs;
- name: string;
- cycle: Cycle;
- couponCode?: string;
- percentage?: number;
- popular?: boolean;
-}
-
-interface Props {
- onSelect: (params: {
- offer: EligibleOffer;
- plan: string;
- cycle: Cycle;
- currency: Currency;
- couponCode?: string | null;
- }) => void;
- className?: string;
- onClose?: () => void;
- offer: EligibleOffer;
-}
-
-interface Pricing {
- [index: number]: {
- withCoupon: number;
- withoutCoupon: number;
- withoutCouponMonthly: number;
- };
-}
-
-const BlackFridayModal = ({ offer, onSelect, ...rest }: Props) => {
- const api = useApi();
- const [loading, withLoading] = useLoading();
- const [currency, updateCurrency] = useState<Currency>(DEFAULT_CURRENCY);
- const [pricing, updatePricing] = useState<Pricing>({});
-
- const isBlackFridayOffer = offer.name === 'black-friday';
- const isProductPayerOffer = offer.name === 'product-payer';
-
- const title = isBlackFridayOffer
- ? c('blackfriday: VPNspecialoffer Title').t`Get the special year-end offer on our Premium VPN PLUS plan`
- : c('blackfriday Title').t`Save more when combining Mail and VPN`;
- const driveAppName = getAppName(APPS.PROTONDRIVE);
-
- const DEAL_TITLE = {
- [MONTHLY]: c('blackfriday: VPNspecialoffer Title').t`1 month`,
- [YEARLY]: c('blackfriday: VPNspecialoffer Title').t`12 months`,
- [TWO_YEARS]: c('blackfriday: VPNspecialoffer Title').t`24 months`,
- };
-
- const BILLED_DESCRIPTION = ({ cycle, amount, notice }: { cycle: Cycle; amount: ReactNode; notice: number }) => {
- const supNotice = <sup key="notice">{notice}</sup>;
- if (cycle === MONTHLY) {
- return c('blackfriday: VPNspecialoffer Title').jt`Billed at ${amount} for the first month.${supNotice}`;
- }
- if (cycle === YEARLY) {
- return c('blackfriday: VPNspecialoffer Title').jt`Billed at ${amount} for the first year.${supNotice}`;
- }
- if (cycle === TWO_YEARS) {
- return c('blackfriday: VPNspecialoffer Title').jt`Billed at ${amount} for the first 2 years.${supNotice}`;
- }
- return null;
- };
-
- const AFTER_INFO = ({ amount, notice }: { amount: ReactNode; notice: number }) => {
- if (notice === 1) {
- return c('blackfriday: VPNspecialoffer Title')
- .jt`(${notice}) Renews after 2 years at a standard discounted 2-year price of ${amount} (33% discount)`;
- }
- if (notice === 2) {
- return c('blackfriday: VPNspecialoffer Title')
- .jt`(${notice}) Renews after 1 year at a standard discounted annual price of ${amount} (20% discount)`;
- }
- if (notice === 3) {
- return c('blackfriday: VPNspecialoffer Title')
- .jt`(${notice}) Renews after 1 month at a standard monthly price of ${amount}`;
- }
- return null;
- };
-
- const getCTA = (popular?: boolean) => {
- if (isProductPayerOffer) {
- return c('blackfriday Action').t`Get the offer`;
- }
- if (popular) {
- return c('blackfriday: VPNspecialoffer Action').t`Get the deal now`;
- }
- return c('blackfriday: VPNspecialoffer Action').t`Get the deal`;
- };
-
- const getFooter = () => {
- if (isProductPayerOffer) {
- return (
- <p className="text-xs color-weak text-center">
- (1){' '}
- {c('blackfriday Info')
- .t`This subscription will automatically renew after 2 years at the same rate until it is cancelled.`}
- </p>
- );
- }
-
- const standardMonthlyPricing = (
- <Price key="standard-pricing" currency={currency} suffix={c('Suffix for price').t`/month`}>
- {pricing[2]?.withoutCoupon || 0}
- </Price>
- );
-
- return (
- <>
- <div className="text-xs mt1 mb0 color-weak text-center">
- <Href url="https://protonvpn.com/support/year-end-offer-terms-2021">
- {c('blackfriday: VPNspecialoffer Info').t`Special offer Terms and Conditions`}
- </Href>
- </div>
- <p className="text-xs mt0 mb0 color-weak text-center">{c('blackfriday: VPNspecialoffer Info')
- .jt`Discounts are based on standard monthly pricing of ${standardMonthlyPricing}`}</p>
- {offer.plans.map((_, index) => {
- const key = `${index}`;
- const { withoutCoupon = 0 } = pricing[index] || {};
- const amount = (
- <Price key={key} currency={currency} isDisplayedInSentence>
- {withoutCoupon}
- </Price>
- );
- return (
- <p key={key} className="text-xs mt0 mb0 color-weak text-center">
- {AFTER_INFO({ notice: index + 1, amount })}
- </p>
- );
- })}
- </>
- );
- };
-
- const getBundlePrices = async () => {
- try {
- const result = await Promise.all(
- offer.plans.map(({ planIDs, cycle = DEFAULT_CYCLE, couponCode }) => {
- return Promise.all([
- api<SubscriptionCheckResponse>(
- checkSubscription({
- PlanIDs: planIDs,
- CouponCode: couponCode,
- Currency: currency,
- Cycle: cycle,
- })
- ),
- api<SubscriptionCheckResponse>(
- checkSubscription({
- PlanIDs: planIDs,
- Currency: currency,
- Cycle: cycle,
- })
- ),
- api<SubscriptionCheckResponse>(
- checkSubscription({
- PlanIDs: planIDs,
- Currency: currency,
- Cycle: MONTHLY,
- })
- ),
- ]);
- })
- );
-
- updatePricing(
- result.reduce<Pricing>((acc, [withCoupon, withoutCoupon, withoutCouponMonthly], index) => {
- acc[index] = {
- withCoupon: withCoupon.Amount + (withCoupon.CouponDiscount || 0),
- withoutCoupon: withoutCoupon.Amount + (withoutCoupon.CouponDiscount || 0), // BUNDLE discount can be applied
- withoutCouponMonthly: withoutCouponMonthly.Amount,
- };
- return acc;
- }, {})
- );
- } catch (error: any) {
- rest.onClose?.();
- throw error;
- }
- };
-
- useEffect(() => {
- withLoading(getBundlePrices());
- }, []);
-
- return (
- <FormModal
- title={title}
- loading={loading}
- footer={null}
- className={classnames(['blackfriday-mail-modal', offer.isVPNOnly && 'blackfriday-mail-modal--vpn'])}
- {...rest}
- >
- {loading ? (
- <div className="text-center">
- <CircleLoader size="large" className="mxauto flex mb2" />
- </div>
- ) : (
- <>
- <div
- className={classnames([
- 'flex flex-nowrap flex-justify-space-around on-mobile-flex-column',
- isProductPayerOffer ? 'mt2' : 'mt4',
- ])}
- >
- {offer.plans.map(({ name, plan, cycle, planIDs, popular, couponCode }, index) => {
- const key = `${index}`;
- const { withCoupon = 0, withoutCouponMonthly = 0 } = pricing[index] || {};
- const withCouponMonthly = withCoupon / cycle;
- const percentage = 100 - Math.round((withCouponMonthly * 100) / withoutCouponMonthly);
- const monthlyPrice = (
- <Price
- currency={currency}
- className="blackfriday-monthly-price"
- suffix={c('blackfriday: VPNspecialoffer info').t`per month`}
- isDisplayedInSentence
- >
- {withCoupon / cycle}
- </Price>
- );
- const amountDue = (
- <Price key={key} currency={currency} isDisplayedInSentence>
- {withCoupon}
- </Price>
- );
- const regularPrice = (
- <span className={classnames([offer.isVPNOnly === false && 'text-strike'])} key={key}>
- <Price currency={currency}>{withoutCouponMonthly * cycle}</Price>
- </span>
- );
-
- return (
- <div
- key={key}
- className={classnames([
- 'relative flex blackfriday-plan-container',
- popular && 'blackfriday-plan-container--mostPopular',
- isProductPayerOffer && 'blackfriday-plan-container--productPayer',
- ])}
- >
- {percentage ? (
- <span
- className={classnames([
- 'text-uppercase text-bold absolute blackfriday-percentage text-center',
- popular ? 'bg-danger' : 'bg-primary',
- ])}
- >
- {c('blackfriday: VPNspecialoffer Info').jt`Save ${percentage}%`}
- </span>
- ) : null}
- {popular ? (
- <div className="text-uppercase absolute text-bold bg-primary pt0-75 pb0-5 mt0 mb0 text-center blackfriday-mostPopular">
- {offer.isVPNOnly
- ? c('blackfriday Title').t`Best deal`
- : c('blackfriday Title').t`Most popular`}
- </div>
- ) : null}
- <div
- className={classnames([
- 'blackfriday-plan w100 border p1 mb1 flex flex-column flex-align-items-center flex-justify-end',
- popular && 'border-color-primary',
- ])}
- >
- <div className="blackfriday-plan-namePeriod">
- <strong className="blackfriday-plan-name block text-center text-lg mt0-5 mb0">
- {name}
- </strong>
- <strong className="block text-center">{DEAL_TITLE[cycle]}</strong>
- </div>
- <div className="mb1 mt1 text-center lh130">{monthlyPrice}</div>
- <div className="text-center flex-item-fluid-auto">
- {Object.keys(planIDs).length > 1 ? (
- <>
- <p className="m0">{c('blackfriday Info').t`Includes`}</p>
- <p className={classnames(['mt0', popular && 'color-success'])}>
- <strong className="blackfriday-protonDrive-productName ml0-25">
- {driveAppName} {c('blackfriday Info').t`beta`}
- </strong>
- <Info
- buttonClass="inline-flex color-inherit ml0-25 mb0-1"
- url={getKnowledgeBaseUrl(
- '/protondrive-early-access/?utm_campaign=ww-en-2c-mail-coms_inapp-protondrive_learn_more&utm_source=webmail&utm_medium=app_ad&utm_content=tooltip_v4'
- )}
- />
- </p>
- </>
- ) : null}
- </div>
- <Button
- color="norm"
- shape={popular || isProductPayerOffer ? 'solid' : 'outline'}
- className={classnames(['mb1 text-uppercase'])}
- onClick={() => {
- rest.onClose?.();
- onSelect({ offer, plan, cycle, currency, couponCode });
- }}
- >
- {getCTA(popular)}
- </Button>
- {offer.isVPNOnly ? (
- <>
- <small className="text-bold text-center color-weak">
- {BILLED_DESCRIPTION({
- cycle,
- amount: amountDue,
- notice: index + 1,
- })}
- </small>
- </>
- ) : (
- <>
- <small className="text-bold">
- {BILLED_DESCRIPTION({
- cycle,
- amount: amountDue,
- notice: index + 1,
- })}
- </small>
- <small className="color-weak blackfriday-standardPrice mb1">{c(
- 'blackfriday Info'
- ).jt`Standard price: ${regularPrice}`}</small>
- </>
- )}
- </div>
- </div>
- );
- })}
- </div>
- <div className="mb1 text-center blackfriday-currency-selector">
- <CurrencySelector
- id="currency-select"
- mode="buttons"
- currency={currency}
- onSelect={updateCurrency}
- />
- </div>
- {getFooter()}
- </>
- )}
- </FormModal>
- );
-};
-
-export default BlackFridayModal;
diff --git a/packages/components/containers/payments/subscription/index.ts b/packages/components/containers/payments/subscription/index.ts
index 500200df7a..f7bd1e7855 100644
--- a/packages/components/containers/payments/subscription/index.ts
+++ b/packages/components/containers/payments/subscription/index.ts
@@ -1,7 +1,6 @@
export { default as SubscriptionModal } from './SubscriptionModal';
export { default as CancelSubscriptionSection } from './CancelSubscriptionSection';
export { default as YourPlanSection } from './YourPlanSection';
-export { default as BlackFridayModal } from './BlackFridayModal';
export { default as SubscriptionCheckout } from './SubscriptionCheckout';
export { default as PlanSelection } from './PlanSelection';
export { default as SubscriptionModalProvider } from './SubscriptionModalProvider';
diff --git a/packages/components/hooks/index.ts b/packages/components/hooks/index.ts
index d4fcc2257e..bd962e3466 100644
--- a/packages/components/hooks/index.ts
+++ b/packages/components/hooks/index.ts
@@ -10,7 +10,6 @@ export { default as useApps } from './useApps';
export { default as useAuthentication } from './useAuthentication';
export { default as useAutocompleteAriaProps } from './useAutocompleteAriaProps';
export { default as useBeforeUnload } from './useBeforeUnload';
-export { default as useBlackFridayPeriod } from './useBlackFridayPeriod';
export { default as useCache } from './useCache';
export { default as useCachedModelResult } from './useCachedModelResult';
export { default as useCalendars, useGetCalendars } from './useCalendars';
@@ -94,7 +93,6 @@ export { default as usePlans } from './usePlans';
export { default as usePremiumDomains } from './usePremiumDomains';
export { default as usePreventLeave, PreventLeaveProvider } from './usePreventLeave';
export { default as usePrimaryRecoverySecret } from './usePrimaryRecoverySecret';
-export { default as useProductPayerPeriod } from './useProductPayerPeriod';
export { default as usePromiseResult } from './usePromiseResult';
export { default as useRecoverySecrets } from './useRecoverySecrets';
export { default as useRecoveryStatus } from './useRecoveryStatus';
@@ -104,6 +102,7 @@ export { default as useSortedList, useSortedListAsync, useMultiSortedList } from
export { default as useSpotlightOnFeature } from './useSpotlightOnFeature';
export { default as useStep } from './useStep';
export { default as useSubscription } from './useSubscription';
+export { default as useLastSubscriptionEnd } from './useLastSubscriptionEnd';
export { default as useSvgGraphicsBbox } from './useSvgGraphicsBbox';
export { default as useApiEnvironmentConfig } from './useApiEnvironmentConfig';
export { default as useRelocalizeText } from './useRelocalizeText';
diff --git a/packages/components/hooks/useBlackFridayPeriod.ts b/packages/components/hooks/useBlackFridayPeriod.ts
deleted file mode 100644
index 7ba674502c..0000000000
--- a/packages/components/hooks/useBlackFridayPeriod.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { useEffect, useState } from 'react';
-
-import { isBlackFridayPeriod } from '@proton/shared/lib/helpers/blackfriday';
-
-const EVERY_MINUTE = 60 * 1000;
-
-const useBlackFridayPeriod = () => {
- const [blackFriday, setBlackFriday] = useState(isBlackFridayPeriod);
-
- useEffect(() => {
- const intervalID = setInterval(() => {
- setBlackFriday(isBlackFridayPeriod());
- }, EVERY_MINUTE);
-
- return () => {
- clearInterval(intervalID);
- };
- }, []);
-
- return blackFriday;
-};
-
-export default useBlackFridayPeriod;
diff --git a/packages/components/hooks/useLastSubscriptionEnd.ts b/packages/components/hooks/useLastSubscriptionEnd.ts
new file mode 100644
index 0000000000..2edf8296a7
--- /dev/null
+++ b/packages/components/hooks/useLastSubscriptionEnd.ts
@@ -0,0 +1,40 @@
+import { useEffect, useState } from 'react';
+
+import useIsMounted from '@proton/hooks/useIsMounted';
+import { getLastCancelledSubscription } from '@proton/shared/lib/api/payments';
+import { LatestSubscription } from '@proton/shared/lib/interfaces';
+
+import useApi from './useApi';
+import useLoading from './useLoading';
+import useUser from './useUser';
+
+const useLastSubscriptionEnd = (): [latestSubscription: number, loading: boolean] => {
+ const api = useApi();
+ const [loading, withLoading] = useLoading();
+ const [{ isPaid }] = useUser();
+ const [latestSubscription, setLatestSubscription] = useState<number>(0);
+ const isMounted = useIsMounted();
+
+ useEffect(() => {
+ if (isPaid) {
+ setLatestSubscription(0);
+ return;
+ }
+ const run = async () => {
+ try {
+ const { LastSubscriptionEnd = 0 } = await api<LatestSubscription>(getLastCancelledSubscription());
+
+ if (isMounted()) {
+ setLatestSubscription(LastSubscriptionEnd);
+ }
+ } catch (e) {
+ // Ignore
+ }
+ };
+ void withLoading(run());
+ }, [isPaid]);
+
+ return [latestSubscription, loading];
+};
+
+export default useLastSubscriptionEnd;
diff --git a/packages/components/hooks/useProductPayerPeriod.ts b/packages/components/hooks/useProductPayerPeriod.ts
deleted file mode 100644
index b637108c53..0000000000
--- a/packages/components/hooks/useProductPayerPeriod.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { useEffect, useState } from 'react';
-
-import { isProductPayerPeriod } from '@proton/shared/lib/helpers/blackfriday';
-
-const EVERY_MINUTE = 60 * 1000;
-
-const useProductPayerPeriod = () => {
- const [productPayer, setProductPayer] = useState(isProductPayerPeriod());
-
- useEffect(() => {
- const intervalID = setInterval(() => {
- setProductPayer(isProductPayerPeriod());
- }, EVERY_MINUTE);
-
- return () => {
- clearInterval(intervalID);
- };
- }, []);
-
- return productPayer;
-};
-
-export default useProductPayerPeriod;
diff --git a/packages/shared/lib/constants.ts b/packages/shared/lib/constants.ts
index 6810a4319b..05d6e1f40f 100644
--- a/packages/shared/lib/constants.ts
+++ b/packages/shared/lib/constants.ts
@@ -494,7 +494,7 @@ export enum CYCLE {
}
export const BLACK_FRIDAY = {
- COUPON_CODE: 'BF2021',
+ COUPON_CODE: 'BF2022',
START: new Date(Date.UTC(2021, 10, 1, 5)), // 6 AM CET
END: new Date(Date.UTC(2022, 0, 1, 17)), // 6 PM CET
CYBER_START: new Date(Date.UTC(2020, 10, 30, 6)),
@@ -644,6 +644,7 @@ export enum COUPON_CODES {
BLACK_FRIDAY_2018 = 'TWO4ONE2018',
BLACK_FRIDAY_2019 = 'BF2019',
BLACK_FRIDAY_2020 = 'BF2020',
+ BLACK_FRIDAY_2022 = 'BF2022',
LIFETIME = 'LIFETIME',
VISIONARYFOREVER = 'VISIONARYFOREVER',
REFERRAL = 'REFERRAL',
diff --git a/packages/shared/lib/helpers/blackfriday.ts b/packages/shared/lib/helpers/blackfriday.ts
index 6ac7380992..329ab1087b 100644
--- a/packages/shared/lib/helpers/blackfriday.ts
+++ b/packages/shared/lib/helpers/blackfriday.ts
@@ -1,8 +1,6 @@
-import { isAfter, isWithinInterval } from 'date-fns';
+import { isWithinInterval } from 'date-fns';
-import { BLACK_FRIDAY, COUPON_CODES, PRODUCT_PAYER } from '../constants';
-import { Subscription } from '../interfaces';
-import { hasAddons, hasMailPlus, hasMailProfessional, hasVpnBasic, hasVpnPlus } from './subscription';
+import { BLACK_FRIDAY } from '../constants';
export const isBlackFridayPeriod = () => {
return isWithinInterval(new Date(), { start: BLACK_FRIDAY.START, end: BLACK_FRIDAY.END });
@@ -11,22 +9,3 @@ export const isBlackFridayPeriod = () => {
export const isCyberMonday = () => {
return isWithinInterval(new Date(), { start: BLACK_FRIDAY.CYBER_START, end: BLACK_FRIDAY.CYBER_END });
};
-
-export const isProductPayerPeriod = () => {
- return isAfter(new Date(), PRODUCT_PAYER.START);
-};
-
-export const isProductPayer = (subscription: Subscription) => {
- if (!subscription) {
- return false;
- }
-
- const couponCode = subscription?.CouponCode || '';
- const isPaying = hasMailPlus(subscription) || hasVpnBasic(subscription) || hasVpnPlus(subscription);
- const noPro = !hasMailProfessional(subscription);
- const noBundle = !(hasMailPlus(subscription) && hasVpnPlus(subscription));
- const noBFCoupon = ![BLACK_FRIDAY.COUPON_CODE, COUPON_CODES.BLACK_FRIDAY_2020].includes(couponCode);
- const noAddons = !hasAddons(subscription);
-
- return isPaying && noPro && noBundle && noBFCoupon && noAddons;
-};
diff --git a/packages/shared/lib/helpers/subscription.ts b/packages/shared/lib/helpers/subscription.ts
index dbb6066ee0..cd893a697b 100644
--- a/packages/shared/lib/helpers/subscription.ts
+++ b/packages/shared/lib/helpers/subscription.ts
@@ -148,3 +148,11 @@ export const getCycleDiscount = (cycle: Cycle, planName: PLANS | ADDON_NAMES, pl
const percentage = (originalPrice - normalisedPrice) / originalPrice;
return Math.round(percentage * 100);
};
+
+export const isExternal = (subscription: Subscription | undefined) => {
+ return !!subscription?.External;
+};
+
+export const hasBlackFridayDiscount = (subscription: Subscription | undefined) => {
+ return subscription?.CouponCode === COUPON_CODES.BLACK_FRIDAY_2022;
+};
diff --git a/packages/shared/lib/interfaces/Subscription.ts b/packages/shared/lib/interfaces/Subscription.ts
index f8d3aa3c4f..422d746a1a 100644
--- a/packages/shared/lib/interfaces/Subscription.ts
+++ b/packages/shared/lib/interfaces/Subscription.ts
@@ -35,17 +35,25 @@ export interface Plan {
State: number;
}
+enum External {
+ Default = 0,
+ iOS = 1,
+ Android = 2,
+}
+
export interface Subscription {
ID: string;
InvoiceID: string;
Cycle: Cycle;
PeriodStart: number;
PeriodEnd: number;
+ CreateTime: number;
CouponCode: null | string;
Currency: Currency;
Amount: number;
RenewAmount: number;
Plans: Plan[];
+ External: External;
}
export interface SubscriptionModel extends Subscription {