diff --git a/components/link/Info.js b/components/link/Info.js
index 1183ef6bb..f448b5845 100644
--- a/components/link/Info.js
+++ b/components/link/Info.js
@@ -6,6 +6,7 @@ import Icon from '../icon/Icon';
import { usePopper, Popper, usePopperAnchor } from '../popper';
import useRightToLeft from '../../containers/rightToLeft/useRightToLeft';
+/** @type any */
const Info = ({
url,
title,
diff --git a/containers/payments/PlansTable.js b/containers/payments/PlansTable.js
index 51da7506f..47705d9d8 100644
--- a/containers/payments/PlansTable.js
+++ b/containers/payments/PlansTable.js
@@ -26,11 +26,12 @@ const PlansTable = ({
cycle = DEFAULT_CYCLE,
updateCurrency,
updateCycle,
- onSelect
+ onSelect,
+ expand = false
}) => {
const planName = getPlanName(subscription) || FREE;
const { hasPaidVpn } = user;
- const { state, toggle } = useToggle();
+ const { state, toggle } = useToggle(expand);
const mySubscription = c('Title').t`My subscription`;
const getPrice = (planName) => {
@@ -310,54 +311,65 @@ const PlansTable = ({
) : null}
-
-
- ProtonVPN
-
-
-
-
- {hasPaidVpn ? c('Action').t`Edit VPN` : c('Action').t`Add VPN`}
-
-
-
-
- {hasPaidVpn ? c('Action').t`Edit VPN` : c('Action').t`Add VPN`}
-
-
-
-
- {hasPaidVpn ? c('Action').t`Edit VPN` : c('Action').t`Add VPN`}
-
-
- {c('Plan option').t`Included`}
-
-
-
-
- {state ? c('Action').t`Hide additional features` : c('Action').t`Compare all features`}
-
-
-
- {c('Action')
- .t`Update`}
-
-
- {c(
- 'Action'
- ).t`Update`}
-
-
- {c('Action').t`Update`}
-
-
- {c('Action')
- .t`Update`}
-
-
+ {onSelect && (
+ <>
+
+
+ ProtonVPN
+
+
+
+
+ {hasPaidVpn ? c('Action').t`Edit VPN` : c('Action').t`Add VPN`}
+
+
+
+
+ {hasPaidVpn ? c('Action').t`Edit VPN` : c('Action').t`Add VPN`}
+
+
+
+
+ {hasPaidVpn ? c('Action').t`Edit VPN` : c('Action').t`Add VPN`}
+
+
+ {c('Plan option').t`Included`}
+
+
+
+
+ {state
+ ? c('Action').t`Hide additional features`
+ : c('Action').t`Compare all features`}
+
+
+
+ {c('Action')
+ .t`Update`}
+
+
+ {c('Action').t`Update`}
+
+
+ {c('Action').t`Update`}
+
+
+ {c(
+ 'Action'
+ ).t`Update`}
+
+
+ >
+ )}
);
@@ -371,7 +383,8 @@ PlansTable.propTypes = {
user: PropTypes.object,
updateCurrency: PropTypes.func,
updateCycle: PropTypes.func,
- onSelect: PropTypes.func
+ onSelect: PropTypes.func,
+ expand: PropTypes.bool
};
export default PlansTable;
diff --git a/containers/signup/AccountStep/AccountForm.js b/containers/signup/AccountStep/AccountForm.js
new file mode 100644
index 000000000..babc45f62
--- /dev/null
+++ b/containers/signup/AccountStep/AccountForm.js
@@ -0,0 +1,156 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import {
+ Input,
+ PasswordInput,
+ PrimaryButton,
+ Field,
+ useApi,
+ Row,
+ Label,
+ EmailInput,
+ Href,
+ useLoading,
+ Info
+} from 'react-components';
+import { c } from 'ttag';
+import { queryCheckUsernameAvailability } from 'proton-shared/lib/api/user';
+
+const AccountForm = ({ model, onSubmit }) => {
+ const api = useApi();
+ const [username, setUsername] = useState(model.username);
+ const [password, setPassword] = useState(model.password);
+ const [confirmPassword, setConfirmPassword] = useState(model.password);
+ const [email, setEmail] = useState(model.email);
+ const [usernameError, setUsernameError] = useState();
+ const [loading, withLoading] = useLoading();
+
+ const handleChangeUsername = ({ target }) => {
+ if (usernameError) {
+ setUsernameError(null);
+ }
+ setUsername(target.value);
+ };
+
+ const handleChangePassword = ({ target }) => setPassword(target.value);
+ const handleChangeConfirmPassword = ({ target }) => setConfirmPassword(target.value);
+ const handleChangeEmail = ({ target }) => setEmail(target.value);
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+
+ if (password !== confirmPassword) {
+ return;
+ }
+
+ try {
+ await api(queryCheckUsernameAvailability(username));
+ await onSubmit({
+ username,
+ password,
+ email
+ });
+ } catch (e) {
+ setUsernameError(e.data ? e.data.Error : c('Error').t`Can't check username, try again later`);
+ }
+ };
+
+ const termsOfServiceLink = (
+ {c('Link').t`Terms of Service`}
+ );
+
+ const privacyPolicyLink = (
+ {c('Link').t`Privacy Policy`}
+ );
+
+ return (
+
+ );
+};
+
+AccountForm.propTypes = {
+ model: PropTypes.object.isRequired,
+ onSubmit: PropTypes.func.isRequired
+};
+
+export default AccountForm;
diff --git a/containers/signup/AccountStep/AccountStep.js b/containers/signup/AccountStep/AccountStep.js
new file mode 100644
index 000000000..3208fdb4e
--- /dev/null
+++ b/containers/signup/AccountStep/AccountStep.js
@@ -0,0 +1,42 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import AccountForm from './AccountForm';
+import { Row, SubTitle, useModals } from 'react-components';
+import { c } from 'ttag';
+import { hasProtonDomain } from 'proton-shared/lib/helpers/string';
+
+import LoginPromptModal from './LoginPromptModal';
+import LoginPanel from '../LoginPanel';
+
+const AccountStep = ({ onContinue, model, children }) => {
+ const { createModal } = useModals();
+
+ const handleSubmit = ({ email, username, password }) => {
+ if (hasProtonDomain(email)) {
+ createModal( );
+ } else {
+ onContinue({ ...model, email, username, password });
+ }
+ };
+
+ return (
+
+
{c('Title').t`Create an account`}
+
+
+ {children}
+
+
+ );
+};
+
+AccountStep.propTypes = {
+ model: PropTypes.object.isRequired,
+ onContinue: PropTypes.func.isRequired,
+ children: PropTypes.node.isRequired
+};
+
+export default AccountStep;
diff --git a/containers/signup/AccountStep/LoginPromptModal.js b/containers/signup/AccountStep/LoginPromptModal.js
new file mode 100644
index 000000000..2687b4322
--- /dev/null
+++ b/containers/signup/AccountStep/LoginPromptModal.js
@@ -0,0 +1,39 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormModal, PrimaryButton, Alert } from 'react-components';
+import { c } from 'ttag';
+import { Link } from 'react-router-dom';
+import useConfig from '../../config/useConfig';
+import { CLIENT_TYPES } from 'proton-shared/lib/constants';
+
+const LoginPromptModal = ({ email, ...rest }) => {
+ const { CLIENT_TYPE } = useConfig();
+
+ const title = CLIENT_TYPE === CLIENT_TYPES.VPN ? 'ProtonVPN' : 'ProtonMail';
+
+ return (
+
+ {c('Action').t`Go to login`}
+
+ }
+ {...rest}
+ >
+
+ {c('Info').t`You already have a Proton account.`}
+
+ {c('Info')
+ .t`Your existing Proton account can be used to access all Proton services. Please login with ${email}`}
+
+
+ );
+};
+
+LoginPromptModal.propTypes = {
+ email: PropTypes.string.isRequired
+};
+
+export default LoginPromptModal;
diff --git a/containers/signup/LoginPanel.js b/containers/signup/LoginPanel.js
new file mode 100644
index 000000000..7fbeb9c52
--- /dev/null
+++ b/containers/signup/LoginPanel.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import { c } from 'ttag';
+import { Link } from 'react-router-dom';
+import useConfig from '../config/useConfig';
+import { CLIENT_TYPES } from 'proton-shared/lib/constants';
+
+const LoginPanel = () => {
+ const { CLIENT_TYPE } = useConfig();
+
+ const info =
+ CLIENT_TYPE === CLIENT_TYPES.VPN
+ ? c('Info').t`If you are a ProtonMail user you can use your Proton account to log in to ProtonVPN.`
+ : c('Info').t`If you are a ProtonVPN user you can use your Proton account to log in to ProtonMail.`;
+
+ return (
+
+
{c('Title').t`Already have a Proton account?`}
+
{info}
+
+ {c('Link').t`Log in`}
+
+
+ );
+};
+
+export default LoginPanel;
diff --git a/containers/signup/MobileRedirectionStep/MobileRedirectionStep.js b/containers/signup/MobileRedirectionStep/MobileRedirectionStep.js
new file mode 100644
index 000000000..bb37c2f07
--- /dev/null
+++ b/containers/signup/MobileRedirectionStep/MobileRedirectionStep.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { c } from 'ttag';
+import { SubTitle, Href, Icon, Paragraph } from 'react-components';
+
+const MobileRedirectionStep = ({ model }) => {
+ return (
+
+
{c('Title').t`Account created`}
+
+
{c('Info')
+ .t`Your account has been successfully created. Please press the "Close" button to be taken back to the app.`}
+
{c('Link').t`Close`}
+
+ );
+};
+
+MobileRedirectionStep.propTypes = {
+ model: PropTypes.object.isRequired
+};
+
+export default MobileRedirectionStep;
diff --git a/containers/signup/PaymentStep/PaymentStep.js b/containers/signup/PaymentStep/PaymentStep.js
new file mode 100644
index 000000000..8b5206d86
--- /dev/null
+++ b/containers/signup/PaymentStep/PaymentStep.js
@@ -0,0 +1,60 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Alert, Payment, usePayment, PrimaryButton, Field, Row, useLoading, SubTitle } from 'react-components';
+import { c } from 'ttag';
+import { PAYMENT_METHOD_TYPES, CYCLE, CURRENCIES } from 'proton-shared/lib/constants';
+
+import LoginPanel from '../LoginPanel';
+
+const PaymentStep = ({ onPay, paymentAmount, model, children }) => {
+ const [loading, withLoading] = useLoading();
+ const { method, setMethod, parameters, canPay, setParameters, setCardValidity } = usePayment();
+
+ return (
+
+
{c('Title').t`Provide payment details`}
+
+
+
{c('Info').t`Your payment details are protected with TLS encryption and Swiss laws`}
+
withLoading(onPay(model, params))}
+ fieldClassName="auto flex-item-fluid-auto"
+ >
+ {method === PAYMENT_METHOD_TYPES.CARD && (
+
+ withLoading(onPay(model, parameters))}
+ >{c('Action').t`Confirm payment`}
+
+ )}
+
+
+
+ {children}
+
+
+ );
+};
+
+PaymentStep.propTypes = {
+ paymentAmount: PropTypes.number.isRequired,
+ model: PropTypes.shape({
+ cycle: PropTypes.oneOf([CYCLE.MONTHLY, CYCLE.TWO_YEARS, CYCLE.YEARLY]).isRequired,
+ currency: PropTypes.oneOf(CURRENCIES).isRequired
+ }),
+ onPay: PropTypes.func.isRequired,
+ children: PropTypes.node.isRequired
+};
+
+export default PaymentStep;
diff --git a/containers/signup/PlanStep/OSIcon.js b/containers/signup/PlanStep/OSIcon.js
new file mode 100644
index 000000000..a3a8ceeac
--- /dev/null
+++ b/containers/signup/PlanStep/OSIcon.js
@@ -0,0 +1,24 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import windowsSvg from 'design-system/assets/img/pm-images/windows.svg';
+import iosSvg from 'design-system/assets/img/pm-images/iOS.svg';
+import linuxSvg from 'design-system/assets/img/pm-images/linux.svg';
+import androidSvg from 'design-system/assets/img/pm-images/android.svg';
+import macosSvg from 'design-system/assets/img/pm-images/macOS.svg';
+
+const OSIcon = ({ os }) => {
+ const src = {
+ windows: windowsSvg,
+ ios: iosSvg,
+ linux: linuxSvg,
+ android: androidSvg,
+ macos: macosSvg
+ };
+ return ;
+};
+
+OSIcon.propTypes = {
+ os: PropTypes.string
+};
+
+export default OSIcon;
diff --git a/containers/signup/PlanStep/PlanCard/PlanCard.js b/containers/signup/PlanStep/PlanCard/PlanCard.js
new file mode 100644
index 000000000..646639bde
--- /dev/null
+++ b/containers/signup/PlanStep/PlanCard/PlanCard.js
@@ -0,0 +1,87 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { classnames, Button, Tooltip, Badge } from 'react-components';
+import { c } from 'ttag';
+import PlanPrice from './PlanPrice';
+import { CYCLE, CURRENCIES } from 'proton-shared/lib/constants';
+
+const PlanCard = ({ plan, isActive, onSelect, cycle, currency, isDisabled }) => {
+ const button = (
+
+ {c('Plan Action').t`Get ${plan.title}`}
+
+ );
+
+ return (
+
+
{plan.image}
+
+
{plan.title}
+ {plan.isBest && (
+
+ {c('Plan info').t`Most popular`}
+
+ )}
+
+
+
+ {plan.description && (
+
+ {plan.description}
+
+ )}
+ {plan.additionalFeatures && (
+ <>
+
{plan.additionalFeatures}
+
+
+ >
+ )}
+ {plan.features && (
+
+ {plan.features.map((feature, i) => (
+ {feature}
+ ))}
+
+ )}
+ {isDisabled ? (
+
{button}
+ ) : (
+ button
+ )}
+
+
+ );
+};
+
+PlanCard.propTypes = {
+ isActive: PropTypes.bool.isRequired,
+ plan: PropTypes.shape({
+ image: PropTypes.node.isRequired,
+ planName: PropTypes.string.isRequired,
+ title: PropTypes.string.isRequired,
+ description: PropTypes.string,
+ additionalFeatures: PropTypes.string,
+ features: PropTypes.arrayOf(PropTypes.node),
+ highlights: PropTypes.arrayOf(PropTypes.string),
+ isBest: PropTypes.bool
+ }).isRequired,
+ cycle: PropTypes.oneOf([CYCLE.MONTHLY, CYCLE.TWO_YEARS, CYCLE.YEARLY]).isRequired,
+ currency: PropTypes.oneOf(CURRENCIES).isRequired,
+ onSelect: PropTypes.func.isRequired,
+ isDisabled: PropTypes.bool
+};
+
+export default PlanCard;
diff --git a/containers/signup/PlanStep/PlanCard/PlanPrice.js b/containers/signup/PlanStep/PlanCard/PlanPrice.js
new file mode 100644
index 000000000..e1ff51669
--- /dev/null
+++ b/containers/signup/PlanStep/PlanCard/PlanPrice.js
@@ -0,0 +1,61 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { c } from 'ttag';
+import { Price } from 'react-components';
+import { CYCLE, CURRENCIES } from 'proton-shared/lib/constants';
+
+const PlanPrice = ({ plan, cycle, currency }) => {
+ const discount = plan.couponDiscount || plan.price.saved;
+ const totalMonthlyPriceText = (
+
+
+ {plan.price.totalMonthly}
+
+
+ );
+ const totalBilledText =
+ cycle === CYCLE.MONTHLY ? (
+
+ {plan.price.totalMonthly}
+
+ ) : (
+
+ {plan.price.total}
+
+ );
+ const discountText = (
+
+ {discount}
+
+ );
+ return (
+
+
{c('PlanPrice').jt`${totalMonthlyPriceText} / mo`}
+
+
+
{c('PlanPrice').jt`Billed as ${totalBilledText}`}
+ {discount > 0 &&
{c('PlanPrice').jt`SAVE ${discountText}`}
}
+
+
+ );
+};
+
+PlanPrice.propTypes = {
+ cycle: PropTypes.oneOf([CYCLE.MONTHLY, CYCLE.TWO_YEARS, CYCLE.YEARLY]).isRequired,
+ currency: PropTypes.oneOf(CURRENCIES).isRequired,
+ plan: PropTypes.shape({
+ couponDiscount: PropTypes.number,
+ price: PropTypes.shape({
+ totalMonthly: PropTypes.number,
+ monthly: PropTypes.number,
+ total: PropTypes.number,
+ saved: PropTypes.number
+ }).isRequired
+ }).isRequired
+};
+
+export default PlanPrice;
diff --git a/containers/signup/PlanStep/PlanComparisonModal.js b/containers/signup/PlanStep/PlanComparisonModal.js
new file mode 100644
index 000000000..eb409c1a7
--- /dev/null
+++ b/containers/signup/PlanStep/PlanComparisonModal.js
@@ -0,0 +1,42 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import { DialogModal, HeaderModal, InnerModal, usePlans } from 'react-components';
+import { c } from 'ttag';
+import { CYCLE, CURRENCIES } from 'proton-shared/lib/constants';
+
+const { MONTHLY, YEARLY, TWO_YEARS } = CYCLE;
+
+const PlanComparisonModal = ({
+ modalTitleID = 'modalTitle',
+ onClose,
+ defaultCycle,
+ defaultCurrency,
+ renderPlansTable,
+ ...rest
+}) => {
+ const [cycle, updateCycle] = useState(defaultCycle);
+ const [currency, updateCurrency] = useState(defaultCurrency);
+ const [plans, loading] = usePlans();
+
+ return (
+
+
+ {c('Title').t`ProtonVPN plan comparison`}
+
+
+
+ {renderPlansTable({ expand: true, loading, currency, cycle, updateCurrency, updateCycle, plans })}
+
+
+
+ );
+};
+
+PlanComparisonModal.propTypes = {
+ ...DialogModal.propTypes,
+ cycle: PropTypes.oneOf([MONTHLY, TWO_YEARS, YEARLY]).isRequired,
+ currency: PropTypes.oneOf(CURRENCIES).isRequired,
+ renderPlansTable: PropTypes.func.isRequired
+};
+
+export default PlanComparisonModal;
diff --git a/containers/signup/PlanStep/PlanStep.js b/containers/signup/PlanStep/PlanStep.js
new file mode 100644
index 000000000..3f496cb46
--- /dev/null
+++ b/containers/signup/PlanStep/PlanStep.js
@@ -0,0 +1,119 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Row, Field, CurrencySelector, CycleSelector, SubTitle, useModals, LinkButton } from 'react-components';
+import PlanCard from './PlanCard/PlanCard';
+import { CURRENCIES, CYCLE } from 'proton-shared/lib/constants';
+import { c } from 'ttag';
+import PlanComparisonModal from './PlanComparisonModal';
+import OSIcon from './OSIcon';
+import PlansGroupButtons from './PlansGroupButtons';
+
+const { MONTHLY, YEARLY, TWO_YEARS } = CYCLE;
+
+const PlanStep = ({
+ plans,
+ onSelectPlan,
+ onChangeCurrency,
+ onChangeCycle,
+ model,
+ signupAvailability,
+ renderPlansTable
+}) => {
+ const { createModal } = useModals();
+
+ const handleSelect = (planName) => () => onSelectPlan({ ...model, planName }, true);
+ const handleComparisonClick = () =>
+ createModal(
+
+ );
+
+ const supportedOS = (
+
+
+
+
+
+
+
+ );
+
+ return (
+ <>
+
+
+ {c('Title').t`Select a plan`}
+
+
+
+
+
+
+
+
+
+
+
+
+ {plans.map((plan) => (
+
0)}
+ />
+ ))}
+
+ {model.cycle === CYCLE.YEARLY && (
+ {c('Info')
+ .t`You are saving 20% with an annual plan`}
+ )}
+ {model.cycle === CYCLE.TWO_YEARS && (
+ {c('Info')
+ .t`You are saving 33% with an two-year plan`}
+ )}
+
+ {c('Action')
+ .t`View full plan comparison`}
+
+
+ {c('Info').jt`All plans support: ${supportedOS}`}
+
+ {c('Info').t`30-days money back guarantee`}
+
+ >
+ );
+};
+
+PlanStep.propTypes = {
+ signupAvailability: PropTypes.shape({
+ paid: PropTypes.bool
+ }).isRequired,
+ plans: PropTypes.arrayOf(PropTypes.object).isRequired,
+ model: PropTypes.shape({
+ planName: PropTypes.string.isRequired,
+ cycle: PropTypes.oneOf([MONTHLY, TWO_YEARS, YEARLY]).isRequired,
+ currency: PropTypes.oneOf(CURRENCIES).isRequired
+ }).isRequired,
+ onSelectPlan: PropTypes.func.isRequired,
+ onChangeCycle: PropTypes.func.isRequired,
+ onChangeCurrency: PropTypes.func.isRequired,
+ renderPlansTable: PropTypes.func.isRequired
+};
+
+export default PlanStep;
diff --git a/containers/signup/PlanStep/PlansGroupButtons.js b/containers/signup/PlanStep/PlansGroupButtons.js
new file mode 100644
index 000000000..5b39d9a17
--- /dev/null
+++ b/containers/signup/PlanStep/PlansGroupButtons.js
@@ -0,0 +1,36 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Group, ButtonGroup, classnames } from 'react-components';
+import { CURRENCIES, CYCLE } from 'proton-shared/lib/constants';
+
+const { MONTHLY, YEARLY, TWO_YEARS } = CYCLE;
+
+const PlansGroupButtons = ({ plans, onSelectPlan, model, ...rest }) => {
+ return (
+
+ {plans.map(({ planName, title }) => {
+ return (
+ onSelectPlan({ ...model, planName })}
+ >
+ {title}
+
+ );
+ })}
+
+ );
+};
+
+PlansGroupButtons.propTypes = {
+ plans: PropTypes.arrayOf(PropTypes.object).isRequired,
+ onSelectPlan: PropTypes.func.isRequired,
+ model: PropTypes.shape({
+ planName: PropTypes.string.isRequired,
+ cycle: PropTypes.oneOf([MONTHLY, TWO_YEARS, YEARLY]).isRequired,
+ currency: PropTypes.oneOf(CURRENCIES).isRequired
+ }).isRequired
+};
+
+export default PlansGroupButtons;
diff --git a/containers/signup/SelectedPlan/PlanDetails.js b/containers/signup/SelectedPlan/PlanDetails.js
new file mode 100644
index 000000000..44bf977d4
--- /dev/null
+++ b/containers/signup/SelectedPlan/PlanDetails.js
@@ -0,0 +1,42 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { c } from 'ttag';
+import { classnames } from 'react-components';
+import { PLAN } from '../plans';
+import PriceInfo from './PriceInfo';
+import { CYCLE, CURRENCIES } from 'proton-shared/lib/constants';
+
+const PlanDetails = ({ selectedPlan, cycle, currency }) => {
+ const { planName, title, additionalFeatures, features } = selectedPlan;
+ return (
+
+
{c('Title').t`${title} plan details`}
+
+
+ {additionalFeatures && {additionalFeatures} }
+ {features.map((feature, i) => (
+ {feature}
+ ))}
+
+ {planName !== PLAN.FREE && (
+
+ )}
+
+
+ );
+};
+
+PlanDetails.propTypes = {
+ selectedPlan: PropTypes.object.isRequired,
+ cycle: PropTypes.oneOf([CYCLE.MONTHLY, CYCLE.TWO_YEARS, CYCLE.YEARLY]).isRequired,
+ currency: PropTypes.oneOf(CURRENCIES).isRequired
+};
+
+export default PlanDetails;
diff --git a/containers/signup/SelectedPlan/PlanUpsell.js b/containers/signup/SelectedPlan/PlanUpsell.js
new file mode 100644
index 000000000..08f87b411
--- /dev/null
+++ b/containers/signup/SelectedPlan/PlanUpsell.js
@@ -0,0 +1,82 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { c } from 'ttag';
+import { PLAN } from '../plans';
+import { PrimaryButton, Price } from 'react-components';
+import { CYCLE, CURRENCIES } from 'proton-shared/lib/constants';
+
+const PlanUpsell = ({ selectedPlan, getPlanByName, cycle, currency, onExtendCycle, onUpgrade }) => {
+ const { planName, upsell } = selectedPlan;
+ const upsellCycle = cycle === CYCLE.MONTHLY && planName !== PLAN.FREE;
+
+ if (!upsell && !upsellCycle) {
+ return null; // No upsell needed
+ }
+
+ const yearlyPlan = getPlanByName(selectedPlan.planName, CYCLE.YEARLY);
+ const upsellPlan = upsell && getPlanByName(upsell.planName);
+
+ const handleExtendCycle = () => onExtendCycle();
+ const handleUpgrade = () => onUpgrade(upsell.planName);
+
+ const totalMonthlyText = upsellPlan && (
+
+ {upsellPlan.price.totalMonthly}
+
+ );
+
+ return (
+
+
+ {[PLAN.FREE, PLAN.BASIC].includes(planName)
+ ? c('Title').t`Upgrade and get more`
+ : c('Title').t`Summary`}
+
+
+ {upsellCycle && (
+ <>
+
+
{c('Plan upsell').t`Monthly plan`}
+
+
+ {selectedPlan.price.totalMonthly}
+
+
+
+
+
{c('Plan upsell').t`Yearly plan`}
+
+ {yearlyPlan.price.totalMonthly}
+
+
+
{c('Action')
+ .t`Pay annually and save 20%`}
+ >
+ )}
+
+ {upsell && !upsellCycle && (
+ <>
+
+ {upsell.features.map((feature, i) => (
+ {feature}
+ ))}
+
+
{c('Action')
+ .jt`Try ${upsellPlan.title} for only ${totalMonthlyText}`}
+ >
+ )}
+
+
+ );
+};
+
+PlanUpsell.propTypes = {
+ selectedPlan: PropTypes.object.isRequired,
+ cycle: PropTypes.oneOf([CYCLE.MONTHLY, CYCLE.TWO_YEARS, CYCLE.YEARLY]).isRequired,
+ currency: PropTypes.oneOf(CURRENCIES).isRequired,
+ onExtendCycle: PropTypes.func.isRequired,
+ onUpgrade: PropTypes.func.isRequired,
+ getPlanByName: PropTypes.func.isRequired
+};
+
+export default PlanUpsell;
diff --git a/containers/signup/SelectedPlan/PriceInfo.js b/containers/signup/SelectedPlan/PriceInfo.js
new file mode 100644
index 000000000..20cdc7513
--- /dev/null
+++ b/containers/signup/SelectedPlan/PriceInfo.js
@@ -0,0 +1,60 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { CYCLE, CURRENCIES } from 'proton-shared/lib/constants';
+import { c } from 'ttag';
+import { Price } from 'react-components';
+
+const PriceInfo = ({ plan, cycle, currency }) => {
+ const billingCycleI18n = {
+ [CYCLE.MONTHLY]: {
+ label: c('Label').t`1 month`,
+ total: c('Label').t`Total price`
+ },
+ [CYCLE.YEARLY]: {
+ label: c('Label').t`12 months`,
+ discount: c('Label').t`Annual discount (20%)`,
+ total: c('Label').t`Total price (annually)`
+ },
+ [CYCLE.TWO_YEARS]: {
+ label: c('Label').t`24 months`,
+ discount: c('Label').t`Two-year discount (33%)`,
+ total: c('Label').t`Total price (two-year)`
+ }
+ };
+
+ const billingCycle = billingCycleI18n[cycle];
+ const discount = plan.couponDiscount || plan.price.saved;
+
+ return (
+ <>
+ {plan.price.monthly > 0 && (
+
+
+ {plan.title} - {billingCycle.label}
+
+
{plan.price.monthly * cycle}
+
+ )}
+ {(plan.couponDiscount || billingCycle.discount) && (
+
+
+ {plan.couponDiscount ? plan.couponDescription : billingCycle.discount}
+
+
{-discount}
+
+ )}
+
+ {billingCycle.total}
+ {plan.price.total}
+
+ >
+ );
+};
+
+PriceInfo.propTypes = {
+ plan: PropTypes.object.isRequired,
+ cycle: PropTypes.oneOf([CYCLE.MONTHLY, CYCLE.TWO_YEARS, CYCLE.YEARLY]).isRequired,
+ currency: PropTypes.oneOf(CURRENCIES).isRequired
+};
+
+export default PriceInfo;
diff --git a/containers/signup/SignupContainer.js b/containers/signup/SignupContainer.js
new file mode 100644
index 000000000..8a03ca93b
--- /dev/null
+++ b/containers/signup/SignupContainer.js
@@ -0,0 +1,254 @@
+import React, { useState, useEffect } from 'react';
+import PropTypes from 'prop-types';
+import { c } from 'ttag';
+import { Button, Title, useLoading, TextLoader, VpnLogo, Href, FullLoader, SupportDropdown } from 'react-components';
+import { checkCookie } from 'proton-shared/lib/helpers/cookies';
+import { CYCLE, CLIENT_TYPES } from 'proton-shared/lib/constants';
+import AccountStep from './AccountStep/AccountStep';
+import PlanStep from './PlanStep/PlanStep';
+import useSignup from './useSignup';
+import VerificationStep from './VerificationStep/VerificationStep';
+import PaymentStep from './PaymentStep/PaymentStep';
+import { PLAN, getAvailablePlans } from './plans';
+import PlanDetails from './SelectedPlan/PlanDetails';
+import PlanUpsell from './SelectedPlan/PlanUpsell';
+import useVerification from './VerificationStep/useVerification';
+import MobileRedirectionStep from './MobileRedirectionStep/MobileRedirectionStep';
+import useConfig from '../config/useConfig';
+import MailLogo from '../../components/logo/MailLogo';
+
+const SignupState = {
+ Plan: 'plan',
+ Account: 'account',
+ Verification: 'verification',
+ Payment: 'payment',
+ MobileRedirection: 'mobile-redirection'
+};
+
+const SignupContainer = ({ match, history, onLogin, redirectUrl, stopRedirect, renderPlansTable, homepageUrl }) => {
+ const { CLIENT_TYPE } = useConfig();
+ const searchParams = new URLSearchParams(history.location.search);
+ const preSelectedPlan = searchParams.get('plan');
+ const redirectToMobile = searchParams.get('from') === 'mobile';
+ const availablePlans = getAvailablePlans(CLIENT_TYPE, checkCookie('offer', 'bestdeal'));
+ const appName = CLIENT_TYPE === CLIENT_TYPES.VPN ? 'ProtonVPN' : 'ProtonMail';
+
+ useEffect(() => {
+ document.title = c('Title').t`Sign up - ${appName}`;
+ // Always start at plans, or account if plan is preselected
+ if (preSelectedPlan) {
+ history.replace(`/signup/${SignupState.Account}`, history.location.state);
+ } else {
+ history.replace('/signup', history.location.state);
+ }
+ }, []);
+
+ const signupState = match.params.step;
+ const [upsellDone, setUpsellDone] = useState(false);
+ const [creatingAccount, withCreateLoading] = useLoading(false);
+ const historyState = history.location.state || {};
+ const invite = historyState.invite;
+ const coupon = historyState.coupon;
+
+ const goToStep = (step) => history.push(`/signup/${step}`);
+
+ const handleLogin = (...args) => {
+ if (redirectToMobile) {
+ return goToStep(SignupState.MobileRedirection);
+ }
+
+ stopRedirect();
+ history.push(redirectUrl);
+ onLogin(...args);
+ };
+
+ const {
+ model,
+ setModel,
+ signup,
+ selectedPlan,
+ makePayment,
+ signupAvailability,
+ getPlanByName,
+ isLoading,
+ appliedCoupon,
+ appliedInvite
+ } = useSignup(
+ handleLogin,
+ { coupon, invite, availablePlans },
+ {
+ planName: preSelectedPlan,
+ cycle: Number(searchParams.get('billing')),
+ currency: searchParams.get('currency')
+ }
+ );
+ const { verify, requestCode } = useVerification();
+
+ const handleSelectPlan = (model, next = false) => {
+ setModel(model);
+ next && goToStep(SignupState.Account);
+ };
+
+ const handleCreateAccount = async (model) => {
+ setModel(model);
+
+ if (selectedPlan.price.total > 0) {
+ goToStep(SignupState.Payment);
+ } else if (appliedInvite || appliedCoupon) {
+ await withCreateLoading(signup(model, { invite: appliedInvite, coupon: appliedCoupon }));
+ } else {
+ goToStep(SignupState.Verification);
+ }
+ };
+
+ const handleVerification = async (model, code, params) => {
+ const verificationToken = await verify(code, params);
+ await signup(model, { verificationToken });
+ setModel(model);
+ };
+
+ const handlePayment = async (model, paymentParameters = {}) => {
+ const paymentDetails = await makePayment(model, paymentParameters);
+ const { Payment = {} } = paymentParameters;
+ const { Type = '' } = Payment;
+ await withCreateLoading(signup(model, { paymentDetails, paymentMethodType: Type }));
+ setModel(model);
+ };
+
+ const handleUpgrade = (planName) => {
+ setModel({ ...model, planName });
+ setUpsellDone(true);
+ if (planName !== PLAN.FREE && signupState === SignupState.Verification) {
+ goToStep(SignupState.Payment);
+ } else if (planName === PLAN.FREE && signupState === SignupState.Payment) {
+ goToStep(SignupState.Verification);
+ }
+ };
+
+ const handleExtendCycle = () => {
+ setModel({ ...model, cycle: CYCLE.YEARLY });
+ setUpsellDone(true);
+ };
+
+ const selectedPlanComponent = (
+
+
+ {!upsellDone && (
+
+ )}
+
+ );
+
+ return (
+
+
+
+
+ {!creatingAccount &&
+ (signupState && signupState !== SignupState.Plan ? (
+ history.goBack()}>{c('Action').t`Back`}
+ ) : (
+ {c('Action')
+ .t`Homepage`}
+ ))}
+
+
+
+ {CLIENT_TYPE === CLIENT_TYPES.VPN ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
{c('Title').t`Sign up`}
+
+ {isLoading || creatingAccount ? (
+
+
+ {isLoading ? c('Info').t`Loading` : c('Info').t`Creating your account`}
+
+ ) : (
+ <>
+ {(!signupState || signupState === SignupState.Plan) && (
+
getPlanByName(plan))}
+ model={model}
+ onChangeCycle={(cycle) => setModel({ ...model, cycle })}
+ onChangeCurrency={(currency) => setModel({ ...model, currency })}
+ signupAvailability={signupAvailability}
+ onSelectPlan={handleSelectPlan}
+ />
+ )}
+ {signupState === SignupState.Account && (
+
+ {selectedPlanComponent}
+
+ )}
+ {signupState === SignupState.Verification && (
+ withCreateLoading(handleVerification(...rest))}
+ requestCode={requestCode}
+ >
+ {selectedPlanComponent}
+
+ )}
+ {signupState === SignupState.Payment && (
+
+ {selectedPlanComponent}
+
+ )}
+ {signupState === SignupState.MobileRedirection && }
+ >
+ )}
+
+
+ );
+};
+
+SignupContainer.propTypes = {
+ redirectUrl: PropTypes.string.isRequired,
+ homepageUrl: PropTypes.string.isRequired,
+ stopRedirect: PropTypes.func.isRequired,
+ onLogin: PropTypes.func.isRequired,
+ match: PropTypes.shape({
+ params: PropTypes.shape({
+ step: PropTypes.string
+ })
+ }).isRequired,
+ renderPlansTable: PropTypes.func.isRequired,
+ history: PropTypes.shape({
+ push: PropTypes.func.isRequired,
+ goBack: PropTypes.func.isRequired,
+ replace: PropTypes.func.isRequired,
+ location: PropTypes.shape({
+ search: PropTypes.string.isRequired,
+ state: PropTypes.oneOfType([
+ PropTypes.shape({
+ selector: PropTypes.string.isRequired,
+ token: PropTypes.string.isRequired
+ }),
+ PropTypes.shape({
+ Coupon: PropTypes.shape({ Code: PropTypes.string })
+ })
+ ])
+ }).isRequired
+ }).isRequired
+};
+
+export default SignupContainer;
diff --git a/containers/signup/VerificationStep/VerificationForm/VerificationCodeForm/ResendCodeModal.js b/containers/signup/VerificationStep/VerificationForm/VerificationCodeForm/ResendCodeModal.js
new file mode 100644
index 000000000..13268860f
--- /dev/null
+++ b/containers/signup/VerificationStep/VerificationForm/VerificationCodeForm/ResendCodeModal.js
@@ -0,0 +1,61 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { DialogModal, HeaderModal, InnerModal, FooterModal, PrimaryButton, Button } from 'react-components';
+import { c } from 'ttag';
+
+const ResendCodeModal = ({ modalTitleID = 'modalTitle', onResend, onBack, destination, onClose, ...rest }) => {
+ const editI18n = c('Action').t`Edit`;
+ const destinationText = {destination.Address || destination.Phone} ;
+ const destinationType = destination.Address ? c('VerificationType').t`email` : c('VerificationType').t`phone`;
+
+ return (
+
+
+ {c('Title').t`Resend code`}
+
+
+
+
+ {c('Info')
+ .jt`Click below to resend the code to ${destinationText}. If ${destinationType} is incorrect, please click "${editI18n}".`}
+
+
+
+
+
{c('Action').t`Cancel`}
+
+
{
+ onBack();
+ onClose();
+ }}
+ >
+ {editI18n}
+
+
{
+ onResend();
+ onClose();
+ }}
+ >{c('Action').t`Resend`}
+
+
+
+
+
+ );
+};
+
+ResendCodeModal.propTypes = {
+ modalTitleID: PropTypes.string,
+ destination: PropTypes.shape({
+ Phone: PropTypes.string,
+ Address: PropTypes.string
+ }).isRequired,
+ onResend: PropTypes.func.isRequired,
+ onClose: PropTypes.func,
+ onBack: PropTypes.func
+};
+
+export default ResendCodeModal;
diff --git a/containers/signup/VerificationStep/VerificationForm/VerificationCodeForm/VerificationCodeForm.js b/containers/signup/VerificationStep/VerificationForm/VerificationCodeForm/VerificationCodeForm.js
new file mode 100644
index 000000000..8e398364f
--- /dev/null
+++ b/containers/signup/VerificationStep/VerificationForm/VerificationCodeForm/VerificationCodeForm.js
@@ -0,0 +1,85 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import {
+ Row,
+ Field,
+ Input,
+ PrimaryButton,
+ Label,
+ InlineLinkButton,
+ useLoading,
+ Alert,
+ useModals
+} from 'react-components';
+import { c } from 'ttag';
+import ResendCodeModal from './ResendCodeModal';
+
+const VerificationCodeForm = ({ onSubmit, onResend, onBack, destination }) => {
+ const { createModal } = useModals();
+ const [loading, withLoading] = useLoading();
+ const [code, setCode] = useState('');
+ const destinationText = {destination.Address || destination.Phone} ;
+
+ const handleResend = () => {
+ createModal( );
+ };
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ withLoading(onSubmit(code));
+ };
+
+ const handleChangeCode = ({ target }) => setCode(target.value);
+
+ return (
+
+
{c('Title').t`Enter verification code`}
+
+ {c('Info').jt`Enter the verification code that was sent to ${destinationText}.`}
+ {destination.Address ? (
+ {c('Info').t`If you don't find the email in your inbox, please check your spam folder.`}
+ ) : null}
+
+
+
+ );
+};
+
+VerificationCodeForm.propTypes = {
+ onSubmit: PropTypes.func.isRequired,
+ onResend: PropTypes.func.isRequired,
+ onBack: PropTypes.func.isRequired,
+ destination: PropTypes.shape({
+ Phone: PropTypes.string,
+ Address: PropTypes.string
+ })
+};
+
+export default VerificationCodeForm;
diff --git a/containers/signup/VerificationStep/VerificationForm/VerificationForm.js b/containers/signup/VerificationStep/VerificationForm/VerificationForm.js
new file mode 100644
index 000000000..d9fd3b21b
--- /dev/null
+++ b/containers/signup/VerificationStep/VerificationForm/VerificationForm.js
@@ -0,0 +1,56 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import { useNotifications } from 'react-components';
+import VerificationMethodForm from './VerificationMethodForm/VerificationMethodForm';
+import VerificationCodeForm from './VerificationCodeForm/VerificationCodeForm';
+import { c } from 'ttag';
+
+const VerificationForm = ({ defaultEmail, allowedMethods, onRequestCode, onSubmit }) => {
+ const { createNotification } = useNotifications();
+ const [params, setParams] = useState(null);
+
+ const handleBack = () => setParams(null);
+
+ const sendCode = async (params) => {
+ const destination = params.Destination.Phone || params.Destination.Address;
+ await onRequestCode(params);
+ createNotification({ text: c('Notification').t`Verification code successfully sent to ${destination}` });
+ };
+
+ const handleResendCode = () => sendCode(params);
+
+ const handleRequestCode = async (params) => {
+ await sendCode(params);
+ setParams(params);
+ };
+
+ const handleSubmitCode = (code) => onSubmit(code, params);
+
+ if (!params) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+VerificationForm.propTypes = {
+ onSubmit: PropTypes.func.isRequired,
+ onRequestCode: PropTypes.func.isRequired,
+ defaultEmail: PropTypes.string.isRequired,
+ allowedMethods: PropTypes.arrayOf(PropTypes.string).isRequired
+};
+
+export default VerificationForm;
diff --git a/containers/signup/VerificationStep/VerificationForm/VerificationMethodForm/VerificationEmailInput.js b/containers/signup/VerificationStep/VerificationForm/VerificationMethodForm/VerificationEmailInput.js
new file mode 100644
index 000000000..0d4790489
--- /dev/null
+++ b/containers/signup/VerificationStep/VerificationForm/VerificationMethodForm/VerificationEmailInput.js
@@ -0,0 +1,33 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import { PrimaryButton, EmailInput } from 'react-components';
+import { c } from 'ttag';
+
+const VerificationEmailInput = ({ defaultEmail = '', onSendClick, loading }) => {
+ const [email, setEmail] = useState(defaultEmail);
+
+ const handleChange = ({ target }) => setEmail(target.value);
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ onSendClick(email);
+ };
+
+ return (
+
+ );
+};
+
+VerificationEmailInput.propTypes = {
+ onSendClick: PropTypes.func.isRequired,
+ defaultEmail: PropTypes.string,
+ loading: PropTypes.bool
+};
+
+export default VerificationEmailInput;
diff --git a/containers/signup/VerificationStep/VerificationForm/VerificationMethodForm/VerificationMethodForm.js b/containers/signup/VerificationStep/VerificationForm/VerificationMethodForm/VerificationMethodForm.js
new file mode 100644
index 000000000..a498bf998
--- /dev/null
+++ b/containers/signup/VerificationStep/VerificationForm/VerificationMethodForm/VerificationMethodForm.js
@@ -0,0 +1,76 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import { Row, useLoading, Radio, Label, Field } from 'react-components';
+import { c } from 'ttag';
+import VerificationEmailInput from './VerificationEmailInput';
+import VerificationPhoneInput from './VerificationPhoneInput';
+import { TOKEN_TYPES } from 'proton-shared/lib/constants';
+
+const VERIFICATION_METHOD = {
+ EMAIL: TOKEN_TYPES.EMAIL,
+ SMS: TOKEN_TYPES.SMS
+};
+
+const VerificationMethodForm = ({ defaultEmail, allowedMethods, onSubmit }) => {
+ const isMethodAllowed = (method) => allowedMethods.includes(method);
+ const defaultMethod = Object.values(VERIFICATION_METHOD).find(isMethodAllowed);
+
+ const [loading, withLoading] = useLoading();
+ const [method, setMethod] = useState(defaultMethod);
+
+ const handleSendEmailCode = (Address) =>
+ withLoading(onSubmit({ Type: VERIFICATION_METHOD.EMAIL, Destination: { Address } }));
+ const handleSendSMSCode = (Phone) =>
+ withLoading(onSubmit({ Type: VERIFICATION_METHOD.SMS, Destination: { Phone } }));
+
+ const handleSelectMethod = (method) => () => setMethod(method);
+
+ return (
+
+
{c('Title').t`Select an account verification method`}
+
+ {allowedMethods.length ? (
+
+ {c('Label').t`Verification method`}
+
+
+ {isMethodAllowed(VERIFICATION_METHOD.EMAIL) ? (
+ {c('Option').t`Email address`}
+ ) : null}
+ {isMethodAllowed(VERIFICATION_METHOD.SMS) && (
+ {c('Option').t`SMS`}
+ )}
+
+
+ {method === VERIFICATION_METHOD.EMAIL && (
+
+ )}
+ {method === VERIFICATION_METHOD.SMS && (
+
+ )}
+
+
+
+ ) : null}
+
+ );
+};
+
+VerificationMethodForm.propTypes = {
+ defaultEmail: PropTypes.string.isRequired,
+ allowedMethods: PropTypes.arrayOf(PropTypes.string).isRequired,
+ onSubmit: PropTypes.func.isRequired
+};
+
+export default VerificationMethodForm;
diff --git a/containers/signup/VerificationStep/VerificationForm/VerificationMethodForm/VerificationMethodSelector.js b/containers/signup/VerificationStep/VerificationForm/VerificationMethodForm/VerificationMethodSelector.js
new file mode 100644
index 000000000..0da33983f
--- /dev/null
+++ b/containers/signup/VerificationStep/VerificationForm/VerificationMethodForm/VerificationMethodSelector.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Select } from 'react-components';
+
+const VerificationMethodSelector = ({ allowedMethods, method, onSelect, ...rest }) => {
+ const handleChange = ({ target }) => onSelect(target.value);
+ const options = allowedMethods.map((c) => ({ text: c, value: c }));
+
+ return ;
+};
+
+VerificationMethodSelector.propTypes = {
+ allowedMethods: PropTypes.arrayOf(PropTypes.string).isRequired,
+ method: PropTypes.string,
+ onSelect: PropTypes.func.isRequired
+};
+
+export default VerificationMethodSelector;
diff --git a/containers/signup/VerificationStep/VerificationForm/VerificationMethodForm/VerificationPhoneInput.js b/containers/signup/VerificationStep/VerificationForm/VerificationMethodForm/VerificationPhoneInput.js
new file mode 100644
index 000000000..5832c1bd3
--- /dev/null
+++ b/containers/signup/VerificationStep/VerificationForm/VerificationMethodForm/VerificationPhoneInput.js
@@ -0,0 +1,40 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import { PrimaryButton, IntlTelInput } from 'react-components';
+import { c } from 'ttag';
+
+const VerificationPhoneInput = ({ onSendClick, loading }) => {
+ const [phone, setPhone] = useState('');
+
+ const handleChangePhone = (status, value, countryData, number) => {
+ setPhone(number);
+ };
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ onSendClick(phone);
+ };
+
+ return (
+
+ );
+};
+
+VerificationPhoneInput.propTypes = {
+ onSendClick: PropTypes.func.isRequired,
+ loading: PropTypes.bool
+};
+
+export default VerificationPhoneInput;
diff --git a/containers/signup/VerificationStep/VerificationStep.js b/containers/signup/VerificationStep/VerificationStep.js
new file mode 100644
index 000000000..a51c89800
--- /dev/null
+++ b/containers/signup/VerificationStep/VerificationStep.js
@@ -0,0 +1,51 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Row, Alert, SubTitle } from 'react-components';
+import VerificationForm from './VerificationForm/VerificationForm';
+import { c } from 'ttag';
+
+import LoginPanel from '../LoginPanel';
+
+const VerificationStep = ({ onVerify, requestCode, allowedMethods, model, children }) => {
+ const handleSubmit = async (code, params) => {
+ const newEmail = params.Destination.Address;
+
+ await onVerify(
+ { ...model, email: newEmail && newEmail !== model.email ? newEmail : model.email },
+ code,
+ params
+ );
+ };
+
+ return (
+
+
{c('Title').t`Are you human?`}
+
+
+
{c('Info')
+ .t`To prevent misuse, please verify you are human. Please do not close this tab until you have verified your account.`}
+
+
+
+ {children}
+
+
+ );
+};
+
+VerificationStep.propTypes = {
+ model: PropTypes.shape({
+ email: PropTypes.string
+ }).isRequired,
+ allowedMethods: PropTypes.arrayOf(PropTypes.string).isRequired,
+ onVerify: PropTypes.func.isRequired,
+ requestCode: PropTypes.func.isRequired,
+ children: PropTypes.node.isRequired
+};
+
+export default VerificationStep;
diff --git a/containers/signup/VerificationStep/useVerification.js b/containers/signup/VerificationStep/useVerification.js
new file mode 100644
index 000000000..91debcc44
--- /dev/null
+++ b/containers/signup/VerificationStep/useVerification.js
@@ -0,0 +1,19 @@
+import { useConfig, useApi } from 'react-components';
+import { queryCheckVerificationCode, queryVerificationCode } from 'proton-shared/lib/api/user';
+
+const useVerification = () => {
+ const api = useApi();
+ const { CLIENT_TYPE } = useConfig();
+ const requestCode = ({ Type, Destination }) => api(queryVerificationCode(Type, Destination));
+
+ const verify = async (code, { Type: TokenType, Destination }) => {
+ const Token = `${Destination.Phone || Destination.Address}:${code}`;
+ const verificationToken = { Token, TokenType };
+ await api(queryCheckVerificationCode(Token, TokenType, CLIENT_TYPE));
+ return verificationToken;
+ };
+
+ return { verify, requestCode };
+};
+
+export default useVerification;
diff --git a/containers/signup/plans.tsx b/containers/signup/plans.tsx
new file mode 100644
index 000000000..b709cf6b8
--- /dev/null
+++ b/containers/signup/plans.tsx
@@ -0,0 +1,250 @@
+import React from 'react';
+import { c, msgid } from 'ttag';
+import { PLAN_TYPES, CYCLE, CLIENT_TYPES } from 'proton-shared/lib/constants';
+import freePlanSvg from 'design-system/assets/img/pv-images/plans/free.svg';
+import basicPlanSvg from 'design-system/assets/img/pv-images/plans/basic.svg';
+import plusPlanSvg from 'design-system/assets/img/pv-images/plans/plus.svg';
+import visionaryPlanSvg from 'design-system/assets/img/pv-images/plans/visionary.svg';
+import Info from '../../components/link/Info';
+
+interface PlanCountries {
+ free: string[];
+ basic: string[];
+ all: string[];
+}
+
+export enum PLAN {
+ FREE = 'free',
+ PLUS = 'plus',
+ PROFESSIONAL = 'professional',
+ VISIONARY = 'visionary',
+ VPNBASIC = 'vpnbasic',
+ VPNPLUS = 'vpnplus'
+}
+
+export const PLAN_NAMES = {
+ [PLAN.FREE]: 'Free',
+ [PLAN.VISIONARY]: 'Visionary',
+ [PLAN.VPNBASIC]: 'Basic',
+ [PLAN.VPNPLUS]: 'Plus',
+ [PLAN.PLUS]: 'Plus',
+ [PLAN.PROFESSIONAL]: 'Professional'
+};
+
+type VPNPlans = PLAN.FREE | PLAN.VPNBASIC | PLAN.VPNPLUS | PLAN.VISIONARY;
+export const VPN_PLANS = [PLAN.FREE, PLAN.VPNBASIC, PLAN.VPNPLUS, PLAN.VISIONARY];
+export const VPN_BEST_DEAL_PLANS = [PLAN.VPNBASIC, PLAN.VPNPLUS, PLAN.VISIONARY];
+
+type MailPlans = PLAN.FREE | PLAN.PLUS | PLAN.VISIONARY | PLAN.PROFESSIONAL;
+export const MAIL_PLANS = [PLAN.FREE, PLAN.PLUS, PLAN.VISIONARY, PLAN.PROFESSIONAL];
+
+export const getPlusPlan = (clientType: CLIENT_TYPES) => (clientType === CLIENT_TYPES.VPN ? PLAN.VPNPLUS : PLAN.PLUS);
+
+export const getAvailablePlans = (clientType: CLIENT_TYPES, bestDeal?: boolean) => {
+ if (clientType === CLIENT_TYPES.VPN) {
+ return bestDeal ? VPN_BEST_DEAL_PLANS : VPN_PLANS;
+ }
+
+ return MAIL_PLANS;
+};
+
+const getVPNPlanFeatures = (plan: VPNPlans, maxConnections: number, countries: PlanCountries) =>
+ ({
+ [PLAN.FREE]: {
+ image: ,
+ description: c('Plan Description').t`Privacy and security for everyone`,
+ upsell: {
+ planName: PLAN.VPNBASIC,
+ features: [
+ c('Plan Feature').t`High speed 1 Gbps VPN servers`,
+ c('Plan Feature').t`Access to ${countries.basic.length} countries`,
+ c('Plan Feature').t`Filesharing/P2P support`
+ ]
+ },
+ features: [
+ c('Plan Feature').ngettext(
+ msgid`${maxConnections} simultaneous VPN connection`,
+ `${maxConnections} simultaneous VPN connections`,
+ maxConnections
+ ),
+ c('Plan Feature').t`Servers in ${countries.free.length} countries`,
+ c('Plan Feature').t`Medium speed`,
+ c('Plan Feature').t`No logs policy`,
+ c('Plan Feature').t`No data limit`,
+ c('Plan Feature').t`No ads`
+ ]
+ },
+ [PLAN.VPNBASIC]: {
+ image: ,
+ description: c('Plan Description').t`Basic privacy features`,
+ additionalFeatures: c('Plan feature').t`All ${PLAN_NAMES[PLAN.FREE]} plan features`,
+ upsell: {
+ planName: PLAN.VPNPLUS,
+ features: [
+ c('Plan Feature').t`Highest speed servers (10 Gbps)`,
+ c('Plan Feature').t`Access blocked content`,
+ c('Plan Feature').t`All advanced security features`
+ ]
+ },
+ features: [
+ c('Plan Feature').ngettext(
+ msgid`${maxConnections} simultaneous VPN connection`,
+ `${maxConnections} simultaneous VPN connections`,
+ maxConnections
+ ),
+ c('Plan Feature').t`Servers in ${countries.basic.length} countries`,
+ c('Plan Feature').t`High speed servers`,
+ <>
+ {c('Plan Feature').t`Filesharing/P2P support`}
+
+ >,
+ c('Plan Feature').t`No logs policy`
+ ]
+ },
+ [PLAN.VPNPLUS]: {
+ image: ,
+ isBest: true,
+ description: c('Plan Description').t`Advanced security features`,
+ additionalFeatures: c('Plan feature').t`All ${PLAN_NAMES[PLAN.VPNBASIC]} plan features`,
+ features: [
+ c('Plan Feature').ngettext(
+ msgid`${maxConnections} simultaneous VPN connection`,
+ `${maxConnections} simultaneous VPN connections`,
+ maxConnections
+ ),
+ countries.basic.length !== countries.all.length &&
+ c('Plan Feature').t`Servers in ${countries.all.length} countries`,
+ c('Plan Feature').t`Secure Core`,
+ c('Plan Feature').t`Highest speeds`,
+ <>
+ {c('Plan Feature').t`Access blocked content`}
+
+ >,
+ c('Plan Feature').t`All advanced security features`
+ ]
+ },
+ [PLAN.VISIONARY]: {
+ image: ,
+ description: c('Plan Description').t`The complete privacy suite`,
+ additionalFeatures: c('Plan feature').t`All ${PLAN_NAMES[PLAN.VPNPLUS]} plan features`,
+ features: [
+ c('Plan Feature').ngettext(
+ msgid`${maxConnections} simultaneous VPN connection`,
+ `${maxConnections} simultaneous VPN connections`,
+ maxConnections
+ ),
+ c('Plan Feature').t`ProtonMail Visionary account`
+ ]
+ }
+ }[plan]);
+
+const getMailPlanFeatures = (plan: MailPlans) =>
+ ({
+ [PLAN.FREE]: {
+ image: FREE PLAN IMAGE
,
+ description: c('Plan Description').t`Basic account with limited features`,
+ upsell: {
+ planName: PLAN.PLUS,
+ features: [
+ c('Plan Feature').t`Upsell feature 1`,
+ c('Plan Feature').t`Upsell feature 2`,
+ c('Plan Feature').t`Upsell feature 3`
+ ]
+ },
+ features: [
+ c('Plan Feature').t`500MB storage`,
+ c('Plan Feature').t`150 messages per day`,
+ c('Plan Feature').t`Limited Support`
+ ]
+ },
+ [PLAN.PLUS]: {
+ image: PLUS PLAN IMAGE
,
+ isBest: true,
+ description: c('Plan Description').t`Secure email with advanced features`,
+ upsell: {
+ planName: PLAN.VISIONARY,
+ features: [
+ c('Plan Feature').t`Upsell feature 1`,
+ c('Plan Feature').t`Upsell feature 2`,
+ c('Plan Feature').t`Upsell feature 3`,
+ c('Plan Feature').t`Upsell feature 4`
+ ]
+ },
+ features: [
+ c('Plan Feature').t`5 GB storage`,
+ c('Plan Feature').t`1000 messages per day`,
+ c('Plan Feature').t`Labels, Custom Filters, and Folders`,
+ c('Plan Feature').t`Send encrypted messages to external recipients`,
+ c('Plan Feature').t`Use your own domain (e.g. john@smith.com)`,
+ c('Plan Feature').t`Up to 5 email aliases`,
+ c('Plan Feature').t`Priority Customer Support`
+ ]
+ },
+ [PLAN.VISIONARY]: {
+ image: VISIONARY PLAN IMAGE
,
+ description: c('Plan Description').t`Special accounts for our supporters`,
+ additionalFeatures: c('Plan feature').t`All ${PLAN_NAMES[PLAN.PLUS]} plan features`,
+ features: [
+ c('Plan Feature').t`20GB storage`,
+ c('Plan Feature').t`No sending limits*`, // TODO: asterisk info
+ c('Plan Feature').t`Support for up to 10 domains`,
+ c('Plan Feature').t`Up to 50 email aliases`,
+ c('Plan Feature').t`Multi-User Support (6 total)`,
+ c('Plan Feature').t`Early access to new features`,
+ c('Plan Feature').t`Includes access to ProtonVPN`
+ ]
+ },
+ [PLAN.PROFESSIONAL]: {
+ image: PROFESSIONAL PLAN IMAGE
,
+ description: c('Plan Description').t`Encrypted Email for your Organization`,
+ features: [
+ c('Plan Feature').t`Feature 1`,
+ c('Plan Feature').t`Feature 2`,
+ c('Plan Feature').t`Feature 3`,
+ c('Plan Feature').t`Feature 4`
+ ]
+ }
+ }[plan]);
+
+// To use coupon, AmountDue from coupon must be merged into plan.
+const getPlanPrice = (plan: any, cycle: number) => {
+ const monthly = plan.Pricing[CYCLE.MONTHLY];
+ const cyclePrice = plan.Pricing[cycle];
+ const adjustedTotal = plan.AmountDue;
+
+ const total = typeof adjustedTotal !== 'undefined' ? adjustedTotal : cyclePrice;
+ const saved = monthly * cycle - cyclePrice;
+ const totalMonthly = total / cycle;
+
+ return { monthly, total, totalMonthly, saved };
+};
+
+export const getPlan = (
+ clientType: CLIENT_TYPES,
+ planName: VPNPlans | MailPlans,
+ cycle: number,
+ plans: any = [],
+ countries: PlanCountries = { free: [], basic: [], all: [] }
+) => {
+ const plan = plans.find(({ Type, Name }: any) => Type === PLAN_TYPES.PLAN && Name === planName);
+ const price = plan ? getPlanPrice(plan, cycle) : { monthly: 0, total: 0, totalMonthly: 0, saved: 0 };
+
+ const planFeatures =
+ clientType === CLIENT_TYPES.VPN
+ ? getVPNPlanFeatures(planName as VPNPlans, plan ? plan.MaxVPN : 1, countries)
+ : getMailPlanFeatures(planName as MailPlans);
+
+ return {
+ ...planFeatures,
+ planName,
+ title: PLAN_NAMES[planName],
+ id: plan && plan.ID,
+ disabled: !plan && planName !== PLAN.FREE,
+ price,
+ couponDiscount: plan && Math.abs(plan.CouponDiscount),
+ couponDescription: plan && plan.CouponDescription
+ };
+};
diff --git a/containers/signup/useSignup.js b/containers/signup/useSignup.js
new file mode 100644
index 000000000..15dfd7bac
--- /dev/null
+++ b/containers/signup/useSignup.js
@@ -0,0 +1,255 @@
+import { useState, useEffect } from 'react';
+import { handlePaymentToken } from 'react-components/containers/payments/paymentTokenHelper';
+import { srpVerify, srpAuth } from 'proton-shared/lib/srp';
+import { queryCreateUser, queryDirectSignupStatus } from 'proton-shared/lib/api/user';
+import { auth, setCookies } from 'proton-shared/lib/api/auth';
+import { subscribe, setPaymentMethod, verifyPayment, checkSubscription } from 'proton-shared/lib/api/payments';
+import { mergeHeaders } from 'proton-shared/lib/fetch/helpers';
+import { getAuthHeaders } from 'proton-shared/lib/api';
+import { getRandomString } from 'proton-shared/lib/helpers/string';
+import {
+ DEFAULT_CURRENCY,
+ CYCLE,
+ PLAN_TYPES,
+ TOKEN_TYPES,
+ CURRENCIES,
+ PAYMENT_METHOD_TYPES
+} from 'proton-shared/lib/constants';
+import { getPlan, PLAN, getPlusPlan } from './plans';
+import { c } from 'ttag';
+import useApi from '../api/useApi';
+import useNotifications from '../notifications/useNotifications';
+import useModals from '../modals/useModals';
+import useConfig from '../config/useConfig';
+import useApiResult from '../../hooks/useApiResult';
+import usePlans from '../../hooks/usePlans';
+import useVPNCountries from '../../hooks/useVPNCountries';
+
+const getSignupAvailability = (isDirectSignupEnabled, allowedMethods = []) => {
+ const email = allowedMethods.includes(TOKEN_TYPES.EMAIL);
+ const sms = allowedMethods.includes(TOKEN_TYPES.SMS);
+ const paid = allowedMethods.includes(TOKEN_TYPES.PAYMENT);
+ const free = email || sms;
+
+ return {
+ allowedMethods,
+ inviteOnly: !isDirectSignupEnabled || (!free && !paid),
+ email,
+ free,
+ sms,
+ paid
+ };
+};
+
+const withAuthHeaders = (UID, AccessToken, config) => mergeHeaders(config, getAuthHeaders(UID, AccessToken));
+
+/**
+ * @param {Function} onLogin - callback after login that is done after registration
+ * @param {{
+ * coupon: { plan, code, cycle },
+ * invite: String
+ * availablePlans: string[]
+ * }} config - coupon/invite is ignored if method is not allowed
+ * @param {Object} initialModel - initially set values
+ */
+const useSignup = (onLogin, { coupon, invite, availablePlans } = {}, initialModel = {}) => {
+ const api = useApi();
+ const { createNotification } = useNotifications();
+ const { createModal } = useModals();
+ const { CLIENT_TYPE } = useConfig();
+ const { result } = useApiResult(() => queryDirectSignupStatus(CLIENT_TYPE), []);
+ const [plans] = usePlans();
+ const [plansWithCoupons, setPlansWithCoupons] = useState();
+ const [countries, countriesLoading] = useVPNCountries();
+ const [appliedCoupon, setAppliedCoupon] = useState();
+ const [appliedInvite, setAppliedInvite] = useState();
+
+ const signupAvailability = result && getSignupAvailability(result.Direct, result.VerifyMethods);
+ const defaultCurrency = plans && plans[0] ? plans[0].Currency : DEFAULT_CURRENCY;
+ const defaultCycle = coupon ? coupon.cycle : CYCLE.YEARLY;
+ const defaultPlan = coupon ? coupon.plan : getPlusPlan(CLIENT_TYPE);
+ const isLoading = !plansWithCoupons || !signupAvailability || countriesLoading;
+
+ const [model, setModel] = useState({
+ planName: Object.values(PLAN).includes(initialModel.planName) ? initialModel.planName : defaultPlan,
+ cycle: Object.values(CYCLE).includes(initialModel.cycle) ? initialModel.cycle : defaultCycle,
+ currency: CURRENCIES.includes(initialModel.currency) ? initialModel.currency : defaultCurrency,
+ email: initialModel.email || '',
+ username: initialModel.username || '',
+ password: initialModel.password || ''
+ });
+
+ const getPlanByName = (planName, cycle = model.cycle) =>
+ getPlan(CLIENT_TYPE, planName, cycle, plansWithCoupons || [], countries);
+
+ // Until we can query plans+coupons at once, we need to check each plan individually
+ useEffect(() => {
+ const applyCoupon = async () => {
+ const selectablePlans = plans.filter(
+ ({ Name, Type }) => Type === PLAN_TYPES.PLAN && availablePlans.includes(Name)
+ );
+ const plansWithCoupons = await Promise.all(
+ selectablePlans.map(async (plan) => {
+ const {
+ AmountDue,
+ CouponDiscount,
+ Coupon: { Description }
+ } = await api(
+ checkSubscription({
+ CouponCode: coupon.code,
+ Currency: model.currency,
+ Cycle: model.cycle,
+ PlanIDs: { [plan.ID]: 1 }
+ })
+ );
+ return {
+ ...plan,
+ AmountDue,
+ CouponDiscount,
+ CouponDescription: Description
+ };
+ })
+ );
+ setAppliedCoupon(coupon);
+ setPlansWithCoupons(plansWithCoupons);
+ };
+
+ if (!plans || !result) {
+ return;
+ }
+
+ if (coupon && !result.VerifyMethods.includes(TOKEN_TYPES.COUPON)) {
+ createNotification({ type: 'error', text: c('Notification').t`Coupons are temporarily disabled` });
+ }
+
+ if (coupon) {
+ applyCoupon();
+ } else {
+ setPlansWithCoupons(plans);
+ }
+ }, [availablePlans, result, plans, coupon, model.cycle, model.currency]);
+
+ useEffect(() => {
+ if (!invite || !signupAvailability) {
+ return;
+ }
+
+ if (!signupAvailability.allowedMethods.includes(TOKEN_TYPES.INVITE)) {
+ createNotification({ type: 'error', text: c('Notification').t`Invites are temporarily disabled` });
+ } else {
+ setAppliedInvite(invite);
+ }
+ }, [invite, signupAvailability]);
+
+ /**
+ * Makes payment, verifies it and saves payment details for signup
+ * @param {*=} paymentParameters payment parameters from usePayment
+ * @returns {Promise<{ VerifyCode, Payment }>} - paymentDetails
+ */
+ const makePayment = async (model, paymentParameters) => {
+ const selectedPlan = getPlanByName(model.planName, model.cycle);
+ const amount = selectedPlan.price.total;
+
+ if (amount > 0) {
+ const { Payment } = await handlePaymentToken({
+ params: {
+ Amount: selectedPlan.price.total,
+ Currency: model.currency,
+ ...paymentParameters
+ },
+ api,
+ createModal
+ });
+
+ const { VerifyCode } = await api(
+ verifyPayment({
+ Amount: amount,
+ Currency: model.currency,
+ Payment
+ })
+ );
+
+ return { VerifyCode, Payment };
+ }
+
+ return null;
+ };
+
+ const getToken = ({ coupon, invite, verificationToken, paymentDetails }) => {
+ if (invite) {
+ return { Token: `${invite.selector}:${invite.token}`, TokenType: TOKEN_TYPES.INVITE };
+ } else if (coupon) {
+ return { Token: coupon.code, TokenType: TOKEN_TYPES.COUPON };
+ } else if (paymentDetails) {
+ return { Token: paymentDetails.VerifyCode, TokenType: TOKEN_TYPES.PAYMENT };
+ }
+ return verificationToken;
+ };
+
+ const signup = async (model, signupToken) => {
+ const { Token, TokenType } = getToken(signupToken);
+ const { planName, password, email, username, currency, cycle } = model;
+ const selectedPlan = getPlanByName(planName, cycle);
+
+ await srpVerify({
+ api,
+ credentials: { password },
+ config: queryCreateUser({
+ Token,
+ TokenType,
+ Type: CLIENT_TYPE,
+ Email: email,
+ Username: username
+ })
+ });
+
+ const { UID, EventID, AccessToken, RefreshToken } = await srpAuth({
+ api,
+ credentials: { username, password },
+ config: auth({ Username: username })
+ });
+
+ // Add subscription
+ // Amount = 0 means - paid before subscription
+ if (planName !== PLAN.FREE) {
+ const subscription = {
+ PlanIDs: {
+ [selectedPlan.id]: 1
+ },
+ Amount: 0,
+ Currency: currency,
+ Cycle: cycle,
+ CouponCode: signupToken.coupon ? Token : undefined
+ };
+ await api(withAuthHeaders(UID, AccessToken, subscribe(subscription)));
+ }
+
+ // Add payment method
+ if (
+ signupToken.paymentDetails &&
+ [PAYMENT_METHOD_TYPES.CARD, PAYMENT_METHOD_TYPES.PAYPAL].includes(signupToken.paymentMethodType)
+ ) {
+ await api(withAuthHeaders(UID, AccessToken, setPaymentMethod(signupToken.paymentDetails.Payment)));
+ }
+
+ // set cookies after login
+ await api(setCookies({ UID, AccessToken, RefreshToken, State: getRandomString(24) }));
+ onLogin({ UID, EventID });
+ };
+
+ return {
+ model,
+ isLoading,
+ getPlanByName,
+ selectedPlan: getPlanByName(model.planName),
+ signupAvailability,
+ appliedCoupon,
+ appliedInvite,
+
+ makePayment,
+ setModel,
+ signup
+ };
+};
+
+export default useSignup;
diff --git a/index.ts b/index.ts
index 88d13c8ee..6864c0ff4 100644
--- a/index.ts
+++ b/index.ts
@@ -350,6 +350,7 @@ export { useMainArea, MainAreaContext } from './hooks/useMainArea';
export { default as useVPNCountries } from './hooks/useVPNCountries';
export { default as useMessageCounts } from './hooks/useMessageCounts';
export { default as useConversationCounts } from './hooks/useConversationCounts';
+export { default as SignupContainer } from './containers/signup/SignupContainer';
export { default as ErrorBoundary } from './containers/app/ErrorBoundary';
export { default as ProtonApp } from './containers/app/ProtonApp';