ducky/web
ducky/web/src/pages/payment.jsx
Switch to being a website instead of a Chrome app. Update our CNAME to be the more appropriate "prototype.useducky.com" when we deploy. Create a homepage and a non-onboarding page template. This mostly consisted of setting up a header component and the associated styles, and then a logged-in vs. guest flavor of said header, changing the links appropriately. We also created a simple homepage that describes what Ducky is and does, and gave a jumping-off point for it. Stubbed out a basic links page, just to get an idea for what the homepage would be like when a logged-in user navigated to the homepage (e.g., not the marketing copy). Updated our login page to _actually work_, and redirected it to the new URL for the payment setup page. Updated the payment page to actually create a subscription, and moved it from /register/payment to just /payment. Fixed a bug in our registration page that was looking for an invalid_form error when it really meant an invalid_format error. Ooops. Also, updated it to point to the new /payment endpoint instead of /register/payment. Updated our router to use the new homepage, the new links page, and updated the URL for the payment page. Updated our button styles so they should all have the right font color, padding, and border-radius, but could've potentially screwed something up. Oops. Updated our backgrounds all over to have a transparent-y white background behind the content, and a simple pattern for the rest of the body. Not sure how I feel about it just yet, but I'm not going to keep futzing with it.
| 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@22 | 61 userValidationOutputs: { |
| paddy@22 | 62 'conflict': 'You already have a subscription! Head to your account page to manage it.', |
| paddy@22 | 63 }, |
| paddy@22 | 64 |
| paddy@8 | 65 catchAllValidationOutputs: { |
| paddy@22 | 66 'act_of_god': 'Whoops. Something went wrong. Let support know!', |
| paddy@8 | 67 }, |
| paddy@2 | 68 |
| paddy@2 | 69 onScriptLoaded () { |
| paddy@2 | 70 Stripe.setPublishableKey(config.stripeKey) |
| paddy@2 | 71 this.setState({stripeLoading: false}) |
| paddy@2 | 72 }, |
| paddy@2 | 73 |
| paddy@2 | 74 onScriptError () { |
| paddy@8 | 75 this.setState({stripeFailedToLoad: true, errors: [{'error': 'act_of_god'}]}) |
| paddy@2 | 76 }, |
| paddy@2 | 77 |
| paddy@22 | 78 componentDidMount () { |
| paddy@22 | 79 app.subscriptions.on('error', (moc, xhr, options) => { |
| paddy@22 | 80 let state = {active: false} |
| paddy@22 | 81 let resp = {} |
| paddy@22 | 82 if (xhr && xhr.response) { |
| paddy@22 | 83 resp = JSON.parse(xhr.response) |
| paddy@22 | 84 } |
| paddy@22 | 85 if (resp.errors && resp.errors.length) { |
| paddy@22 | 86 state.errors = resp.errors |
| paddy@22 | 87 } else { |
| paddy@22 | 88 state.errors = [{'error': 'act_of_god'}] |
| paddy@22 | 89 } |
| paddy@22 | 90 this.setState(state) |
| paddy@22 | 91 }) |
| paddy@22 | 92 app.subscriptions.on('sync', (moc, xhr, options) => { |
| paddy@22 | 93 this.setState({active: false}) |
| paddy@22 | 94 // TODO: do something? |
| paddy@22 | 95 }) |
| paddy@22 | 96 }, |
| paddy@22 | 97 |
| paddy@2 | 98 addCard (e) { |
| paddy@2 | 99 e.preventDefault() |
| paddy@2 | 100 if (this.state.stripeLoading || this.state.stripeFailedToLoad) { |
| paddy@2 | 101 return |
| paddy@2 | 102 } |
| paddy@22 | 103 this.setState({active: true, errors: []}) |
| paddy@2 | 104 const t = this |
| paddy@2 | 105 const errors = [] |
| paddy@2 | 106 Stripe.card.createToken({ |
| paddy@2 | 107 number: this.state.number, |
| paddy@2 | 108 cvc: this.state.cvc, |
| paddy@2 | 109 exp_month: this.state.expireMonth, |
| paddy@2 | 110 exp_year: this.state.expireYear, |
| paddy@2 | 111 name: this.state.name, |
| paddy@2 | 112 }, function(status, response) { |
| paddy@2 | 113 if (response.error) { |
| paddy@2 | 114 if (response.error.type == 'card_error') { |
| paddy@2 | 115 switch (response.error.code) { |
| paddy@2 | 116 case 'incorrect_number': |
| paddy@2 | 117 errors.push({'error': 'invalid_value', 'field': '/number'}) |
| paddy@2 | 118 break |
| paddy@2 | 119 case 'invalid_number': |
| paddy@2 | 120 errors.push({'error': 'invalid_format', 'field': '/number'}) |
| paddy@2 | 121 break |
| paddy@2 | 122 case 'invalid_expiry_month': |
| paddy@8 | 123 errors.push({'error': 'invalid_format', 'field': '/expiration'}) |
| paddy@2 | 124 break |
| paddy@2 | 125 case 'invalid_expiry_year': |
| paddy@8 | 126 errors.push({'error': 'invalid_format', 'field': '/expiration'}) |
| paddy@2 | 127 break |
| paddy@2 | 128 case 'invalid_cvc': |
| paddy@2 | 129 errors.push({'error': 'invalid_format', 'field': '/cvc'}) |
| paddy@2 | 130 break |
| paddy@2 | 131 case 'expired_card': |
| paddy@2 | 132 errors.push({'error': 'invalid_value', 'field': '/expiration'}) |
| paddy@2 | 133 break |
| paddy@2 | 134 case 'incorrect_cvc': |
| paddy@2 | 135 errors.push({'error': 'invalid_value', 'field': '/cvc'}) |
| paddy@2 | 136 break |
| paddy@2 | 137 case 'incorrect_zip': |
| paddy@2 | 138 errors.push({'error': 'invalid_value', 'field': '/zip'}) |
| paddy@2 | 139 break |
| paddy@2 | 140 case 'card_declined': |
| paddy@2 | 141 errors.push({'error': 'insufficient', 'field': '/balance'}) |
| paddy@2 | 142 break |
| paddy@2 | 143 case 'missing': |
| paddy@2 | 144 errors.push({'error': 'missing', 'field': '/customer/card'}) |
| paddy@2 | 145 break |
| paddy@2 | 146 case 'processing_error': |
| paddy@2 | 147 errors.push({'error': 'act_of_god', 'field': '/'}) |
| paddy@2 | 148 break |
| paddy@2 | 149 case 'rate_limit': |
| paddy@2 | 150 errors.push({'error': 'access_denied', 'field': '/rate'}) |
| paddy@2 | 151 break |
| paddy@2 | 152 default: |
| paddy@2 | 153 errors.push({'error': 'act_of_god', 'field': '/'}) |
| paddy@2 | 154 break |
| paddy@2 | 155 } |
| paddy@2 | 156 } else { |
| paddy@2 | 157 console.log('Error:', response.error.message) |
| paddy@2 | 158 } |
| paddy@2 | 159 } else { |
| paddy@22 | 160 app.subscriptions.create({ |
| paddy@22 | 161 'user_id': app.me.profileID, |
| paddy@22 | 162 'plan': t.state.plan, |
| paddy@22 | 163 'stripe_token': response.id, |
| paddy@22 | 164 'email': app.me.email, |
| paddy@22 | 165 }) |
| paddy@2 | 166 } |
| paddy@2 | 167 }) |
| paddy@2 | 168 }, |
| paddy@2 | 169 |
| paddy@2 | 170 render () { |
| paddy@2 | 171 return ( |
| paddy@2 | 172 <div className='container'> |
| paddy@9 | 173 <HeroUnit title='Set Up Your Subscription'>Gotta pay those bills.</HeroUnit> |
| paddy@2 | 174 <article className='onboarding payment'> |
| paddy@9 | 175 <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 | 176 free 31 day trial. Cancel before it ends, and we won’t charge you at all.</p> |
| paddy@2 | 177 <form onSubmit={this.addCard}> |
| paddy@2 | 178 <div> |
| paddy@2 | 179 <label htmlFor='name'>Cardholder Name</label> |
| paddy@2 | 180 <input id='name' type='text' placeholder='This is the name on your card' valueLink={this.linkState('name')} /> |
| paddy@2 | 181 <ValidationError errors={this.state.errors} field='/name' outputs={this.nameValidationOutputs} /> |
| paddy@2 | 182 |
| paddy@2 | 183 <label htmlFor='cardNumber'>Card Number</label> |
| paddy@2 | 184 <input id='cardNumber' type='text' placeholder='4242 4242 4242 4242' valueLink={this.linkState('number')} /> |
| paddy@2 | 185 <ValidationError errors={this.state.errors} field='/number' outputs={this.numberValidationOutputs} /> |
| paddy@2 | 186 |
| paddy@2 | 187 <label htmlFor='cvc'>Security Code / CVC</label> |
| paddy@2 | 188 <input id='cvc' className='cvc' type='password' placeholder='123' valueLink={this.linkState('cvc')} /> |
| paddy@2 | 189 |
| paddy@2 | 190 <label htmlFor='expireMonth' className='expiration'>Expires</label> |
| paddy@2 | 191 <input id='expireMonth' className='expiration month' type='text' placeholder='01' valueLink={this.linkState('expireMonth')} /> |
| paddy@2 | 192 <input id='expireYear' className='expiration year' type='text' placeholder='15' valueLink={this.linkState('expireYear')} /> |
| paddy@2 | 193 <ValidationError errors={this.state.errors} field='/cvc' outputs={this.cvcValidationOutputs} /> |
| paddy@2 | 194 <ValidationError errors={this.state.errors} field='/expiration' outputs={this.expirationValidationOutputs} /> |
| paddy@9 | 195 |
| paddy@9 | 196 <label htmlFor='plan'>Plan</label> |
| paddy@9 | 197 <select id='plan' className='plan' defaultValue={this.getDefaultPlan()}> |
| paddy@9 | 198 <option value='basic_monthly'>Basic Monthly - $2/month</option> |
| paddy@9 | 199 <option value='basic_yearly'>Basic Yearly - $20/year</option> |
| paddy@9 | 200 <option value='supporter_monthly'>Supporter Monthly - $5/month</option> |
| paddy@9 | 201 <option value='supporter_yearly'>Supporter Yearly - $50/year</option> |
| paddy@9 | 202 </select> |
| paddy@9 | 203 <ValidationError errors={this.state.errors} field='/plan' outputs={this.planValidationOutputs} /> |
| paddy@9 | 204 <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 | 205 |
| paddy@8 | 206 <ValidationError errors={this.state.errors} field='/balance' outputs={this.balanceValidationOutputs} /> |
| paddy@22 | 207 <ValidationError errors={this.state.errors} field='/user_id' outputs={this.userValidationOutputs} /> |
| paddy@22 | 208 <ValidationError errors={this.state.errors} notFields={['/name', '/number', '/cvc', '/expiration', '/plan', '/balance', '/user_id']} notParams={[]} notHeaders={[]} outputs={this.catchAllValidationOutputs} /> |
| paddy@2 | 209 |
| paddy@2 | 210 <div className='actionbuttons'> |
| paddy@2 | 211 <LaddaButton style='expand-right' active={this.state.active}> |
| paddy@22 | 212 <button type='submit' className='primary' disabled={this.state.stripeLoading || this.state.stripeFailedToLoad || this.state.active }>Add Card</button> |
| paddy@2 | 213 </LaddaButton> |
| paddy@2 | 214 </div> |
| paddy@2 | 215 </div> |
| paddy@2 | 216 </form> |
| paddy@2 | 217 </article> |
| paddy@2 | 218 </div> |
| paddy@2 | 219 ) |
| paddy@2 | 220 } |
| paddy@2 | 221 }) |