ducky/web
ducky/web/src/pages/payment.jsx
Add error handling messages, clear server errors on validation. Add missing messages for error conditions that our server can return. Clear our server error messages when client-side validation occurs, so you aren't confusingly left with errors after you change the input that caused them. Ideally, this behaviour would be limited to just the errors that were caused by the updated field, but clearing all of them seems to be the more user-friendly behaviour than just leaving them. Baby steps. Fix a bug in our handling of error messages--ampersand-sync is kind of inconsistent, and parses our successful responses as JSON, but neglects to parse the error responses as JSON. So we need to manually parse our errors from JSON before we can work with them. We also added a handler for invalid_client error messages, which don't match our error objects (thanks OAuth2 spec!), so we translate it to one of our error objects and then add it to the server errors to be displayed.
| paddy@2 | 1 import app from 'ampersand-app' |
| paddy@2 | 2 import React from 'react' |
| paddy@2 | 3 import ScriptLoaderMixin from 'react-script-loader' |
| paddy@2 | 4 import LaddaButton from 'react-ladda' |
| paddy@2 | 5 import LaddaCSS from '../../node_modules/ladda/dist/ladda.min.css' |
| paddy@2 | 6 import HeroUnit from '../components/hero' |
| paddy@2 | 7 import ValidationError from '../components/validation-error' |
| paddy@2 | 8 import onboardStyles from '../styles/onboarding.scss' |
| paddy@2 | 9 import config from '../config' |
| paddy@2 | 10 |
| paddy@2 | 11 export default React.createClass({ |
| paddy@2 | 12 displayName: 'PaymentMethodPage', |
| paddy@2 | 13 mixins: [ScriptLoaderMixin.ReactScriptLoaderMixin, React.addons.LinkedStateMixin], |
| paddy@2 | 14 getScriptURL () { |
| paddy@2 | 15 return 'https://js.stripe.com/v2/' |
| paddy@2 | 16 }, |
| paddy@2 | 17 |
| paddy@2 | 18 getInitialState () { |
| paddy@2 | 19 return { |
| paddy@2 | 20 stripeLoading: true, |
| paddy@2 | 21 stripeFailedToLoad: false, |
| paddy@2 | 22 active: false, |
| paddy@2 | 23 errors: [], |
| paddy@2 | 24 number: null, |
| paddy@2 | 25 name: null, |
| paddy@2 | 26 cvc: null, |
| paddy@2 | 27 expireMonth: null, |
| paddy@2 | 28 expireYear: null, |
| paddy@2 | 29 } |
| paddy@2 | 30 }, |
| paddy@2 | 31 |
| paddy@2 | 32 nameValidationOutputs: {}, |
| paddy@2 | 33 numberValidationOutputs: {}, |
| paddy@2 | 34 cvcValidationOutputs: {}, |
| paddy@2 | 35 expirationValidationOutputs: {}, |
| paddy@2 | 36 |
| paddy@2 | 37 onScriptLoaded () { |
| paddy@2 | 38 Stripe.setPublishableKey(config.stripeKey) |
| paddy@2 | 39 this.setState({stripeLoading: false}) |
| paddy@2 | 40 }, |
| paddy@2 | 41 |
| paddy@2 | 42 onScriptError () { |
| paddy@2 | 43 this.setState({stripeFailedToLoad: true}) |
| paddy@2 | 44 }, |
| paddy@2 | 45 |
| paddy@2 | 46 getChargeDate () { |
| paddy@2 | 47 let created = new Date() |
| paddy@2 | 48 if (app.me && app.me.profile && app.me.profile.created && app.me.profile.created < created) { |
| paddy@2 | 49 console.log("using register date...") |
| paddy@2 | 50 created = app.me.profile.created |
| paddy@2 | 51 } |
| paddy@2 | 52 let month = created.getMonth() |
| paddy@2 | 53 let day = created.getDate() |
| paddy@2 | 54 month = month + 1 |
| paddy@2 | 55 if (day > 1) { |
| paddy@2 | 56 day = 1 |
| paddy@2 | 57 month = month + 1 |
| paddy@2 | 58 } |
| paddy@2 | 59 let result = new Date(created.toString()) |
| paddy@2 | 60 result.setDate(day) |
| paddy@2 | 61 result.setMonth(month) |
| paddy@2 | 62 return result |
| paddy@2 | 63 }, |
| paddy@2 | 64 |
| paddy@2 | 65 addCard (e) { |
| paddy@2 | 66 e.preventDefault() |
| paddy@2 | 67 if (this.state.stripeLoading || this.state.stripeFailedToLoad) { |
| paddy@2 | 68 return |
| paddy@2 | 69 } |
| paddy@2 | 70 this.setState({active: true}) |
| paddy@2 | 71 const t = this |
| paddy@2 | 72 const errors = [] |
| paddy@2 | 73 Stripe.card.createToken({ |
| paddy@2 | 74 number: this.state.number, |
| paddy@2 | 75 cvc: this.state.cvc, |
| paddy@2 | 76 exp_month: this.state.expireMonth, |
| paddy@2 | 77 exp_year: this.state.expireYear, |
| paddy@2 | 78 name: this.state.name, |
| paddy@2 | 79 }, function(status, response) { |
| paddy@2 | 80 console.log(status) |
| paddy@2 | 81 console.log(response) |
| paddy@2 | 82 if (response.error) { |
| paddy@2 | 83 if (response.error.type == 'card_error') { |
| paddy@2 | 84 switch (response.error.code) { |
| paddy@2 | 85 case 'incorrect_number': |
| paddy@2 | 86 errors.push({'error': 'invalid_value', 'field': '/number'}) |
| paddy@2 | 87 break |
| paddy@2 | 88 case 'invalid_number': |
| paddy@2 | 89 errors.push({'error': 'invalid_format', 'field': '/number'}) |
| paddy@2 | 90 break |
| paddy@2 | 91 case 'invalid_expiry_month': |
| paddy@2 | 92 errors.push({'error': 'invalid_format', 'field': '/expireMonth'}) |
| paddy@2 | 93 break |
| paddy@2 | 94 case 'invalid_expiry_year': |
| paddy@2 | 95 errors.push({'error': 'invalid_format', 'field': '/expireYear'}) |
| paddy@2 | 96 break |
| paddy@2 | 97 case 'invalid_cvc': |
| paddy@2 | 98 errors.push({'error': 'invalid_format', 'field': '/cvc'}) |
| paddy@2 | 99 break |
| paddy@2 | 100 case 'expired_card': |
| paddy@2 | 101 errors.push({'error': 'invalid_value', 'field': '/expiration'}) |
| paddy@2 | 102 break |
| paddy@2 | 103 case 'incorrect_cvc': |
| paddy@2 | 104 errors.push({'error': 'invalid_value', 'field': '/cvc'}) |
| paddy@2 | 105 break |
| paddy@2 | 106 case 'incorrect_zip': |
| paddy@2 | 107 errors.push({'error': 'invalid_value', 'field': '/zip'}) |
| paddy@2 | 108 break |
| paddy@2 | 109 case 'card_declined': |
| paddy@2 | 110 errors.push({'error': 'insufficient', 'field': '/balance'}) |
| paddy@2 | 111 break |
| paddy@2 | 112 case 'missing': |
| paddy@2 | 113 errors.push({'error': 'missing', 'field': '/customer/card'}) |
| paddy@2 | 114 break |
| paddy@2 | 115 case 'processing_error': |
| paddy@2 | 116 errors.push({'error': 'act_of_god', 'field': '/'}) |
| paddy@2 | 117 break |
| paddy@2 | 118 case 'rate_limit': |
| paddy@2 | 119 errors.push({'error': 'access_denied', 'field': '/rate'}) |
| paddy@2 | 120 break |
| paddy@2 | 121 default: |
| paddy@2 | 122 errors.push({'error': 'act_of_god', 'field': '/'}) |
| paddy@2 | 123 break |
| paddy@2 | 124 } |
| paddy@2 | 125 } else { |
| paddy@2 | 126 console.log('Error:', response.error.message) |
| paddy@2 | 127 } |
| paddy@2 | 128 } else { |
| paddy@2 | 129 console.log('Sending '+response.id+' to server to create customer') |
| paddy@2 | 130 } |
| paddy@2 | 131 t.setState({active: false, errors: errors}) |
| paddy@2 | 132 }) |
| paddy@2 | 133 }, |
| paddy@2 | 134 |
| paddy@2 | 135 render () { |
| paddy@2 | 136 return ( |
| paddy@2 | 137 <div className='container'> |
| paddy@2 | 138 <HeroUnit title='Add a Payment Method'>Gotta keep our servers online and food on the table.</HeroUnit> |
| paddy@2 | 139 <article className='onboarding payment'> |
| paddy@2 | 140 <p>Ducky costs $2 a month to use. We’ll charge the card you enter below on the first of every month until your account is disabled. You won’t be charged before {this.getChargeDate().toLocaleDateString(navigator.languages, {month: 'long', year: 'numeric', day: 'numeric'})}.</p> |
| paddy@2 | 141 <form onSubmit={this.addCard}> |
| paddy@2 | 142 <div> |
| paddy@2 | 143 <label htmlFor='name'>Cardholder Name</label> |
| paddy@2 | 144 <input id='name' type='text' placeholder='This is the name on your card' valueLink={this.linkState('name')} /> |
| paddy@2 | 145 <ValidationError errors={this.state.errors} field='/name' outputs={this.nameValidationOutputs} /> |
| paddy@2 | 146 |
| paddy@2 | 147 <label htmlFor='cardNumber'>Card Number</label> |
| paddy@2 | 148 <input id='cardNumber' type='text' placeholder='4242 4242 4242 4242' valueLink={this.linkState('number')} /> |
| paddy@2 | 149 <ValidationError errors={this.state.errors} field='/number' outputs={this.numberValidationOutputs} /> |
| paddy@2 | 150 |
| paddy@2 | 151 <label htmlFor='cvc'>Security Code / CVC</label> |
| paddy@2 | 152 <input id='cvc' className='cvc' type='password' placeholder='123' valueLink={this.linkState('cvc')} /> |
| paddy@2 | 153 |
| paddy@2 | 154 <label htmlFor='expireMonth' className='expiration'>Expires</label> |
| paddy@2 | 155 <input id='expireMonth' className='expiration month' type='text' placeholder='01' valueLink={this.linkState('expireMonth')} /> |
| paddy@2 | 156 <input id='expireYear' className='expiration year' type='text' placeholder='15' valueLink={this.linkState('expireYear')} /> |
| paddy@2 | 157 <ValidationError errors={this.state.errors} field='/cvc' outputs={this.cvcValidationOutputs} /> |
| paddy@2 | 158 <ValidationError errors={this.state.errors} field='/expiration' outputs={this.expirationValidationOutputs} /> |
| paddy@2 | 159 |
| paddy@2 | 160 <div className='actionbuttons'> |
| paddy@2 | 161 <button type='button' onClick={this.skip} className='ladda-button' disabled={this.state.active}>Not Now</button> |
| paddy@2 | 162 <LaddaButton style='expand-right' active={this.state.active}> |
| paddy@2 | 163 <button type='submit' className='primary' disabled={this.state.stripeLoading || this.state.stripeFailedToLoad || this.state.active || this.state.errors.length}>Add Card</button> |
| paddy@2 | 164 </LaddaButton> |
| paddy@2 | 165 </div> |
| paddy@2 | 166 </div> |
| paddy@2 | 167 </form> |
| paddy@2 | 168 </article> |
| paddy@2 | 169 </div> |
| paddy@2 | 170 ) |
| paddy@2 | 171 } |
| paddy@2 | 172 }) |