diff options
author | Richard <richard@protonmail.com> | 2022-09-07 16:53:07 +0300 |
---|---|---|
committer | Richard <richard@protonmail.com> | 2022-09-28 11:01:54 +0300 |
commit | d08ede1944f50d8a463afdfb9ed1b866d38dba1a (patch) | |
tree | 176a7ef34f573137122e6dc185db88150103bab1 | |
parent | a1cc98a48f144a6ed0b217625365a50e715c5000 (diff) |
Add new offer structureproton-mail@5.0.9.4
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 { |