diff --git a/package.json b/package.json index f1786e0c3..fcb6b280d 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "amplitude-js": "^4.0.0", "bluebird": "^3.5.1", "classnames": "^2.2.5", + "country-data": "^0.0.31", "electron-dl": "^1.6.0", "formik": "^0.10.4", "from2": "^2.3.0", diff --git a/src/renderer/component/userPhoneNew/index.js b/src/renderer/component/userPhoneNew/index.js new file mode 100644 index 000000000..3033a3c2c --- /dev/null +++ b/src/renderer/component/userPhoneNew/index.js @@ -0,0 +1,15 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { doUserPhoneNew } from 'redux/actions/user'; +import { selectPhoneNewErrorMessage } from 'redux/selectors/user'; +import UserPhoneNew from './view'; + +const select = state => ({ + phoneErrorMessage: selectPhoneNewErrorMessage(state), +}); + +const perform = dispatch => ({ + addUserPhone: (phone, country_code) => dispatch(doUserPhoneNew(phone, country_code)), +}); + +export default connect(select, perform)(UserPhoneNew); diff --git a/src/renderer/component/userPhoneNew/view.jsx b/src/renderer/component/userPhoneNew/view.jsx new file mode 100644 index 000000000..b9999c746 --- /dev/null +++ b/src/renderer/component/userPhoneNew/view.jsx @@ -0,0 +1,109 @@ +import React from 'react'; +import { Form, FormRow, Submit } from 'component/form.js'; +import FormField from 'component/formField'; + +const os = require('os').type(); +const countryCodes = require('country-data') + .callingCountries.all.filter(_ => _.emoji) + .reduce( + (acc, cur) => acc.concat(cur.countryCallingCodes.map(_ => ({ ...cur, countryCallingCode: _ }))), + [] + ) + .sort((a, b) => { + if (a.countryCallingCode < b.countryCallingCode) { + return -1; + } + if (a.countryCallingCode > b.countryCallingCode) { + return 1; + } + return 0; + }); + +class UserPhoneNew extends React.PureComponent { + constructor(props) { + super(props); + + this.state = { + phone: '', + country_code: '+1', + }; + + this.formatPhone = this.formatPhone.bind(this); + } + + formatPhone(value) { + const { country_code } = this.state; + value = value.replace(/\D/g, ''); + if (country_code === '+1') { + if (!value) { + return ''; + } else if (value.length < 4) { + return value; + } else if (value.length < 7) { + return `(${value.substring(0, 3)}) ${value.substring(3)}`; + } + const fullNumber = `(${value.substring(0, 3)}) ${value.substring(3, 6)}-${value.substring( + 6 + )}`; + return fullNumber.length <= 14 ? fullNumber : fullNumber.substring(0, 14); + } + return value; + } + + handleChanged(event) { + this.setState({ + phone: this.formatPhone(event.target.value), + }); + } + + handleSelect(event) { + this.setState({ country_code: event.target.value }); + } + + handleSubmit() { + const { phone, country_code } = this.state; + this.props.addUserPhone(phone.replace(/\D/g, ''), country_code.substring(1)); + } + + render() { + const { cancelButton, phoneErrorMessage, isPending } = this.props; + + return ( +
+

+ {__( + 'Enter your phone number and we will send you a verification code. We will not share your phone number with third parties.' + )} +

+
+
+ + {countryCodes.map((country, index) => ( + + ))} + + { + this.handleChanged(event); + }} + /> +
+
+ + {cancelButton} +
+
+
+ ); + } +} + +export default UserPhoneNew; diff --git a/src/renderer/component/userPhoneVerify/index.js b/src/renderer/component/userPhoneVerify/index.js new file mode 100644 index 000000000..fea5e2893 --- /dev/null +++ b/src/renderer/component/userPhoneVerify/index.js @@ -0,0 +1,22 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { doUserPhoneVerify, doUserPhoneReset } from 'redux/actions/user'; +import { + selectPhoneToVerify, + selectPhoneVerifyErrorMessage, + selectUserCountryCode, +} from 'redux/selectors/user'; +import UserPhoneVerify from './view'; + +const select = state => ({ + phone: selectPhoneToVerify(state), + countryCode: selectUserCountryCode(state), + phoneErrorMessage: selectPhoneVerifyErrorMessage(state), +}); + +const perform = dispatch => ({ + resetPhone: () => dispatch(doUserPhoneReset()), + verifyUserPhone: code => dispatch(doUserPhoneVerify(code)), +}); + +export default connect(select, perform)(UserPhoneVerify); diff --git a/src/renderer/component/userPhoneVerify/view.jsx b/src/renderer/component/userPhoneVerify/view.jsx new file mode 100644 index 000000000..2ee39c1e8 --- /dev/null +++ b/src/renderer/component/userPhoneVerify/view.jsx @@ -0,0 +1,69 @@ +import React from 'react'; +import Link from 'component/link'; +import { Form, FormRow, Submit } from 'component/form.js'; + +class UserPhoneVerify extends React.PureComponent { + constructor(props) { + super(props); + + this.state = { + code: '', + }; + } + + handleCodeChanged(event) { + this.setState({ + code: String(event.target.value).trim(), + }); + } + + handleSubmit() { + const { code } = this.state; + this.props.verifyUserPhone(code); + } + + reset() { + const { resetPhone } = this.props; + resetPhone(); + } + + render() { + const { cancelButton, phoneErrorMessage, phone, countryCode } = this.props; + return ( +
+

+ {__( + `Please enter the verification code sent to +${countryCode}${ + phone + }. Didn't receive it? ` + )} + +

+ { + this.handleCodeChanged(event); + }} + errorMessage={phoneErrorMessage} + /> + {/* render help separately so it always shows */} +
+

+ {__('Email')} or join our{' '} + {' '} + {__('if you encounter any trouble with your code.')} +

+
+
+ + {cancelButton} +
+ + ); + } +} + +export default UserPhoneVerify; diff --git a/src/renderer/component/userVerify/index.js b/src/renderer/component/userVerify/index.js index 20f084e7f..bf0a4f6e1 100644 --- a/src/renderer/component/userVerify/index.js +++ b/src/renderer/component/userVerify/index.js @@ -9,6 +9,9 @@ import { selectIdentityVerifyErrorMessage, } from 'redux/selectors/user'; import UserVerify from './view'; +import { selectCurrentModal } from 'redux/selectors/app'; +import { doOpenModal } from 'redux/actions/app'; +import { PHONE_COLLECTION } from 'constants/modal_types'; const select = (state, props) => { const selectReward = makeSelectRewardByType(); @@ -17,12 +20,14 @@ const select = (state, props) => { isPending: selectIdentityVerifyIsPending(state), errorMessage: selectIdentityVerifyErrorMessage(state), reward: selectReward(state, { reward_type: rewards.TYPE_NEW_USER }), + modal: selectCurrentModal(state), }; }; const perform = dispatch => ({ navigate: uri => dispatch(doNavigate(uri)), verifyUserIdentity: token => dispatch(doUserIdentityVerify(token)), + verifyPhone: () => dispatch(doOpenModal(PHONE_COLLECTION)), }); export default connect(select, perform)(UserVerify); diff --git a/src/renderer/component/userVerify/view.jsx b/src/renderer/component/userVerify/view.jsx index 07cadba99..5aa05948e 100644 --- a/src/renderer/component/userVerify/view.jsx +++ b/src/renderer/component/userVerify/view.jsx @@ -23,7 +23,7 @@ class UserVerify extends React.PureComponent { } render() { - const { errorMessage, isPending, navigate } = this.props; + const { errorMessage, isPending, navigate, verifyPhone, modal } = this.props; return (
@@ -66,7 +66,30 @@ class UserVerify extends React.PureComponent {
-

{__('2) Proof via YouTube')}

+

{__('2) Proof via Phone')}

+
+
+ {`${__( + 'You will receive an SMS text message confirming that your phone number is correct.' + )}`} +
+
+ { + verifyPhone(); + }} + button="alt" + icon="icon-phone" + label={__('Submit Phone Number')} + /> +
+
+
{__('Standard messaging rates apply.')}
+
+
+
+
+

{__('3) Proof via YouTube')}

@@ -96,7 +119,7 @@ class UserVerify extends React.PureComponent {

-

{__('3) Proof via Chat')}

+

{__('4) Proof via Chat')}

diff --git a/src/renderer/constants/action_types.js b/src/renderer/constants/action_types.js index 861957d36..f0602c1f2 100644 --- a/src/renderer/constants/action_types.js +++ b/src/renderer/constants/action_types.js @@ -109,6 +109,13 @@ export const USER_EMAIL_NEW_FAILURE = 'USER_EMAIL_NEW_FAILURE'; export const USER_EMAIL_VERIFY_STARTED = 'USER_EMAIL_VERIFY_STARTED'; export const USER_EMAIL_VERIFY_SUCCESS = 'USER_EMAIL_VERIFY_SUCCESS'; export const USER_EMAIL_VERIFY_FAILURE = 'USER_EMAIL_VERIFY_FAILURE'; +export const USER_PHONE_RESET = 'USER_PHONE_RESET'; +export const USER_PHONE_NEW_STARTED = 'USER_PHONE_NEW_STARTED'; +export const USER_PHONE_NEW_SUCCESS = 'USER_PHONE_NEW_SUCCESS'; +export const USER_PHONE_NEW_FAILURE = 'USER_PHONE_NEW_FAILURE'; +export const USER_PHONE_VERIFY_STARTED = 'USER_PHONE_VERIFY_STARTED'; +export const USER_PHONE_VERIFY_SUCCESS = 'USER_PHONE_VERIFY_SUCCESS'; +export const USER_PHONE_VERIFY_FAILURE = 'USER_PHONE_VERIFY_FAILURE'; export const USER_IDENTITY_VERIFY_STARTED = 'USER_IDENTITY_VERIFY_STARTED'; export const USER_IDENTITY_VERIFY_SUCCESS = 'USER_IDENTITY_VERIFY_SUCCESS'; export const USER_IDENTITY_VERIFY_FAILURE = 'USER_IDENTITY_VERIFY_FAILURE'; diff --git a/src/renderer/constants/modal_types.js b/src/renderer/constants/modal_types.js index 9c6d457f9..19b86238e 100644 --- a/src/renderer/constants/modal_types.js +++ b/src/renderer/constants/modal_types.js @@ -7,6 +7,7 @@ export const INSUFFICIENT_CREDITS = 'insufficient_credits'; export const UPGRADE = 'upgrade'; export const WELCOME = 'welcome'; export const EMAIL_COLLECTION = 'email_collection'; +export const PHONE_COLLECTION = 'phone_collection'; export const FIRST_REWARD = 'first_reward'; export const AUTHENTICATION_FAILURE = 'auth_failure'; export const TRANSACTION_FAILED = 'transaction_failed'; diff --git a/src/renderer/modal/modalPhoneCollection/index.js b/src/renderer/modal/modalPhoneCollection/index.js new file mode 100644 index 000000000..d3ae17bc6 --- /dev/null +++ b/src/renderer/modal/modalPhoneCollection/index.js @@ -0,0 +1,22 @@ +import React from 'react'; +import * as settings from 'constants/settings'; +import { connect } from 'react-redux'; +import { doCloseModal } from 'redux/actions/app'; +import { doSetClientSetting } from 'redux/actions/settings'; +import { selectPhoneToVerify, selectUser } from 'redux/selectors/user'; +import ModalPhoneCollection from './view'; +import { doNavigate } from 'redux/actions/navigation'; + +const select = state => ({ + phone: selectPhoneToVerify(state), + user: selectUser(state), +}); + +const perform = dispatch => () => ({ + closeModal: () => { + dispatch(doCloseModal()); + dispatch(doNavigate('/rewards')); + }, +}); + +export default connect(select, perform)(ModalPhoneCollection); diff --git a/src/renderer/modal/modalPhoneCollection/view.jsx b/src/renderer/modal/modalPhoneCollection/view.jsx new file mode 100644 index 000000000..bf1ead9f6 --- /dev/null +++ b/src/renderer/modal/modalPhoneCollection/view.jsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { Modal } from 'modal/modal'; +import Link from 'component/link/index'; +import UserPhoneNew from 'component/userPhoneNew'; +import UserPhoneVerify from 'component/userPhoneVerify'; + +class ModalPhoneCollection extends React.PureComponent { + renderInner() { + const { closeModal, phone, user } = this.props; + + const cancelButton = ; + + if (!user.phone_number && !phone) { + return ; + } else if (!user.phone_number) { + return ; + } + closeModal(); + } + + render() { + const { user } = this.props; + + // this shouldn't happen + if (!user) { + return null; + } + + return ( + +

+

Verify Your Phone

+ {this.renderInner()} +
+ + ); + } +} + +export default ModalPhoneCollection; diff --git a/src/renderer/modal/modalRouter/view.jsx b/src/renderer/modal/modalRouter/view.jsx index b2d68fbf2..b84272a6c 100644 --- a/src/renderer/modal/modalRouter/view.jsx +++ b/src/renderer/modal/modalRouter/view.jsx @@ -13,6 +13,7 @@ import ModalFileTimeout from 'modal/modalFileTimeout'; import ModalAffirmPurchase from 'modal/modalAffirmPurchase'; import ModalRevokeClaim from 'modal/modalRevokeClaim'; import ModalEmailCollection from '../modalEmailCollection'; +import ModalPhoneCollection from '../modalPhoneCollection'; import * as modals from 'constants/modal_types'; class ModalRouter extends React.PureComponent { @@ -124,6 +125,8 @@ class ModalRouter extends React.PureComponent { return ; case modals.CONFIRM_CLAIM_REVOKE: return ; + case modals.PHONE_COLLECTION: + return ; case modals.EMAIL_COLLECTION: return ; default: diff --git a/src/renderer/redux/actions/user.js b/src/renderer/redux/actions/user.js index 785a45a09..4863f58cb 100644 --- a/src/renderer/redux/actions/user.js +++ b/src/renderer/redux/actions/user.js @@ -3,7 +3,11 @@ import * as MODALS from 'constants/modal_types'; import Lbryio from 'lbryio'; import { doOpenModal, doShowSnackBar } from 'redux/actions/app'; import { doClaimRewardType, doRewardList } from 'redux/actions/rewards'; -import { selectEmailToVerify } from 'redux/selectors/user'; +import { + selectEmailToVerify, + selectPhoneToVerify, + selectUserCountryCode, +} from 'redux/selectors/user'; import rewards from 'rewards'; export function doFetchInviteStatus() { @@ -78,6 +82,78 @@ export function doUserFetch() { }; } +export function doUserPhoneReset() { + return { + type: ACTIONS.USER_PHONE_RESET, + }; +} + +export function doUserPhoneNew(phone, country_code) { + return dispatch => { + dispatch({ + type: ACTIONS.USER_PHONE_NEW_STARTED, + data: { phone, country_code }, + }); + + const success = () => { + dispatch({ + type: ACTIONS.USER_PHONE_NEW_SUCCESS, + data: { phone }, + }); + }; + + const failure = () => { + dispatch({ + type: ACTIONS.USER_PHONE_NEW_FAILURE, + data: { error: 'An error occurred while processing this phone number.' }, + }); + }; + + Lbryio.call('user', 'phone_number_new', { phone_number: phone, country_code }, 'post').then( + success, + failure + ); + }; +} + +export function doUserPhoneVerifyFailure(error) { + return { + type: ACTIONS.USER_PHONE_VERIFY_FAILURE, + data: { error }, + }; +} + +export function doUserPhoneVerify(verificationCode) { + return (dispatch, getState) => { + const phoneNumber = selectPhoneToVerify(getState()); + const countryCode = selectUserCountryCode(getState()); + + dispatch({ + type: ACTIONS.USER_PHONE_VERIFY_STARTED, + code: verificationCode, + }); + + Lbryio.call( + 'user', + 'phone_number_confirm', + { + verification_code: verificationCode, + phone_number: phoneNumber, + country_code: countryCode, + }, + 'post' + ) + .then(() => { + dispatch({ + type: ACTIONS.USER_PHONE_VERIFY_SUCCESS, + data: { phone_number: phoneNumber }, + }); + dispatch(doUserFetch()); + }) + .catch(error => dispatch(doUserPhoneVerifyFailure(error))); + }; +} + export function doUserEmailNew(email) { return dispatch => { dispatch({ diff --git a/src/renderer/redux/reducers/user.js b/src/renderer/redux/reducers/user.js index f616dd1d1..870eaee9e 100644 --- a/src/renderer/redux/reducers/user.js +++ b/src/renderer/redux/reducers/user.js @@ -55,6 +55,55 @@ reducers[ACTIONS.USER_FETCH_FAILURE] = state => user: null, }); +reducers[ACTIONS.USER_PHONE_NEW_STARTED] = (state, action) => { + const user = Object.assign({}, state.user); + user.country_code = action.data.country_code; + return Object.assign({}, state, { + phoneNewIsPending: true, + phoneNewErrorMessage: '', + user, + }); +}; + +reducers[ACTIONS.USER_PHONE_NEW_SUCCESS] = (state, action) => + Object.assign({}, state, { + phoneToVerify: action.data.phone, + phoneNewIsPending: false, + }); + +reducers[ACTIONS.USER_PHONE_RESET] = state => + Object.assign({}, state, { + phoneToVerify: null, + }); + +reducers[ACTIONS.USER_PHONE_NEW_FAILURE] = (state, action) => + Object.assign({}, state, { + phoneNewIsPending: false, + phoneNewErrorMessage: action.data.error, + }); + +reducers[ACTIONS.USER_PHONE_VERIFY_STARTED] = state => + Object.assign({}, state, { + phoneVerifyIsPending: true, + phoneVerifyErrorMessage: '', + }); + +reducers[ACTIONS.USER_PHONE_VERIFY_SUCCESS] = (state, action) => { + const user = Object.assign({}, state.user); + user.phone_number = action.data.phone_number; + return Object.assign({}, state, { + phoneToVerify: '', + phoneVerifyIsPending: false, + user, + }); +}; + +reducers[ACTIONS.USER_PHONE_VERIFY_FAILURE] = (state, action) => + Object.assign({}, state, { + phoneVerifyIsPending: false, + phoneVerifyErrorMessage: action.data.error, + }); + reducers[ACTIONS.USER_EMAIL_NEW_STARTED] = state => Object.assign({}, state, { emailNewIsPending: true, diff --git a/src/renderer/redux/selectors/user.js b/src/renderer/redux/selectors/user.js index de2c6c447..a1b0df18e 100644 --- a/src/renderer/redux/selectors/user.js +++ b/src/renderer/redux/selectors/user.js @@ -16,12 +16,28 @@ export const selectUserEmail = createSelector( user => (user ? user.primary_email : null) ); +export const selectUserPhone = createSelector( + selectUser, + user => (user ? user.phone_number : null) +); + +export const selectUserCountryCode = createSelector( + selectUser, + user => (user ? user.country_code : null) +); + export const selectEmailToVerify = createSelector( selectState, selectUserEmail, (state, userEmail) => state.emailToVerify || userEmail ); +export const selectPhoneToVerify = createSelector( + selectState, + selectUserPhone, + (state, userPhone) => state.phoneToVerify || userPhone +); + export const selectUserIsRewardApproved = createSelector( selectUser, user => user && user.is_reward_approved @@ -37,6 +53,11 @@ export const selectEmailNewErrorMessage = createSelector( state => state.emailNewErrorMessage ); +export const selectPhoneNewErrorMessage = createSelector( + selectState, + state => state.phoneNewErrorMessage +); + export const selectEmailVerifyIsPending = createSelector( selectState, state => state.emailVerifyIsPending @@ -47,6 +68,11 @@ export const selectEmailVerifyErrorMessage = createSelector( state => state.emailVerifyErrorMessage ); +export const selectPhoneVerifyErrorMessage = createSelector( + selectState, + state => state.phoneVerifyErrorMessage +); + export const selectIdentityVerifyIsPending = createSelector( selectState, state => state.identityVerifyIsPending diff --git a/src/renderer/scss/_vars.scss b/src/renderer/scss/_vars.scss index 15436a5b1..f05049912 100644 --- a/src/renderer/scss/_vars.scss +++ b/src/renderer/scss/_vars.scss @@ -79,6 +79,7 @@ $text-color: #000; /* Select */ --select-bg: var(--color-bg-alt); --select-color: var(--text-color); + --select-height: 30px; /* Button */ --button-bg: var(--color-bg-alt); diff --git a/src/renderer/scss/component/_form-field.scss b/src/renderer/scss/component/_form-field.scss index 042565611..a63352589 100644 --- a/src/renderer/scss/component/_form-field.scss +++ b/src/renderer/scss/component/_form-field.scss @@ -5,6 +5,15 @@ margin-bottom: $spacing-vertical; } +.form-row-phone { + display: flex; + + .form-field__input-text { + margin-left: 5px; + width: calc(0.85 * var(--input-width)); + } +} + .form-row__label-row { margin-top: $spacing-vertical * 5/6; margin-bottom: 0px; @@ -32,7 +41,7 @@ box-sizing: border-box; padding-left: 5px; padding-right: 5px; - height: $spacing-vertical; + height: var(--select-height); background: var(--select-bg); color: var(--select-color); &:focus { diff --git a/yarn.lock b/yarn.lock index 0c540f5bc..7fe8e2985 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2213,6 +2213,13 @@ cosmiconfig@^3.1.0: parse-json "^3.0.0" require-from-string "^2.0.1" +country-data@^0.0.31: + version "0.0.31" + resolved "https://registry.yarnpkg.com/country-data/-/country-data-0.0.31.tgz#80966b8e1d147fa6d6a589d32933f8793774956d" + dependencies: + currency-symbol-map "~2" + underscore ">1.4.4" + create-ecdh@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.0.tgz#888c723596cdf7612f6498233eebd7a35301737d" @@ -2406,6 +2413,10 @@ csso@~2.3.1: clap "^1.0.9" source-map "^0.5.3" +currency-symbol-map@~2: + version "2.2.0" + resolved "https://registry.yarnpkg.com/currency-symbol-map/-/currency-symbol-map-2.2.0.tgz#2b3c1872ff1ac2ce595d8273e58e1fff0272aea2" + currently-unhandled@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea" @@ -8812,6 +8823,10 @@ unc-path-regex@^0.1.0: version "0.1.2" resolved "https://registry.yarnpkg.com/unc-path-regex/-/unc-path-regex-0.1.2.tgz#e73dd3d7b0d7c5ed86fbac6b0ae7d8c6a69d50fa" +underscore@>1.4.4: + version "1.8.3" + resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.8.3.tgz#4f3fb53b106e6097fcf9cb4109f2a5e9bdfa5022" + uniq@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/uniq/-/uniq-1.0.1.tgz#b31c5ae8254844a3a8281541ce2b04b865a734ff"