ducky/web
ducky/web/src/pages/payment.jsx
Persist session data to localStorage. Create a helper library that figures out whether to write to chrome.storage.local or window.localStorage, and unifies their two APIs. Update the Me model to use the getOrFetch method for the profiles collection when retrieving the user's profile. This, unfortunately, makes it an async call (because we may need to fetch data from the server), so we can no longer have it be a derived property, which is a shame. It instead must just be the me.profile() function. Separate out the logic to determine when an access token expires, and turn it into the tokenExpires function. Fill the writeToCache placeholder with the logic to store the current session in either window.localStorage or chrome.storage.local, whichever is the more appropriate, using the helper library. Create the load helper function that will attempt to read session data from localStorage or chrome.storage.local, whichever the library decides is available, and updates the session based on it. Implement the logout function, which just uses the helper library to remove the session data from window.localStorage or chrome.storage.local. We should also be resetting the app.me variable, however. Create a debouncedWriteToCache function that will only write to the cache once every 250 ms, to avoid rushes on the cache. When instantiating our app.me variable, load it in from localStorage or chrome.storage.local if we can. Also, listen for changes to app.me, and persist them to chrome.storage.local or localStorage.
| 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@9 | 27 plan: this.getDefaultPlan(), |
| paddy@2 | 28 expireMonth: null, |
| paddy@2 | 29 expireYear: null, |
| paddy@2 | 30 } |
| paddy@2 | 31 }, |
| paddy@2 | 32 |
| paddy@9 | 33 getDefaultPlan () { |
| paddy@9 | 34 if (app.me && app.me.subscription && app.me.subscription.plan) { |
| paddy@9 | 35 return app.me.subscription.plan |
| paddy@9 | 36 } |
| paddy@9 | 37 return 'basic_monthly' |
| paddy@9 | 38 }, |
| paddy@9 | 39 |
| paddy@8 | 40 nameValidationOutputs: { |
| paddy@8 | 41 }, |
| paddy@8 | 42 numberValidationOutputs: { |
| paddy@8 | 43 'invalid_value': 'Are you sure this is right? That number didn’t work.', |
| paddy@8 | 44 'invalid_format': 'That’s not a valid credit card number.', |
| paddy@8 | 45 }, |
| paddy@8 | 46 cvcValidationOutputs: { |
| paddy@8 | 47 'invalid_format': 'That’s not a valid security code.', |
| paddy@8 | 48 'invalid_value': 'That’s not the correct security code.', |
| paddy@8 | 49 }, |
| paddy@8 | 50 expirationValidationOutputs: { |
| paddy@8 | 51 'invalid_format': 'That’s not a valid expiration date.', |
| paddy@8 | 52 'invalid_value': 'That card appears to be expired.', |
| paddy@8 | 53 }, |
| paddy@8 | 54 balanceValidationOutputs: { |
| paddy@8 | 55 'insufficient': 'Your card was declined.', |
| paddy@8 | 56 }, |
| paddy@9 | 57 planValidationOutputs: { |
| paddy@9 | 58 'invalid_value': 'That’s not a valid plan!', |
| paddy@9 | 59 }, |
| paddy@8 | 60 |
| paddy@8 | 61 catchAllValidationOutputs: { |
| paddy@8 | 62 }, |
| paddy@2 | 63 |
| paddy@2 | 64 onScriptLoaded () { |
| paddy@2 | 65 Stripe.setPublishableKey(config.stripeKey) |
| paddy@2 | 66 this.setState({stripeLoading: false}) |
| paddy@2 | 67 }, |
| paddy@2 | 68 |
| paddy@2 | 69 onScriptError () { |
| paddy@8 | 70 this.setState({stripeFailedToLoad: true, errors: [{'error': 'act_of_god'}]}) |
| paddy@2 | 71 }, |
| paddy@2 | 72 |
| paddy@2 | 73 addCard (e) { |
| paddy@2 | 74 e.preventDefault() |
| paddy@2 | 75 if (this.state.stripeLoading || this.state.stripeFailedToLoad) { |
| paddy@2 | 76 return |
| paddy@2 | 77 } |
| paddy@2 | 78 this.setState({active: true}) |
| paddy@2 | 79 const t = this |
| paddy@2 | 80 const errors = [] |
| paddy@2 | 81 Stripe.card.createToken({ |
| paddy@2 | 82 number: this.state.number, |
| paddy@2 | 83 cvc: this.state.cvc, |
| paddy@2 | 84 exp_month: this.state.expireMonth, |
| paddy@2 | 85 exp_year: this.state.expireYear, |
| paddy@2 | 86 name: this.state.name, |
| paddy@2 | 87 }, function(status, response) { |
| paddy@2 | 88 if (response.error) { |
| paddy@2 | 89 if (response.error.type == 'card_error') { |
| paddy@2 | 90 switch (response.error.code) { |
| paddy@2 | 91 case 'incorrect_number': |
| paddy@2 | 92 errors.push({'error': 'invalid_value', 'field': '/number'}) |
| paddy@2 | 93 break |
| paddy@2 | 94 case 'invalid_number': |
| paddy@2 | 95 errors.push({'error': 'invalid_format', 'field': '/number'}) |
| paddy@2 | 96 break |
| paddy@2 | 97 case 'invalid_expiry_month': |
| paddy@8 | 98 errors.push({'error': 'invalid_format', 'field': '/expiration'}) |
| paddy@2 | 99 break |
| paddy@2 | 100 case 'invalid_expiry_year': |
| paddy@8 | 101 errors.push({'error': 'invalid_format', 'field': '/expiration'}) |
| paddy@2 | 102 break |
| paddy@2 | 103 case 'invalid_cvc': |
| paddy@2 | 104 errors.push({'error': 'invalid_format', 'field': '/cvc'}) |
| paddy@2 | 105 break |
| paddy@2 | 106 case 'expired_card': |
| paddy@2 | 107 errors.push({'error': 'invalid_value', 'field': '/expiration'}) |
| paddy@2 | 108 break |
| paddy@2 | 109 case 'incorrect_cvc': |
| paddy@2 | 110 errors.push({'error': 'invalid_value', 'field': '/cvc'}) |
| paddy@2 | 111 break |
| paddy@2 | 112 case 'incorrect_zip': |
| paddy@2 | 113 errors.push({'error': 'invalid_value', 'field': '/zip'}) |
| paddy@2 | 114 break |
| paddy@2 | 115 case 'card_declined': |
| paddy@2 | 116 errors.push({'error': 'insufficient', 'field': '/balance'}) |
| paddy@2 | 117 break |
| paddy@2 | 118 case 'missing': |
| paddy@2 | 119 errors.push({'error': 'missing', 'field': '/customer/card'}) |
| paddy@2 | 120 break |
| paddy@2 | 121 case 'processing_error': |
| paddy@2 | 122 errors.push({'error': 'act_of_god', 'field': '/'}) |
| paddy@2 | 123 break |
| paddy@2 | 124 case 'rate_limit': |
| paddy@2 | 125 errors.push({'error': 'access_denied', 'field': '/rate'}) |
| paddy@2 | 126 break |
| paddy@2 | 127 default: |
| paddy@2 | 128 errors.push({'error': 'act_of_god', 'field': '/'}) |
| paddy@2 | 129 break |
| paddy@2 | 130 } |
| paddy@2 | 131 } else { |
| paddy@2 | 132 console.log('Error:', response.error.message) |
| paddy@2 | 133 } |
| paddy@2 | 134 } else { |
| paddy@2 | 135 console.log('Sending '+response.id+' to server to create customer') |
| paddy@2 | 136 } |
| paddy@2 | 137 t.setState({active: false, errors: errors}) |
| paddy@2 | 138 }) |
| paddy@2 | 139 }, |
| paddy@2 | 140 |
| paddy@2 | 141 render () { |
| paddy@2 | 142 return ( |
| paddy@2 | 143 <div className='container'> |
| paddy@9 | 144 <HeroUnit title='Set Up Your Subscription'>Gotta pay those bills.</HeroUnit> |
| paddy@2 | 145 <article className='onboarding payment'> |
| paddy@9 | 146 <p>Ducky costs money to run, and to keep it improving. We pass these costs on to you. There’s no parent company, no ads, nothing but people making software they want you to enjoy. Also, you get a |
| paddy@9 | 147 free 31 day trial. Cancel before it ends, and we won’t charge you at all.</p> |
| paddy@2 | 148 <form onSubmit={this.addCard}> |
| paddy@2 | 149 <div> |
| paddy@2 | 150 <label htmlFor='name'>Cardholder Name</label> |
| paddy@2 | 151 <input id='name' type='text' placeholder='This is the name on your card' valueLink={this.linkState('name')} /> |
| paddy@2 | 152 <ValidationError errors={this.state.errors} field='/name' outputs={this.nameValidationOutputs} /> |
| paddy@2 | 153 |
| paddy@2 | 154 <label htmlFor='cardNumber'>Card Number</label> |
| paddy@2 | 155 <input id='cardNumber' type='text' placeholder='4242 4242 4242 4242' valueLink={this.linkState('number')} /> |
| paddy@2 | 156 <ValidationError errors={this.state.errors} field='/number' outputs={this.numberValidationOutputs} /> |
| paddy@2 | 157 |
| paddy@2 | 158 <label htmlFor='cvc'>Security Code / CVC</label> |
| paddy@2 | 159 <input id='cvc' className='cvc' type='password' placeholder='123' valueLink={this.linkState('cvc')} /> |
| paddy@2 | 160 |
| paddy@2 | 161 <label htmlFor='expireMonth' className='expiration'>Expires</label> |
| paddy@2 | 162 <input id='expireMonth' className='expiration month' type='text' placeholder='01' valueLink={this.linkState('expireMonth')} /> |
| paddy@2 | 163 <input id='expireYear' className='expiration year' type='text' placeholder='15' valueLink={this.linkState('expireYear')} /> |
| paddy@2 | 164 <ValidationError errors={this.state.errors} field='/cvc' outputs={this.cvcValidationOutputs} /> |
| paddy@2 | 165 <ValidationError errors={this.state.errors} field='/expiration' outputs={this.expirationValidationOutputs} /> |
| paddy@9 | 166 |
| paddy@9 | 167 <label htmlFor='plan'>Plan</label> |
| paddy@9 | 168 <select id='plan' className='plan' defaultValue={this.getDefaultPlan()}> |
| paddy@9 | 169 <option value='basic_monthly'>Basic Monthly - $2/month</option> |
| paddy@9 | 170 <option value='basic_yearly'>Basic Yearly - $20/year</option> |
| paddy@9 | 171 <option value='supporter_monthly'>Supporter Monthly - $5/month</option> |
| paddy@9 | 172 <option value='supporter_yearly'>Supporter Yearly - $50/year</option> |
| paddy@9 | 173 </select> |
| paddy@9 | 174 <ValidationError errors={this.state.errors} field='/plan' outputs={this.planValidationOutputs} /> |
| paddy@9 | 175 <p>There’s no difference between the Supporter and Basic tiers. The Supporter tier is just for people who love Ducky and want to see it grow and improve.</p> |
| paddy@9 | 176 |
| paddy@8 | 177 <ValidationError errors={this.state.errors} field='/balance' outputs={this.balanceValidationOutputs} /> |
| paddy@9 | 178 <ValidationError errors={this.state.errors} notFields={['/name', '/number', '/cvc', '/expiration', '/plan', '/balance']} notParams={[]} notHeaders={[]} outputs={this.catchAllValidationOutputs} /> |
| paddy@2 | 179 |
| paddy@2 | 180 <div className='actionbuttons'> |
| paddy@2 | 181 <LaddaButton style='expand-right' active={this.state.active}> |
| paddy@2 | 182 <button type='submit' className='primary' disabled={this.state.stripeLoading || this.state.stripeFailedToLoad || this.state.active || this.state.errors.length}>Add Card</button> |
| paddy@2 | 183 </LaddaButton> |
| paddy@2 | 184 </div> |
| paddy@2 | 185 </div> |
| paddy@2 | 186 </form> |
| paddy@2 | 187 </article> |
| paddy@2 | 188 </div> |
| paddy@2 | 189 ) |
| paddy@2 | 190 } |
| paddy@2 | 191 }) |