- {hasPaidMail && mailPlanName !== 'visionary' ? (
-
- {c('Action').t`Customize`}
-
- ) : null}
-
+ .t`To manage your subscription, including billing frequency and currency, or to switch to another plan, click on Manage subscription.`}
+
+
+
ProtonMail plan
- {hasPaidMail
- ? PLAN_NAMES[mailPlanName]
- : addresses.length
- ? c('Plan').t`Free`
- : c('Info').t`Not activated`}
+ {hasPaidMail ? (
+ PLAN_NAMES[mailPlanName]
+ ) : hasAddresses ? (
+ c('Plan').t`Free`
+ ) : (
+ {c('Info').t`Not activated`}
+ )}
-
+
+ {hasAddresses || mailPlanName === 'visionary' ? (
+ {c('Action').t`Manage subscription`}
+ ) : null}
+
{mailAddons.map((props, index) => (
@@ -166,14 +171,23 @@ const SubscriptionSection = ({ permission }) => {
{hasPaidVpn ? PLAN_NAMES[vpnPlanName] : c('Plan').t`Free`}
-
+
+ {c('Action').t`Manage subscription`}
+
{vpnAddons.map((props, index) => (
))}
)}
+ {isPaid ? (
+
+ {c('Action')
+ .t`Cancel subscription`}
+
+ ) : null}
+
>
);
};
diff --git a/containers/payments/subscription/SubscriptionTable.js b/containers/payments/subscription/SubscriptionTable.js
new file mode 100644
index 000000000..9441ad234
--- /dev/null
+++ b/containers/payments/subscription/SubscriptionTable.js
@@ -0,0 +1,109 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Button, classnames, LinkButton, Icon } from 'react-components';
+import { c } from 'ttag';
+
+const SubscriptionTable = ({
+ plans,
+ onSelect,
+ currentPlanIndex = 0,
+ mostPopularIndex = 0,
+ currentPlan = c('Title for subscription plan').t`Current plan`,
+ selected = c('Info').t`Selected`,
+ select = c('Action').t`Select`,
+ disabled = false
+}) => {
+ return (
+
+
+ {plans.map(({ name, title, price, imageSrc, description, features = [], canCustomize }, index) => {
+ return (
+
+
+
+
{description}
+
+ {features.map(({ icon, content }, index) => {
+ return (
+ -
+
+ {content}
+
+ );
+ })}
+
+
+
+
+ );
+ })}
+
+
+ );
+};
+
+SubscriptionTable.propTypes = {
+ disabled: PropTypes.bool,
+ currentPlan: PropTypes.string,
+ plans: PropTypes.arrayOf(
+ PropTypes.shape({
+ name: PropTypes.string.isRequired,
+ title: PropTypes.string.isRequired,
+ price: PropTypes.node.isRequired,
+ imageSrc: PropTypes.string.isRequired,
+ description: PropTypes.node.isRequired,
+ features: PropTypes.arrayOf(
+ PropTypes.shape({
+ icon: PropTypes.string.isRequired,
+ content: PropTypes.node.isRequired
+ })
+ ).isRequired
+ })
+ ),
+ onSelect: PropTypes.func.isRequired,
+ currentPlanIndex: PropTypes.number,
+ mostPopularIndex: PropTypes.number,
+ selected: PropTypes.string,
+ select: PropTypes.string
+};
+
+export default SubscriptionTable;
diff --git a/containers/payments/subscription/SubscriptionTable.scss b/containers/payments/subscription/SubscriptionTable.scss
new file mode 100644
index 000000000..395b5362b
--- /dev/null
+++ b/containers/payments/subscription/SubscriptionTable.scss
@@ -0,0 +1,76 @@
+.subscriptionTable-plan[data-current-plan="true"] {
+ border-left: 2px solid $pm-primary;
+ border-right: 2px solid $pm-primary;
+ border-bottom: 2px solid $pm-primary;
+ background-color: var(--bgcolor-input, $pm-global-light);
+}
+
+.subscriptionTable-currentPlan-container {
+ background-color: $pm-primary;
+ color: $white;
+ position: absolute;
+ top: -21px;
+ left: -2px;
+ right: -2px;
+ border-radius: $global-border-radius $global-border-radius 0 0;
+}
+
+.subscriptionTable-header {
+ min-height: 22rem;
+}
+
+.subscriptionTable-description,
+.subscriptionTable-footer {
+ min-height: 7rem;
+}
+
+.subscriptionTable-image-container {
+ height: 10rem;
+}
+
+.subscriptionPrices-monthly,
+.subscriptionPlan-customize {
+ .amount,
+ .currency {
+ font-size: 3rem;
+ line-height: 1;
+ }
+
+}
+.subscriptionPlan-customize {
+ .prefix {
+ margin-left: auto;
+ }
+ .price {
+ font-size: 1.4rem;
+ line-height: 1;
+ }
+ .amount,
+ .currency,
+ .prefix,
+ .subscriptionPlan-customize-included {
+ font-size: 2.2rem;
+ }
+}
+
+.subscriptionTable-feature {
+ word-break: break-word;
+}
+@supports (-webkit-hyphens: auto) or (-ms-hyphens: auto) or (hyphens: auto) {
+ .subscriptionTable-feature {
+ word-break: normal;
+ @include vendor-prefix(hyphens, auto, webkit ms);
+ }
+}
+
+.subscriptionTable-mostPopular {
+ position: absolute;
+ top: .5rem;
+ left: 0;
+ right: 0;
+}
+
+.subscriptionCheckout-container {
+ position: sticky;
+ top: 1em;
+}
diff --git a/containers/payments/subscription/SubscriptionThanks.js b/containers/payments/subscription/SubscriptionThanks.js
new file mode 100644
index 000000000..a4c934c4f
--- /dev/null
+++ b/containers/payments/subscription/SubscriptionThanks.js
@@ -0,0 +1,59 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { c } from 'ttag';
+import { PrimaryButton, useConfig, Href } from 'react-components';
+import { CLIENT_TYPES, PAYMENT_METHOD_TYPES } from 'proton-shared/lib/constants';
+import mailLandscapeSvg from 'design-system/assets/img/pm-images/landscape.svg';
+import vpnLandscapeSvg from 'design-system/assets/img/pv-images/landscape.svg';
+import appStoreSvg from 'design-system/assets/img/shared/app-store.svg';
+import playStoreSvg from 'design-system/assets/img/shared/play-store.svg';
+
+const SubscriptionThanks = ({ method = '', onClose }) => {
+ const { CLIENT_TYPE } = useConfig();
+
+ return (
+ <>
+
+ {[PAYMENT_METHOD_TYPES.CASH, PAYMENT_METHOD_TYPES.BITCOIN].includes(method)
+ ? c('Info').t`Your account will be updated once the payment is cleared.`
+ : c('Info').t`Your account has been successfully updated.`}
+
+
{c('Info')
+ .t`Download your favorite app today and take privacy with you everywhere you go.`}
+
+

+
+
+
+
+
+
+
+
+
+
+
{c('Action').t`Close`}
+
+ >
+ );
+};
+
+SubscriptionThanks.propTypes = {
+ method: PropTypes.string,
+ onClose: PropTypes.func.isRequired
+};
+
+export default SubscriptionThanks;
diff --git a/containers/payments/subscription/SubscriptionUpgrade.js b/containers/payments/subscription/SubscriptionUpgrade.js
new file mode 100644
index 000000000..0bb7efcce
--- /dev/null
+++ b/containers/payments/subscription/SubscriptionUpgrade.js
@@ -0,0 +1,16 @@
+import React from 'react';
+import { c } from 'ttag';
+import { FullLoader } from 'react-components';
+
+const SubscriptionUpgrade = () => {
+ return (
+ <>
+
{c('Info')
+ .t`Your account is being upgraded, this may take up to 30 seconds.`}
+
+
{c('Info').t`Thank you for supporting our mission.`}
+ >
+ );
+};
+
+export default SubscriptionUpgrade;
diff --git a/containers/payments/subscription/UnsubscribeButton.js b/containers/payments/subscription/UnsubscribeButton.js
new file mode 100644
index 000000000..fc83303a9
--- /dev/null
+++ b/containers/payments/subscription/UnsubscribeButton.js
@@ -0,0 +1,77 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {
+ Button,
+ DowngradeModal,
+ LossLoyaltyModal,
+ useApi,
+ useUser,
+ useNotifications,
+ useLoading,
+ useModals,
+ useEventManager,
+ useOrganization
+} from 'react-components';
+import { c } from 'ttag';
+import { deleteSubscription } from 'proton-shared/lib/api/payments';
+
+import { isLoyal } from 'proton-shared/lib/helpers/organization';
+
+const DOWNGRADING_ID = 'downgrading-notification';
+
+const UnsubscribeButton = ({ className, children }) => {
+ const [user] = useUser();
+ const [organization] = useOrganization();
+ const { createNotification, hideNotification } = useNotifications();
+ const { createModal } = useModals();
+ const api = useApi();
+ const { call } = useEventManager();
+ const [loading, withLoading] = useLoading();
+
+ const handleUnsubscribe = async () => {
+ createNotification({
+ type: 'info',
+ text: c('State').t`Downgrading your account, please wait`,
+ id: DOWNGRADING_ID,
+ expiration: 99999
+ });
+ try {
+ await api(deleteSubscription());
+ await call();
+ createNotification({ text: c('Success').t`You have successfully unsubscribed` });
+ } finally {
+ hideNotification(DOWNGRADING_ID);
+ }
+ };
+
+ const handleClick = async () => {
+ if (user.isFree) {
+ return createNotification({ type: 'error', text: c('Info').t`You already have a free account` });
+ }
+
+ await new Promise((resolve, reject) => {
+ createModal(
);
+ });
+
+ if (isLoyal(organization)) {
+ await new Promise((resolve, reject) => {
+ createModal(
);
+ });
+ }
+
+ return handleUnsubscribe();
+ };
+
+ return (
+
+ );
+};
+
+UnsubscribeButton.propTypes = {
+ className: PropTypes.string,
+ children: PropTypes.node.isRequired
+};
+
+export default UnsubscribeButton;
diff --git a/containers/payments/subscription/Upgrading.js b/containers/payments/subscription/Upgrading.js
index 2d7e050b3..94f345703 100644
--- a/containers/payments/subscription/Upgrading.js
+++ b/containers/payments/subscription/Upgrading.js
@@ -4,10 +4,10 @@ import { Alert, Loader } from 'react-components';
const Upgrading = () => {
return (
- <>
+
{c('Info').t`Your account is being upgraded, this may take up to 30 seconds.`}
- >
+
);
};
diff --git a/containers/payments/subscription/UpsellSubscription.js b/containers/payments/subscription/UpsellSubscription.js
new file mode 100644
index 000000000..eff3b9c9c
--- /dev/null
+++ b/containers/payments/subscription/UpsellSubscription.js
@@ -0,0 +1,131 @@
+import React from 'react';
+import {
+ useUser,
+ useSubscription,
+ useModals,
+ usePlans,
+ PrimaryButton,
+ Loader,
+ useAddresses,
+ useOrganization
+} from 'react-components';
+import { hasMailPlus, hasVpnBasic, switchPlan, getPlanIDs } from 'proton-shared/lib/helpers/subscription';
+import { DEFAULT_CURRENCY, DEFAULT_CYCLE, PLAN_SERVICES, PLANS } from 'proton-shared/lib/constants';
+import { toMap } from 'proton-shared/lib/helpers/object';
+import { c } from 'ttag';
+
+import NewSubscriptionModal from './NewSubscriptionModal';
+
+const UpsellSubscription = () => {
+ const [{ hasPaidMail, hasPaidVpn }, loadingUser] = useUser();
+ const [subscription, loadingSubscription] = useSubscription();
+ const [organization, loadingOrganization] = useOrganization();
+ const [plans, loadingPlans] = usePlans();
+ const { Currency = DEFAULT_CURRENCY, Cycle = DEFAULT_CYCLE } = subscription || {};
+ const isFreeMail = !hasPaidMail;
+ const isFreeVpn = !hasPaidVpn;
+ const { createModal } = useModals();
+ const [addresses, loadingAddresses] = useAddresses();
+ const hasAddresses = Array.isArray(addresses) && addresses.length > 0;
+
+ if (loadingUser || loadingSubscription || loadingPlans || loadingAddresses || loadingOrganization) {
+ return
;
+ }
+
+ const plansMap = toMap(plans, 'Name');
+ const planIDs = getPlanIDs(subscription);
+
+ return [
+ isFreeMail &&
+ hasAddresses && {
+ title: c('Title').t`Upgrade to ProtonMail Plus`,
+ description: c('Title')
+ .t`Upgrade to ProtonMail Plus to get more storage, more email addresses and more ways to customize your mailbox with folders, labels and filters. Upgrading to a paid plan also allows you to get early access to new products.`,
+ upgradeButton: (
+
{
+ createModal(
+
+ );
+ }}
+ >{c('Action').t`Upgrade`}
+ )
+ },
+ hasMailPlus(subscription) &&
+ hasAddresses && {
+ title: c('Title').t`Upgrade to ProtonMail Professional`,
+ description: c('Title')
+ .t`Ugrade to ProtonMail Professional to get multi-user support. This allows you to use ProtonMail host email for your organization and provide separate logins for each user. Professional also comes with priority support.`,
+ upgradeButton: (
+
{
+ createModal(
+
+ );
+ }}
+ >{c('Action').t`Upgrade`}
+ )
+ },
+ (isFreeVpn || hasVpnBasic(subscription)) && {
+ title: c('Title').t`Upgrade to ProtonVPN Plus`,
+ description: c('Title')
+ .t`Upgrade to ProtonVPN Plus to get access to higher speed servers (up to 10 Gbps) and unlock advanced features such as Secure Core VPN, Tor over VPN, and access geo-blocked content (such as Netflix, Youtube, Amazon Prime, etc...).`,
+ upgradeButton: (
+
{
+ createModal(
+
+ );
+ }}
+ >{c('Action').t`Upgrade`}
+ )
+ }
+ ]
+ .filter(Boolean)
+ .map(({ title = '', description = '', upgradeButton }, index) => {
+ return (
+
+
{title}
+
+
{description}
+ {upgradeButton}
+
+
+ );
+ });
+};
+
+export default UpsellSubscription;
diff --git a/containers/payments/subscription/VpnFeaturesTable.js b/containers/payments/subscription/VpnFeaturesTable.js
new file mode 100644
index 000000000..1e4447c3c
--- /dev/null
+++ b/containers/payments/subscription/VpnFeaturesTable.js
@@ -0,0 +1,150 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { usePlans, useVPNCountries, Loader } from 'react-components';
+import { toMap } from 'proton-shared/lib/helpers/object';
+import { PLANS } from 'proton-shared/lib/constants';
+import { c } from 'ttag';
+
+import SubscriptionPrices from './SubscriptionPrices';
+
+const VpnFeaturesTable = ({ cycle, currency }) => {
+ const [vpnCountries, loadingVpnCountries] = useVPNCountries();
+ const [plans, loadingPlans] = usePlans();
+ const plansMap = toMap(plans, 'Name');
+
+ if (loadingPlans || loadingVpnCountries) {
+ return
;
+ }
+
+ return (
+ <>
+
+
+
+ |
+ Free
+
+ |
+
+ Basic
+
+ |
+
+ Plus
+
+ |
+
+ Visionary
+
+ |
+
+
+
+
+ | {c('Feature').t`1 VPN connection`} |
+ {c('Feature').t`2 VPN connections`} |
+ {c('Feature').t`5 VPN connections`} |
+ {c('Feature').t`10 VPN connections`} |
+
+
+ | {c('Feature').t`Servers in ${vpnCountries.free.length} countries`} |
+ {c('Feature').t`Servers in ${vpnCountries.basic.length} countries`} |
+ {c('Feature').t`Servers in ${vpnCountries.all.length} countries`} |
+ {c('Feature').t`Servers in ${vpnCountries.all.length} countries`} |
+
+
+ | {c('Feature').t`Medium speed`} |
+ {c('Feature').t`High speed`} |
+ {c('Feature').t`Highest speed`} |
+ {c('Feature').t`Highest speed`} |
+
+
+
+ {c('Feature').t`Filesharing / P2P`}
+ |
+ {c('Feature').t`Filesharing / P2P`} |
+ {c('Feature').t`Filesharing / P2P`} |
+ {c('Feature').t`Filesharing / P2P`} |
+
+
+
+ {c('Feature').t`Secure core and Tor VPN`}
+ |
+
+ {c('Feature').t`Secure core and Tor VPN`}
+ |
+ {c('Feature').t`Secure core and Tor VPN`} |
+ {c('Feature').t`Secure core and Tor VPN`} |
+
+
+
+ {c('Feature').t`Advanced privacy features`}
+ |
+
+ {c('Feature').t`Advanced privacy features`}
+ |
+ {c('Feature').t`Advanced privacy features`} |
+ {c('Feature').t`Advanced privacy features`} |
+
+
+
+ {c('Feature').t`Access blocked content`}
+ |
+
+ {c('Feature').t`Access blocked content`}
+ |
+ {c('Feature').t`Access blocked content`} |
+ {c('Feature').t`Access blocked content`} |
+
+
+ | {c('Feature').t`ProtonMail (optional) *`} |
+ {c('Feature').t`ProtonMail (optional) *`} |
+ {c('Feature').t`ProtonMail (optional) *`} |
+ {c('Feature').t`ProtonMail included`} |
+
+
+ | {c('Feature').t`No logs / no ads`} |
+
+
+ | {c('Feature').t`Perfect Forward Secrecy (PFS)`} |
+
+
+ | {c('Feature').t`AES-256 encryption`} |
+
+
+ | {c('Feature').t`DNS leak protection`} |
+
+
+ | {c('Feature').t`Kill switch`} |
+
+
+ | {c('Feature').t`Always-on VPN`} |
+
+
+ | {c('Feature').t`100% anonymous`} |
+
+
+ | {c('Feature').t`10 Gpbs servers`} |
+
+
+ | {c('Feature').t`Split tunneling support`} |
+
+
+ | {c('Feature').t`Swiss based`} |
+
+
+ | {c('Feature').t`Professional support`} |
+
+
+
+
* {c('Info concerning plan features').t`Denotes customizable features`}
+ >
+ );
+};
+
+VpnFeaturesTable.propTypes = {
+ cycle: PropTypes.number,
+ currency: PropTypes.string
+};
+
+export default VpnFeaturesTable;
diff --git a/containers/payments/subscription/VpnSubscriptionTable.js b/containers/payments/subscription/VpnSubscriptionTable.js
new file mode 100644
index 000000000..b8fec16f2
--- /dev/null
+++ b/containers/payments/subscription/VpnSubscriptionTable.js
@@ -0,0 +1,174 @@
+import React from 'react';
+import { SubscriptionTable, useVPNCountries, LinkButton, useModals } from 'react-components';
+import PropTypes from 'prop-types';
+import { PLAN_NAMES, PLANS, CYCLE, CURRENCIES } from 'proton-shared/lib/constants';
+import { toMap } from 'proton-shared/lib/helpers/object';
+import { c } from 'ttag';
+import freePlanSvg from 'design-system/assets/img/pv-images/plans/free-plan.svg';
+import plusPlanSvg from 'design-system/assets/img/pv-images/plans/vpnbasic-plan.svg';
+import professionalPlanSvg from 'design-system/assets/img/pv-images/plans/vpnplus-plan.svg';
+import visionaryPlanSvg from 'design-system/assets/img/pv-images/plans/visionary-plan.svg';
+
+import SubscriptionPrices from './SubscriptionPrices';
+import SubscriptionFeaturesModal from './SubscriptionFeaturesModal';
+
+const INDEXES = {
+ [PLANS.VPNBASIC]: 1,
+ [PLANS.VPNPLUS]: 2,
+ [PLANS.VISIONARY]: 3
+};
+
+const VpnSubscriptionTable = ({
+ planNameSelected,
+ plans: apiPlans = [],
+ cycle,
+ currency,
+ onSelect,
+ currentPlan,
+ ...rest
+}) => {
+ const { createModal } = useModals();
+ const plansMap = toMap(apiPlans, 'Name');
+ const vpnBasicPlan = plansMap[PLANS.VPNBASIC];
+ const vpnPlusPlan = plansMap[PLANS.VPNPLUS];
+ const visionaryPlan = plansMap[PLANS.VISIONARY];
+ const plusPlan = plansMap[PLANS.PLUS];
+ const [vpnCountries] = useVPNCountries();
+ const plans = [
+ {
+ name: '',
+ title: 'Free',
+ canCustomize: false,
+ price:
,
+ imageSrc: freePlanSvg,
+ description: c('Description').t`Privacy and security for everyone`,
+ features: [
+ { icon: 'arrow-right', content: c('Feature').t`1 VPN connection` },
+ { icon: 'arrow-right', content: c('Feature').t`Servers in ${vpnCountries.free.length} countries` },
+ { icon: 'arrow-right', content: c('Feature').t`Medium speed` },
+ { icon: 'arrow-right', content: c('Feature').t`No logs/No ads` },
+ {
+ icon: 'close',
+ content: (
+
{c('Feature')
+ .t`Filesharing/bitorrent support`}
+ )
+ },
+ {
+ icon: 'close',
+ content:
{c('Feature').t`Secure Core and Tor VPN`}
+ },
+ {
+ icon: 'close',
+ content: (
+
{c('Feature').t`Advanced privacy features`}
+ )
+ },
+ {
+ icon: 'close',
+ content:
{c('Feature').t`Access blocked content`}
+ }
+ ]
+ },
+ vpnBasicPlan && {
+ name: vpnBasicPlan.Name,
+ planID: vpnBasicPlan.ID,
+ title: PLAN_NAMES[PLANS.VPNBASIC],
+ price:
,
+ imageSrc: plusPlanSvg,
+ description: c('Description').t`Basic privacy features`,
+ features: [
+ { icon: 'arrow-right', content: c('Feature').t`2 VPN connections` },
+ { icon: 'arrow-right', content: c('Feature').t`Servers in ${vpnCountries.basic.length} countries` },
+ { icon: 'arrow-right', content: c('Feature').t`High speed` },
+ { icon: 'arrow-right', content: c('Feature').t`No logs/No ads` },
+ {
+ icon: 'close',
+ content: (
+
{c('Feature')
+ .t`Filesharing/bitorrent support`}
+ )
+ },
+ {
+ icon: 'close',
+ content:
{c('Feature').t`Secure Core and Tor VPN`}
+ },
+ {
+ icon: 'close',
+ content: (
+
{c('Feature').t`Advanced privacy features`}
+ )
+ },
+ {
+ icon: 'close',
+ content:
{c('Feature').t`Access blocked content`}
+ }
+ ]
+ },
+ vpnPlusPlan && {
+ name: vpnPlusPlan.Name,
+ planID: vpnPlusPlan.ID,
+ title: PLAN_NAMES[PLANS.VPNPLUS],
+ price:
,
+ imageSrc: professionalPlanSvg,
+ description: c('Description').t`Advanced security features`,
+ features: [
+ { icon: 'arrow-right', content: c('Feature').t`5 VPN connections` },
+ { icon: 'arrow-right', content: c('Feature').t`Servers in ${vpnCountries.all.length} countries` },
+ { icon: 'arrow-right', content: c('Feature').t`Highest speed (10 Gbps)` },
+ { icon: 'arrow-right', content: c('Feature').t`No logs/No ads` },
+ { icon: 'arrow-right', content: c('Feature').t`Filesharing/bitorrent support` },
+ { icon: 'arrow-right', content: c('Feature').t`Secure Core and Tor VPN` },
+ { icon: 'arrow-right', content: c('Feature').t`Advanced privacy features` },
+ { icon: 'arrow-right', content: c('Feature').t`Access blocked content` }
+ ]
+ },
+ visionaryPlan && {
+ name: visionaryPlan.Name,
+ planID: visionaryPlan.ID,
+ title: PLAN_NAMES[PLANS.VISIONARY],
+ price:
,
+ imageSrc: visionaryPlanSvg,
+ description: c('Description').t`The complete privacy suite`,
+ features: [
+ { icon: 'arrow-right', content: c('Feature').t`All Plus plan features` },
+ { icon: 'arrow-right', content: c('Feature').t`10 simultaneous VPN connections` },
+ { icon: 'arrow-right', content: c('Feature').t`ProtonMail Visionary account` }
+ ]
+ }
+ ];
+
+ return (
+
+
+ onSelect(expanded && !index ? plusPlan.ID : plans[index].planID, expanded)
+ }
+ currentPlan={currentPlan}
+ {...rest}
+ />
+
+ createModal()}
+ >
+ {c('Action').t`Show all features`}
+
+
+
+ );
+};
+
+VpnSubscriptionTable.propTypes = {
+ currentPlan: PropTypes.string,
+ planNameSelected: PropTypes.string,
+ plans: PropTypes.arrayOf(PropTypes.object),
+ onSelect: PropTypes.func.isRequired,
+ cycle: PropTypes.oneOf([CYCLE.MONTHLY, CYCLE.YEARLY, CYCLE.TWO_YEARS]).isRequired,
+ currency: PropTypes.oneOf(CURRENCIES).isRequired
+};
+
+export default VpnSubscriptionTable;
diff --git a/containers/payments/subscription/constants.ts b/containers/payments/subscription/constants.ts
new file mode 100644
index 000000000..27283d81b
--- /dev/null
+++ b/containers/payments/subscription/constants.ts
@@ -0,0 +1,7 @@
+export enum SUBSCRIPTION_STEPS {
+ NETWORK_ERROR = -1,
+ CUSTOMIZATION = 0,
+ PAYMENT = 1,
+ UPGRADE = 2,
+ THANKS = 3
+}
diff --git a/containers/payments/toDetails.js b/containers/payments/toDetails.js
index 1df21e2f4..ab34a98d5 100644
--- a/containers/payments/toDetails.js
+++ b/containers/payments/toDetails.js
@@ -3,9 +3,11 @@ const formatYear = (year) => {
return `${pre}${year}`;
};
+const clear = (v) => String(v).trim();
+
const toDetails = ({ number, month: ExpMonth, year, cvc: CVC, fullname, zip: ZIP, country: Country }) => {
return {
- Name: String(fullname).trim(),
+ Name: clear(fullname),
Number: String(number).replace(/\s+/g, ''),
ExpMonth,
ExpYear: formatYear(year),
diff --git a/containers/payments/useCard.js b/containers/payments/useCard.js
index ca31bcd64..756195660 100644
--- a/containers/payments/useCard.js
+++ b/containers/payments/useCard.js
@@ -9,12 +9,7 @@ const useCard = (initialCard = getDefaultCard()) => {
const errors = getErrors(card);
const isValid = !Object.keys(errors).length;
- return {
- card,
- updateCard,
- errors,
- isValid
- };
+ return [card, updateCard, errors, isValid];
};
export default useCard;
diff --git a/containers/payments/usePayment.js b/containers/payments/usePayment.js
index 862dc59d3..22f92109d 100644
--- a/containers/payments/usePayment.js
+++ b/containers/payments/usePayment.js
@@ -1,12 +1,29 @@
-import { useState } from 'react';
+import { useState, useEffect } from 'react';
import { PAYMENT_METHOD_TYPES } from 'proton-shared/lib/constants';
+import { useCard, usePayPal } from 'react-components';
+
+import toDetails from './toDetails';
const { CARD, BITCOIN, CASH, PAYPAL } = PAYMENT_METHOD_TYPES;
-const usePayment = () => {
+const usePayment = ({ amount, currency, onPay }) => {
+ const [card, setCard, errors, isValid] = useCard();
const [method, setMethod] = useState('');
const [parameters, setParameters] = useState({});
- const [isCardValid, setCardValidity] = useState(false);
+
+ const paypal = usePayPal({
+ amount,
+ currency,
+ type: PAYMENT_METHOD_TYPES.PAYPAL,
+ onPay
+ });
+
+ const paypalCredit = usePayPal({
+ amount,
+ currency,
+ type: PAYMENT_METHOD_TYPES.PAYPAL_CREDIT,
+ onPay
+ });
const hasToken = () => {
const { Payment = {} } = parameters;
@@ -16,11 +33,20 @@ const usePayment = () => {
};
const canPay = () => {
+ if (!amount) {
+ // Amount equals 0
+ return true;
+ }
+
+ if (!method) {
+ return false;
+ }
+
if ([BITCOIN, CASH].includes(method)) {
return false;
}
- if (method === CARD && !isCardValid) {
+ if (method === CARD && !isValid) {
return false;
}
@@ -31,13 +57,32 @@ const usePayment = () => {
return true;
};
+ useEffect(() => {
+ if (![CARD, PAYPAL, CASH, BITCOIN].includes(method)) {
+ setParameters({ PaymentMethodID: method });
+ }
+
+ if (method === CARD) {
+ setParameters({ Payment: { Type: CARD, Details: toDetails(card) } });
+ }
+
+ // Reset parameters when switching methods
+ if ([PAYPAL, CASH, BITCOIN].includes(method)) {
+ setParameters({});
+ }
+ }, [method, card]);
+
return {
+ paypal,
+ paypalCredit,
+ card,
+ setCard,
+ errors,
method,
setMethod,
parameters,
setParameters,
- canPay: canPay(),
- setCardValidity
+ canPay: canPay()
};
};
diff --git a/hooks/usePayPal.js b/hooks/usePayPal.js
new file mode 100644
index 000000000..52d5d1490
--- /dev/null
+++ b/hooks/usePayPal.js
@@ -0,0 +1,72 @@
+import React, { useEffect, useState } from 'react';
+import { createToken } from 'proton-shared/lib/api/payments';
+import { useApi, useLoading, useModals } from 'react-components';
+
+import PaymentVerificationModal from '../containers/payments/PaymentVerificationModal';
+import { process } from '../containers/payments/paymentTokenHelper';
+
+const usePayPal = ({ amount: Amount = 0, currency: Currency = '', type: Type, onPay }) => {
+ const api = useApi();
+ const [model, setModel] = useState({});
+ const [loadingVerification, withLoadingVerification] = useLoading();
+ const [loadingToken, withLoadingToken] = useLoading();
+ const { createModal } = useModals();
+
+ const onToken = async () => {
+ const result = await api(
+ createToken({
+ Amount,
+ Currency,
+ Payment: { Type }
+ })
+ );
+ setModel(result);
+ };
+
+ const onVerification = async () => {
+ const { Token, ApprovalURL, ReturnHost } = model;
+ const result = await new Promise((resolve, reject) => {
+ const onProcess = () => {
+ const abort = new AbortController();
+ return {
+ promise: process({
+ Token,
+ api,
+ ReturnHost,
+ ApprovalURL,
+ signal: abort.signal
+ }),
+ abort
+ };
+ };
+ createModal(
+
+ );
+ });
+ onPay(result);
+ };
+
+ useEffect(() => {
+ if (Amount) {
+ withLoadingToken(onToken());
+ }
+ }, [Amount, Currency]);
+
+ return {
+ isReady: !!model.Token,
+ loadingToken,
+ loadingVerification,
+ onToken: () => withLoadingToken(onToken()),
+ onVerification: () => withLoadingVerification(onVerification())
+ };
+};
+
+export default usePayPal;
diff --git a/hooks/usePermissions.js b/hooks/usePermissions.js
index 7ff79ca2a..9ffc2edf0 100644
--- a/hooks/usePermissions.js
+++ b/hooks/usePermissions.js
@@ -12,12 +12,12 @@ const ROLES = {
const usePermissions = () => {
const permissions = [];
- const [{ Role, isPaid, hasPaidMail, hasPaidVpn }] = useUser();
+ const [{ Role, isPaid, hasPaidMail, hasPaidVpn, canPay }] = useUser();
const [{ MaxMembers = 0 } = {}] = useOrganization();
permissions.push(ROLES[Role]);
- if ([FREE_ROLE, ADMIN_ROLE].includes(Role)) {
+ if (canPay) {
permissions.push(UPGRADER);
}
diff --git a/hooks/useSvgGraphicsBbox.ts b/hooks/useSvgGraphicsBbox.ts
new file mode 100644
index 000000000..e403a0101
--- /dev/null
+++ b/hooks/useSvgGraphicsBbox.ts
@@ -0,0 +1,24 @@
+import { RefObject, useLayoutEffect, useState } from 'react';
+
+// These defaults do not matter
+const DEFAULT = {
+ y: 0,
+ x: 0,
+ width: 100,
+ height: 100
+};
+const useSvgGraphicsBbox = (ref: RefObject
, deps: any[] = []) => {
+ const [bbox, setBbox] = useState(DEFAULT);
+
+ useLayoutEffect(() => {
+ if (!ref.current) {
+ setBbox(DEFAULT);
+ return;
+ }
+ setBbox(ref.current.getBBox());
+ }, [ref.current, ...deps]);
+
+ return bbox;
+};
+
+export default useSvgGraphicsBbox;
diff --git a/hooks/useVPNCountries.js b/hooks/useVPNCountries.js
index 35f15fef5..acb08a728 100644
--- a/hooks/useVPNCountries.js
+++ b/hooks/useVPNCountries.js
@@ -1,15 +1,27 @@
-import { useApiResult } from 'react-components';
+import { useEffect, useState } from 'react';
+import { useApi, useLoading } from 'react-components';
import { queryVPNLogicalServerInfo } from 'proton-shared/lib/api/vpn';
const useVPNCountries = () => {
- const { loading, result } = useApiResult(queryVPNLogicalServerInfo, []);
+ const api = useApi();
+ const [loading, withLoading] = useLoading();
+ const [logicalServers, setLogicalServers] = useState([]);
+
+ const query = async () => {
+ const { LogicalServers = [] } = await api(queryVPNLogicalServerInfo());
+ setLogicalServers(LogicalServers);
+ };
const getCountries = (servers) =>
Object.keys(servers.reduce((countries, { ExitCountry }) => ({ ...countries, [ExitCountry]: true }), {}));
- const free = result ? getCountries(result.LogicalServers.filter(({ Tier }) => Tier === 0)) : [];
- const basic = result ? getCountries(result.LogicalServers.filter(({ Tier }) => Tier <= 1)) : [];
- const all = result ? getCountries(result.LogicalServers) : [];
+ const free = getCountries(logicalServers.filter(({ Tier }) => Tier === 0));
+ const basic = getCountries(logicalServers.filter(({ Tier }) => Tier <= 1));
+ const all = getCountries(logicalServers);
+
+ useEffect(() => {
+ withLoading(query());
+ }, []);
return [
{
diff --git a/index.ts b/index.ts
index c2c4f998f..6ed47ee79 100644
--- a/index.ts
+++ b/index.ts
@@ -176,11 +176,15 @@ export { default as GiftCodeInput } from './containers/payments/GiftCodeInput';
export { default as PlansSection } from './containers/payments/PlansSection';
export { default as SubscriptionSection } from './containers/payments/subscription/SubscriptionSection';
export { default as SubscriptionModal } from './containers/payments/subscription/SubscriptionModal';
+export { default as NewSubscriptionModal } from './containers/payments/subscription/NewSubscriptionModal';
export { default as BlackFridayModal } from './containers/payments/subscription/BlackFridayModal';
export { default as MailBlackFridayModal } from './containers/payments/subscription/MailBlackFridayModal';
export { default as VPNBlackFridayModal } from './containers/payments/subscription/VPNBlackFridayModal';
export { default as BlackFridayNavbarLink } from './containers/payments/subscription/BlackFridayNavbarLink';
export { default as UpgradeModal } from './containers/payments/subscription/UpgradeModal';
+export { default as SubscriptionTable } from './containers/payments/subscription/SubscriptionTable';
+export { default as MailSubscriptionTable } from './containers/payments/subscription/MailSubscriptionTable';
+export { default as VpnSubscriptionTable } from './containers/payments/subscription/VpnSubscriptionTable';
export { default as AmountButton } from './containers/payments/AmountButton';
export { default as Bitcoin } from './containers/payments/Bitcoin';
export { default as CurrencySelector } from './containers/payments/CurrencySelector';
@@ -333,6 +337,7 @@ export { default as OpenVPNAccountSection } from './containers/vpn/OpenVPNAccoun
export { default as ProtonVPNResourcesSection } from './containers/vpn/ProtonVPNResourcesSection/ProtonVPNResourcesSection';
export { default as ProtonVPNCredentialsSection } from './containers/vpn/ProtonVPNCredentialsSection/ProtonVPNCredentialsSection';
+export { default as usePayPal } from './hooks/usePayPal';
export { useUser, useGetUser } from './hooks/useUser';
export { default as useUserVPN } from './hooks/useUserVPN';
export { default as useBlackFriday } from './hooks/useBlackFriday';
@@ -343,6 +348,7 @@ export { default as useDomains } from './hooks/useDomains';
export { default as usePremiumDomains } from './hooks/usePremiumDomains';
export { useCalendars, useGetCalendars } from './hooks/useCalendars';
export { default as useActiveBreakpoint } from './hooks/useActiveBreakpoint';
+export { default as useSvgGraphicsBbox } from './hooks/useSvgGraphicsBbox';
export { default as useWindowSize } from './hooks/useWindowSize';
export { default as useElementRect } from './hooks/useElementRect';
export { default as useContacts } from './hooks/useContacts';