From 5c00e3f91511f40ec58c52c25afd364337e9ab0f Mon Sep 17 00:00:00 2001 From: Omar Sahyoun Date: Wed, 28 Nov 2018 11:38:07 +0000 Subject: [PATCH] Default to one click if stored payment method is present. --- .../AmountSelection/AmountSelection.js | 2 + .../CurrencySelector/CurrencySelector.js | 90 ++++++++ .../DonationBands/DonationBands.css | 4 + .../components/DonationBands/DonationBands.js | 8 +- .../__snapshots__/DonationBands.test.js.snap | 4 + .../components/OneClick/OneClick.js | 206 ++++++++++++++++++ .../ComponentWrapper.test.js.snap | 2 + app/javascript/fundraiser/FundraiserView.js | 48 +++- app/javascript/state/fundraiser/actions.js | 4 + app/javascript/state/fundraiser/reducer.js | 5 + app/javascript/state/fundraiser/types.js | 5 + .../plugins/fundraisers/_fundraiser.liquid | 3 +- config/locales/member_facing.en.yml | 2 + 13 files changed, 370 insertions(+), 13 deletions(-) create mode 100644 app/javascript/components/CurrencySelector/CurrencySelector.js create mode 100644 app/javascript/components/OneClick/OneClick.js diff --git a/app/javascript/components/AmountSelection/AmountSelection.js b/app/javascript/components/AmountSelection/AmountSelection.js index 18c5f0997..865e817c4 100644 --- a/app/javascript/components/AmountSelection/AmountSelection.js +++ b/app/javascript/components/AmountSelection/AmountSelection.js @@ -15,6 +15,7 @@ export type Props = { currency: string, nextStepTitle?: React.Element, selectAmount: (amount: ?number) => void, + selectCustomAmount?: (amount: ?number) => void, changeCurrency: (currency: string) => void, proceed: () => void, }; @@ -83,6 +84,7 @@ export default class AmountSelection extends React.Component { proceed={this.props.proceed} featuredAmount={this.props.donationFeaturedAmount} selectAmount={this.props.selectAmount} + selectCustomAmount={this.props.selectCustomAmount} />

{ + constructor(props: Props) { + super(props); + this.state = { + currencyDropdVisible: false, + }; + } + + toggleCurrencyDropd() { + this.setState({ + currencyDropdVisible: !this.state.currencyDropdVisible, + }); + } + + onSelectCurrency(currency: string): void { + this.props.changeCurrency(currency); + } + + selectElement() { + return ( + this.state.currencyDropdVisible && ( + + ) + ); + } + + render() { + return ( +

+ + .  + + + + {this.selectElement()} +

+ ); + } +} + +const mapState = (state: AppState) => ({ + currency: state.fundraiser.currency, + donationBands: state.fundraiser.donationBands, +}); + +const mapDispatch = (dispatch: Dispatch<*>) => ({ + changeCurrency: (currency: string) => dispatch(changeCurrency(currency)), +}); + +export default connect( + mapState, + mapDispatch +)(CurrencySelector); diff --git a/app/javascript/components/DonationBands/DonationBands.css b/app/javascript/components/DonationBands/DonationBands.css index 198fb8e09..622c07c7f 100644 --- a/app/javascript/components/DonationBands/DonationBands.css +++ b/app/javascript/components/DonationBands/DonationBands.css @@ -7,6 +7,10 @@ margin-bottom: 12px; } +.DonationBands-container { + margin-top: 12px; +} + .AmountSelection__proceed-button, .AmountSelection__currency-selector { margin-top: 12px; } diff --git a/app/javascript/components/DonationBands/DonationBands.js b/app/javascript/components/DonationBands/DonationBands.js index a45776503..6996d7f9d 100644 --- a/app/javascript/components/DonationBands/DonationBands.js +++ b/app/javascript/components/DonationBands/DonationBands.js @@ -20,13 +20,15 @@ type Props = { customAmount?: number, proceed: () => void, intl: IntlShape, - selectAmount: (amount: ?number) => void, + selectAmount: (amount: ?number) => void | Promise<*>, + selectCustomAmount?: (amount: ?number) => void | Promise<*>, featuredAmount?: number, }; type State = { customAmount?: number, }; + export class DonationBands extends Component { constructor(props: Props) { super(props); @@ -47,7 +49,9 @@ export class DonationBands extends Component { const number = value.replace(/\D/g, ''); const amount = number ? parseFloat(number) : undefined; this.setState({ customAmount: amount }); - if (this.props.selectAmount) { + if (this.props.selectCustomAmount) { + this.props.selectCustomAmount(amount); + } else { this.props.selectAmount(amount); } } diff --git a/app/javascript/components/DonationBands/__snapshots__/DonationBands.test.js.snap b/app/javascript/components/DonationBands/__snapshots__/DonationBands.test.js.snap index 0eab4c9b8..85344ab84 100644 --- a/app/javascript/components/DonationBands/__snapshots__/DonationBands.test.js.snap +++ b/app/javascript/components/DonationBands/__snapshots__/DonationBands.test.js.snap @@ -151,6 +151,8 @@ exports[`renders all amounts with the currency symbol 1`] = ` "fundraiser.loading": "Loading secure
payment portal", "fundraiser.make_recurring": "Make my donation monthly", "fundraiser.month": "month", + "fundraiser.one_click_failed": "We're sorry but we could not process your donation. Please try again with a different card.", + "fundraiser.one_click_warning": "Your donation will be processed immediately.", "fundraiser.oneclick.credit_card_payment_method": "{card_type} ending in {last_four_digits}", "fundraiser.oneclick.new_payment_method": "Add payment method", "fundraiser.oneclick.paypal_payment_method": "Paypal ({email})", @@ -586,6 +588,8 @@ exports[`renders correctly 1`] = ` "fundraiser.loading": "Loading secure
payment portal", "fundraiser.make_recurring": "Make my donation monthly", "fundraiser.month": "month", + "fundraiser.one_click_failed": "We're sorry but we could not process your donation. Please try again with a different card.", + "fundraiser.one_click_warning": "Your donation will be processed immediately.", "fundraiser.oneclick.credit_card_payment_method": "{card_type} ending in {last_four_digits}", "fundraiser.oneclick.new_payment_method": "Add payment method", "fundraiser.oneclick.paypal_payment_method": "Paypal ({email})", diff --git a/app/javascript/components/OneClick/OneClick.js b/app/javascript/components/OneClick/OneClick.js new file mode 100644 index 000000000..892a0138b --- /dev/null +++ b/app/javascript/components/OneClick/OneClick.js @@ -0,0 +1,206 @@ +// @flow + +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { FormattedMessage } from 'react-intl'; +import DonationBands from '../DonationBands/DonationBands'; +import DonateButton from '../DonateButton'; +import CurrencySelector from '../CurrencySelector/CurrencySelector'; +import ee from '../../shared/pub_sub'; + +import Button from '../Button/Button'; +import { + changeAmount, + setSubmitting, + oneClickFailed, +} from '../../state/fundraiser/actions'; + +import type { AppState } from '../../state/reducers'; +import type { ChampaignPage } from '../../types'; +import type { Dispatch } from 'redux'; + +type Props = { + donationBands: any, + currency: string, + donationAmount: number, + selectAmount: (amount: ?number) => void | Promise<*>, + paymentMethods: any[], + formId: number, + formValues: Object, + form: Object, + title: string, + submitting: boolean, + setSubmitting: boolean => void, + oneClickFailed: () => void, + donationFeaturedAmount?: number, + page: ChampaignPage, +}; + +type State = { + amountConfirmationRequired: boolean, + submitting: boolean, +}; + +class OneClick extends Component { + constructor(props: Props) { + super(props); + this.state = { + amountConfirmationRequired: false, + submitting: false, + }; + } + + async onSelectCustomAmount(amount: ?number) { + await this.props.selectAmount(amount); + this.setState({ amountConfirmationRequired: true }); + } + + async selectAmount(amount: ?number) { + this.props.selectAmount(amount); + this.submit(); + } + + oneClickData() { + return { + payment: { + currency: this.props.currency, + amount: this.props.donationAmount, + recurring: false, + payment_method_id: this.props.paymentMethods[0].id, + // payment_method_id: 900, + }, + user: { + form_id: this.props.formId, + // formValues will have the prefillValues + ...this.props.formValues, + // form will have the user's submitted values + ...this.props.form, + }, + }; + } + + submit = () => { + const data = this.oneClickData(); + if (data) { + this.props.setSubmitting(true); + + $.post( + `/api/payment/braintree/pages/${this.props.page.id}/one_click`, + data + ).then(this.onSuccess.bind(this), this.onFailure.bind(this)); + } + }; + + async onFailure(reason: any): any { + this.setState({ submitting: false }); + this.props.setSubmitting(false); + this.props.oneClickFailed(); + + ee.emit('fundraiser:transaction_error', reason, this.oneClickData()); + return reason; + } + + async onSuccess(data: any): any { + ee.emit('fundraiser:transaction_success', data, this.oneClickData()); + return data; + } + + donateButton() { + if (!this.state.amountConfirmationRequired) return null; + if (!this.props.donationAmount) return null; + + return ( + this.submit()} + /> + ); + } + + procssingView() { + return ( +
+

+ + Processing +

+

+ Please do not close this tab +
+ or use the back button. +

+
+ ); + } + + paymentOptionsView() { + return ( +
+
+
+ +
+
+

{this.props.title}

+
+
+

+ +

+ + {}} + selectAmount={this.selectAmount.bind(this)} + selectCustomAmount={this.onSelectCustomAmount.bind(this)} + /> + + {this.donateButton()} + +
+
+
+ ); + } + + render() { + return this.props.submitting + ? this.procssingView() + : this.paymentOptionsView(); + } +} + +const mapState = (state: AppState) => ({ + currency: state.fundraiser.currency, + donationAmount: state.fundraiser.donationAmount, + donationBands: state.fundraiser.donationBands, + recurring: state.fundraiser.recurring, + paymentMethods: state.paymentMethods, + formId: state.fundraiser.formId, + formValues: state.fundraiser.formValues, + form: state.fundraiser.form, + page: state.page, + submitting: state.fundraiser.submitting, + title: state.fundraiser.title, + donationFeaturedAmount: state.fundraiser.donationFeaturedAmount, +}); + +const mapDispatch = (dispatch: Dispatch<*>) => ({ + selectAmount: (amount: number) => dispatch(changeAmount(amount)), + setSubmitting: (submitting: boolean) => dispatch(setSubmitting(submitting)), + oneClickFailed: () => dispatch(oneClickFailed()), +}); + +export default connect( + mapState, + mapDispatch +)(OneClick); diff --git a/app/javascript/components/__snapshots__/ComponentWrapper.test.js.snap b/app/javascript/components/__snapshots__/ComponentWrapper.test.js.snap index 3775aac8c..e183eaf1b 100644 --- a/app/javascript/components/__snapshots__/ComponentWrapper.test.js.snap +++ b/app/javascript/components/__snapshots__/ComponentWrapper.test.js.snap @@ -137,6 +137,8 @@ exports[`Snapshots: With default messages object 1`] = ` "fundraiser.loading": "Loading secure
payment portal", "fundraiser.make_recurring": "Make my donation monthly", "fundraiser.month": "month", + "fundraiser.one_click_failed": "We're sorry but we could not process your donation. Please try again with a different card.", + "fundraiser.one_click_warning": "Your donation will be processed immediately.", "fundraiser.oneclick.credit_card_payment_method": "{card_type} ending in {last_four_digits}", "fundraiser.oneclick.new_payment_method": "Add payment method", "fundraiser.oneclick.paypal_payment_method": "Paypal ({email})", diff --git a/app/javascript/fundraiser/FundraiserView.js b/app/javascript/fundraiser/FundraiserView.js index 6e455fcbd..f335f6318 100644 --- a/app/javascript/fundraiser/FundraiserView.js +++ b/app/javascript/fundraiser/FundraiserView.js @@ -9,6 +9,7 @@ import StepWrapper from '../components/Stepper/StepWrapper'; import AmountSelection from '../components/AmountSelection/AmountSelection'; import MemberDetailsForm from '../components/MemberDetailsForm/MemberDetailsForm'; import Payment from '../components/Payment/Payment'; +import OneClick from '../components/OneClick/OneClick'; import { changeAmount, changeCurrency, @@ -70,6 +71,7 @@ export class FundraiserView extends Component { currentStep, outstandingFields, submitting, + oneClickError, }, } = this.props; @@ -92,6 +94,23 @@ export class FundraiserView extends Component { 'fundraiser-bar--freestanding': this.props.fundraiser.freestanding, }); + const oneClickErrorMessage = oneClickError ? ( +
+ +
+ ) : null; + + if (this.props.oneClickDonate) { + return ( +
+ +
+ ); + } + return (
{ changeStep={this.props.changeStep} > - this.selectAmount(amount)} - proceed={this.proceed.bind(this)} - /> +
+ {oneClickErrorMessage} + this.selectAmount(amount)} + proceed={this.proceed.bind(this)} + /> +
{this.showStepTwo() && ( @@ -146,10 +168,16 @@ export class FundraiserView extends Component { } export const mapStateToProps = (state: AppState) => ({ + paymentMethods: state.paymentMethods, features: state.features, fundraiser: state.fundraiser, member: state.member, page: state.page, + oneClickError: state.fundraiser.oneClickError, + oneClickDonate: + state.fundraiser.oneClick && + state.paymentMethods.length > 0 && + !state.fundraiser.disableSavedPayments, }); export const mapDispatchToProps = (dispatch: Dispatch<*>) => ({ diff --git a/app/javascript/state/fundraiser/actions.js b/app/javascript/state/fundraiser/actions.js index a98e2adbe..0700faed2 100644 --- a/app/javascript/state/fundraiser/actions.js +++ b/app/javascript/state/fundraiser/actions.js @@ -8,6 +8,10 @@ export function changeAmount(payload: ?number): FundraiserAction { return { type: 'change_amount', payload }; } +export function oneClickFailed(): FundraiserAction { + return { type: 'one_click_failed' }; +} + export function changeCurrency(payload: string): FundraiserAction { ee.emit('fundraiser:change_currency', payload); return { type: 'change_currency', payload }; diff --git a/app/javascript/state/fundraiser/reducer.js b/app/javascript/state/fundraiser/reducer.js index e12a85eb5..d6c183cd1 100644 --- a/app/javascript/state/fundraiser/reducer.js +++ b/app/javascript/state/fundraiser/reducer.js @@ -42,6 +42,7 @@ export const initialState: State = { formId: '', formValues: {}, freestanding: false, + oneClick: false, outstandingFields: [], paymentMethods: [], paymentTypes: ['card', 'paypal'], @@ -50,6 +51,7 @@ export const initialState: State = { recurringDefault: 'one_off', storeInVault: false, submitting: false, + oneClickError: false, title: '', }; @@ -66,6 +68,7 @@ export default (state: State = initialState, action: Action): State => { 'title', 'fields', 'freestanding', + 'oneClick', 'donationAmount' ); initialData.formValues = initialData.formValues || {}; @@ -94,6 +97,8 @@ export default (state: State = initialState, action: Action): State => { case 'change_amount': const donationAmount = action.payload || undefined; return { ...state, donationAmount }; + case 'one_click_failed': + return { ...state, disableSavedPayments: true, oneClickError: true }; case 'change_step': return { ...state, currentStep: action.payload }; case 'update_form': { diff --git a/app/javascript/state/fundraiser/types.js b/app/javascript/state/fundraiser/types.js index 5a32d0935..54c3515cc 100644 --- a/app/javascript/state/fundraiser/types.js +++ b/app/javascript/state/fundraiser/types.js @@ -29,6 +29,8 @@ export type Fundraiser = { formId: string, formValues: Object, freestanding?: boolean, + oneClickError?: boolean, + oneClick: boolean, outstandingFields: string[], paymentMethods: any[], paymentTypes: PaymentType[], @@ -47,6 +49,7 @@ export type FundraiserAction = | { type: 'change_amount', payload: ?number } | { type: 'change_currency', payload: string } | { type: 'change_step', payload: number } + | { type: 'one_click_failed' } | { type: 'preselect_amount', payload: boolean } | { type: 'reset_member' } | { type: 'set_direct_debit_only', payload: boolean } @@ -67,6 +70,8 @@ export type FundraiserInitializationOptions = { formValues: { [key: string]: string }, formId: string, freestanding: boolean, + oneClick?: boolean, + oneClickError: boolean, outstandingFields: string[], pageId: string, preselectAmount: boolean, diff --git a/app/views/plugins/fundraisers/_fundraiser.liquid b/app/views/plugins/fundraisers/_fundraiser.liquid index efeee4f9e..2530032ab 100644 --- a/app/views/plugins/fundraisers/_fundraiser.liquid +++ b/app/views/plugins/fundraisers/_fundraiser.liquid @@ -30,7 +30,8 @@ preselectAmount: {{ plugins.fundraiser[ref].preselect_amount }}, fields: {{ plugins.fundraiser[ref].fields | jsonify }}, recurringDefault: global.urlParams.recurring_default || "{{ plugins.fundraiser[ref].recurring_default }}", - freestanding: {% if freestanding %} true {% else %} false {% endif %} + freestanding: {% if freestanding %} true {% else %} false {% endif %}, + oneClick: {% if one_click %} true {% else %} false {% endif %} } }; window.setTimeout(function(){ diff --git a/config/locales/member_facing.en.yml b/config/locales/member_facing.en.yml index c34fb265b..8648afca5 100644 --- a/config/locales/member_facing.en.yml +++ b/config/locales/member_facing.en.yml @@ -60,6 +60,8 @@ en: target_full_name: '[Select your representative above]' fundraiser: + one_click_warning: "Your donation will be processed immediately." + one_click_failed: "We're sorry but we could not process your donation. Please try again with a different card." payment_methods: gocardless: "Direct Debit" paypal: "PayPal"