From fa2963331c9d405182bb51481aa05dade3deccb4 Mon Sep 17 00:00:00 2001 From: vincaslt Date: Mon, 14 Oct 2019 13:04:12 +0300 Subject: [PATCH 1/5] Move conatiner to react-components in preparation for mail signup --- components/link/Info.js | 1 + containers/signup/AccountStep/AccountForm.js | 156 +++++++++++ containers/signup/AccountStep/AccountStep.js | 42 +++ .../signup/AccountStep/LoginPromptModal.js | 34 +++ containers/signup/LoginPanel.js | 18 ++ .../MobileRedirectionStep.js | 26 ++ containers/signup/PaymentStep/PaymentStep.js | 60 ++++ containers/signup/PlanStep/OSIcon.js | 24 ++ .../signup/PlanStep/PlanCard/PlanCard.js | 87 ++++++ .../signup/PlanStep/PlanCard/PlanPrice.js | 61 +++++ .../signup/PlanStep/PlanComparisonModal.js | 42 +++ containers/signup/PlanStep/PlanStep.js | 119 ++++++++ .../signup/PlanStep/PlansGroupButtons.js | 36 +++ containers/signup/SelectedPlan/PlanDetails.js | 42 +++ containers/signup/SelectedPlan/PlanUpsell.js | 82 ++++++ containers/signup/SelectedPlan/PriceInfo.js | 60 ++++ containers/signup/SignupContainer.js | 245 +++++++++++++++++ .../VerificationCodeForm/ResendCodeModal.js | 61 +++++ .../VerificationCodeForm.js | 85 ++++++ .../VerificationForm/VerificationForm.js | 56 ++++ .../VerificationEmailInput.js | 33 +++ .../VerificationMethodForm.js | 76 ++++++ .../VerificationMethodSelector.js | 18 ++ .../VerificationPhoneInput.js | 40 +++ .../VerificationStep/VerificationStep.js | 51 ++++ .../VerificationStep/useVerification.js | 19 ++ containers/signup/plans.tsx | 167 ++++++++++++ containers/signup/useSignup.js | 257 ++++++++++++++++++ index.ts | 1 + 29 files changed, 1999 insertions(+) create mode 100644 containers/signup/AccountStep/AccountForm.js create mode 100644 containers/signup/AccountStep/AccountStep.js create mode 100644 containers/signup/AccountStep/LoginPromptModal.js create mode 100644 containers/signup/LoginPanel.js create mode 100644 containers/signup/MobileRedirectionStep/MobileRedirectionStep.js create mode 100644 containers/signup/PaymentStep/PaymentStep.js create mode 100644 containers/signup/PlanStep/OSIcon.js create mode 100644 containers/signup/PlanStep/PlanCard/PlanCard.js create mode 100644 containers/signup/PlanStep/PlanCard/PlanPrice.js create mode 100644 containers/signup/PlanStep/PlanComparisonModal.js create mode 100644 containers/signup/PlanStep/PlanStep.js create mode 100644 containers/signup/PlanStep/PlansGroupButtons.js create mode 100644 containers/signup/SelectedPlan/PlanDetails.js create mode 100644 containers/signup/SelectedPlan/PlanUpsell.js create mode 100644 containers/signup/SelectedPlan/PriceInfo.js create mode 100644 containers/signup/SignupContainer.js create mode 100644 containers/signup/VerificationStep/VerificationForm/VerificationCodeForm/ResendCodeModal.js create mode 100644 containers/signup/VerificationStep/VerificationForm/VerificationCodeForm/VerificationCodeForm.js create mode 100644 containers/signup/VerificationStep/VerificationForm/VerificationForm.js create mode 100644 containers/signup/VerificationStep/VerificationForm/VerificationMethodForm/VerificationEmailInput.js create mode 100644 containers/signup/VerificationStep/VerificationForm/VerificationMethodForm/VerificationMethodForm.js create mode 100644 containers/signup/VerificationStep/VerificationForm/VerificationMethodForm/VerificationMethodSelector.js create mode 100644 containers/signup/VerificationStep/VerificationForm/VerificationMethodForm/VerificationPhoneInput.js create mode 100644 containers/signup/VerificationStep/VerificationStep.js create mode 100644 containers/signup/VerificationStep/useVerification.js create mode 100644 containers/signup/plans.tsx create mode 100644 containers/signup/useSignup.js 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/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 ( +
withLoading(handleSubmit(e))}> + + + + + + + + + + +
+ +
+ +
+
+ + + + +
+ +
+

+ {c('Info') + .jt`By clicking Create account you agree to abide by our ${termsOfServiceLink} and ${privacyPolicyLink}.`} +

+ + {c('Action').t`Create account`} +
+
+
+ ); +}; + +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..588578819 --- /dev/null +++ b/containers/signup/AccountStep/LoginPromptModal.js @@ -0,0 +1,34 @@ +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'; + +// TODO: make it more generic (title, login url) +const LoginPromptModal = ({ email, ...rest }) => { + 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..05bfdab3e --- /dev/null +++ b/containers/signup/LoginPanel.js @@ -0,0 +1,18 @@ +import React from 'react'; +import { c } from 'ttag'; +import { Link } from 'react-router-dom'; + +const LoginPanel = () => { + return ( +
+

{c('Title').t`Already have a Proton account?`}

+
{c('Info') + .t`If you are a ProtonMail user you can use your Proton account to log in to ProtonVPN.`}
+
+ {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 = ( + + ); + + 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..c2c98e0fa --- /dev/null +++ b/containers/signup/SignupContainer.js @@ -0,0 +1,245 @@ +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 } 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, VPN_PLANS, VPN_BEST_DEAL_PLANS } from './plans'; +import PlanDetails from './SelectedPlan/PlanDetails'; +import PlanUpsell from './SelectedPlan/PlanUpsell'; +import useVerification from './VerificationStep/useVerification'; +import MobileRedirectionStep from './MobileRedirectionStep/MobileRedirectionStep'; + +const SignupState = { + Plan: 'plan', + Account: 'account', + Verification: 'verification', + Payment: 'payment', + MobileRedirection: 'mobile-redirection' +}; + +// TODO: Flexible urls and plans for reuse between project +const SignupContainer = ({ match, history, onLogin, stopRedirect, renderPlansTable }) => { + const searchParams = new URLSearchParams(history.location.search); + const preSelectedPlan = searchParams.get('plan'); + const redirectToMobile = searchParams.get('from') === 'mobile'; + const availablePlans = checkCookie('offer', 'bestdeal') ? VPN_BEST_DEAL_PLANS : VPN_PLANS; + + useEffect(() => { + document.title = c('Title').t`Sign up - ProtonVPN`; + // 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('/downloads'); + 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 ? ( + + ) : ( + {c('Action') + .t`Homepage`} + ))} +
+
+ + + +
+
+ +
+
+ + {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 = { + 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}".`} +

+
+ +
+ +
+ + { + 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} +
+
+ + + + +
+ {c('Action') + .t`Verify`} +
+
+ {c('Action') + .t`Did not receive the code?`} +
+
+ {c('Action') + .t`Use another verification method`} +
+
+
+
+
+ ); +}; + +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 ( +
+
+ +
+
+ {c('Action').t`Send`} +
+
+ ); +}; + +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 ? ( + + + +
+ {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