ducky/web
ducky/web/src/pages/payment.jsx
Start tracking server errors. We've been storing client-side errors for a while now, so we can say "hey that input you entered is really screwed up, please don't even bother submitting the form." But once we pass that hurdle, it's still possible that the server will say "I don't care what the client-side validation said, this input is still wrong." To achieve this, we needed to start tracking server errors and client errors _separately_. The plan, originally, was just to put them in a single array, all mixed together--and, of course, that's what our ValidationError component requires--but there's a usability problem with this. The server error could be "you don't have an internet connection" (using 'server-error' loosely...) or "hey, the server had a problem handling your request, try again." The problem with putting everything together is that all these "try again" errors should get displayed to the user, but anything displayed to the user causes the Register button to be disabled. Meaning they can't try again until they (unintuitively) change pretty much any field in the form. The answer was to separate the two types of errors into their own arrays, and disable the button based on the client-side error array. When displaying errors, we concatenate the arrays. This means server-side errors only get displayed, but do not disable the button. Of course, we can have server errors that can mean "your input is bad and you should feel bad", which are decidedly different from "try again later" errors. These "your input is bad" errors hypothetically _should_ keep the form from being submitted until the value is changed to a valid input. Unfortunately, if we get to the point where a server error is returned, we've already proven the client is incapable of determining between invalid and valid inputs for that specific condition. If the client were capable of knowing, the input never would have reached the server in the first place. So considering the point of disabling the form input is to prevent unnecessary requests to the server, we should never disable the form based on a server error, because (by definition) those requests _must_ be necessary, or the client-side validation would have caught them. TL;DR: disabling a form because of server-side errors is silly. We also fixed a bug in the ValidationError component that would cause any error without a field, param, or header value set (e.g., an error that only contains the error field) would be considered a match for _every_ input, which is not what we want. We still need to decide on logic for displaying global errors. On the naive level, it's easy: match '/' or '' as the field. But that ignores any error responses that we are not specifically looking for. I'd much rather have a catch-all ValidationError handler that will catch any error not already handled by the other ValidationError instances. Unfortunately, I can't wrap my head around a good way to denote that a ValidationError instance has handled a specific error. The best I've got is to do an InverseValidationError that tracks a list of fields, params, and headers (the inputs available for ValidationError) and handles any error that _doesn't_ match them. Unfortunately, for complex forms, this would involve quite a bit of manual bookkeeping, updating ValidationError and InverseValidationError components in sync and never forgetting to update one or the other.
| 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 }) |