ducky/web

Paddy 2015-05-29 Parent:2cd4e16a669e Child:7ae5dd64c482

2:b9d0efb44eaa Browse Files

Validate registration, add payment page. Add lodash.debounce to debounce our validation. Update react-ladda to take advantage of the bugfix that will make it disable the button properly. Use react-script-loader to load stripe.js for our payment page async. No, I'm not terrified that I'm planning on shipping the mission-critical version of my software (the part where I get _paid_) using v0.0.1 software. Should I be? Add jwt-decode so we can decode the access tokens we get in response. We can't _verify_ them, but since we're not a source of truth, that really doesn't make much a difference. Everything is considered to be tamperable, anyways, so we never rely on the client being authoritative or safe. Add a `npm run deploy` task that will deploy the damn project to surge. Create a ValidationError component that will look through a set of errors for the ones that apply to it, and display the appropriate message for them. Update our config.js with a new (bullshit) clientID and clientSecret. It would help if we used an actual database to store these instead of storing them in memory, so I didn't have to generate them fresh every time. But then I'd have to delete accounts, too. Where's the fun in that? We also added a stripeKey which is the publishable key for our test account. Suck it, scraper bots. Use jwt-decode to decode the access token we receive into a claim we can pull the ProfileID out of. The Me model gains a profileID attribute to store this new information, so we can tell who the eff is signed in. We also used the ever wonderful derived properties feature to make a me.profile, which will always resolve to the profile of the signed in user, as it exists (stored locally). We should probably do some fallback checking to make it fetch the profile from the server, huh? I'll open an issue. Create our payment page. this is scary similar to our registration page, except we're loading stripe.js async (and disabling the buttons until it's loaded), and we're asking for the credit card details, instead of their email and password. We then trade the credit card details for a token (hooray, no need to deal with PCI compliance!) and we'll eventually send that token to our server, to create a customer. Taking money is complicated. Hilariously, we have the code to turn all the Stripe server errors into errors our ValidationError component can handle, and we have the ValidationError components placed, but we haven't defined the error messages for them yet. Oops. We also haven't done any kind of local validation. Oopsie. We used our ValidationError to set up errors for our register page, and also used the debounced validator to check the information while we type. What's not to love? Haven't quite gotten around to handling server errors yet. It's on the to-do list. Also, how hilarious is it that we need three freaking methods to debounce our validation properly? Once we register a profile, we should continue on to the payment method part of the program, so we tossed that router.navigate call in there. Finally, we updated our IDs, because reasons and typing. We also included a new _flashes.scss page that will take care of our error display, and added payment page styles to our onboarding.scss file. Finally, we updated our webpack config to output all the files to build/static so we can use build/ as our deploy folder, with an index.html in the root of it.

package.json src/components/validation-error.jsx src/config.js src/models/me.js src/pages/payment.jsx src/pages/register.jsx src/router.jsx src/styles/_flashes.scss src/styles/onboarding.scss webpack.config.js

     1.1 --- a/package.json	Sun May 03 23:51:47 2015 -0400
     1.2 +++ b/package.json	Fri May 29 00:30:16 2015 -0400
     1.3 @@ -16,8 +16,10 @@
     1.4      "extract-text-webpack-plugin": "^0.7.0",
     1.5      "file-loader": "^0.8.1",
     1.6      "find-root": "^0.1.1",
     1.7 +    "jwt-decode": "^1.1.0",
     1.8      "ladda": "^0.9.8",
     1.9      "local-links": "^1.4.0",
    1.10 +    "lodash.debounce": "^3.0.3",
    1.11      "lodash.isobject": "^3.0.1",
    1.12      "minimist": "^1.1.1",
    1.13      "node-bourbon": "^4.2.1-beta1",
    1.14 @@ -26,7 +28,8 @@
    1.15      "normalize.css": "^3.0.3",
    1.16      "qs": "^2.4.1",
    1.17      "react": "^0.13.2",
    1.18 -    "react-ladda": "^2.0.2",
    1.19 +    "react-ladda": "^2.0.4",
    1.20 +    "react-script-loader": "0.0.1",
    1.21      "sass-loader": "0.4.2",
    1.22      "style-loader": "^0.12.0",
    1.23      "url-loader": "^0.5.5",
    1.24 @@ -38,6 +41,7 @@
    1.25    },
    1.26    "scripts": {
    1.27      "build": "NODE_ENV=production webpack",
    1.28 -    "start": "bin/dev-server"
    1.29 +    "start": "bin/dev-server",
    1.30 +    "deploy": "surge ./build/"
    1.31    }
    1.32  }
     2.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     2.2 +++ b/src/components/validation-error.jsx	Fri May 29 00:30:16 2015 -0400
     2.3 @@ -0,0 +1,36 @@
     2.4 +import React from 'react'
     2.5 +
     2.6 +export default React.createClass({
     2.7 +  displayName: 'ValidationError',
     2.8 +
     2.9 +  render () {
    2.10 +    const field = this.props.field
    2.11 +    const param = this.props.param
    2.12 +    const header = this.props.header
    2.13 +    const outputs = this.props.outputs
    2.14 +    const errors = this.props.errors
    2.15 +    return (
    2.16 +      <div className={errors.length ? '' : 'hidden' }>
    2.17 +        {errors.map(error => {
    2.18 +          let errorString = ''
    2.19 +          if (field && error.field && error.field != field) {
    2.20 +            return ''
    2.21 +          }
    2.22 +          if (param && error.param && error.param != param) {
    2.23 +            return ''
    2.24 +          }
    2.25 +          if (header && error.header && error.header != header) {
    2.26 +            return ''
    2.27 +          }
    2.28 +          if (outputs[error.error] == undefined) {
    2.29 +            errorString = 'An unknown error occurred. Please contact support. Sorry.'
    2.30 +          } else {
    2.31 +            errorString = outputs[error.error]
    2.32 +          }
    2.33 +          const id = [error.field, error.param, error.header, error.error].join("|")
    2.34 +          return <div key={id} className="flash-error validation"><span>{errorString}</span></div>
    2.35 +        })}
    2.36 +      </div>
    2.37 +    )
    2.38 +  }
    2.39 +})
     3.1 --- a/src/config.js	Sun May 03 23:51:47 2015 -0400
     3.2 +++ b/src/config.js	Fri May 29 00:30:16 2015 -0400
     3.3 @@ -1,5 +1,6 @@
     3.4  export default {
     3.5    'urlBase': 'http://slightly.local:8080',
     3.6 -  'clientID': '881eeaaf-42d8-4212-a727-b33f23d5c526',
     3.7 -  'clientSecret': '2df53f4b9e4cb588821f6a3a8c65990b6416fc568b7836199594ebfbf7d1c0ca',
     3.8 +  'clientID': '7c9e3391-c924-4d67-956a-20897740550d',
     3.9 +  'clientSecret': 'df5bc28892e93701405b394e87dc5ada584e8857666f43247e2f5fb23cd5e626',
    3.10 +  'stripeKey': 'pk_test_w2jN5rCKL9t9CcMPta2SsX7J',
    3.11  }
     4.1 --- a/src/models/me.js	Sun May 03 23:51:47 2015 -0400
     4.2 +++ b/src/models/me.js	Fri May 29 00:30:16 2015 -0400
     4.3 @@ -3,6 +3,7 @@
     4.4  import qs from 'qs'
     4.5  import config from '../config'
     4.6  import isObject from 'lodash.isobject'
     4.7 +import jwtDecode from 'jwt-decode'
     4.8  
     4.9  export default Model.extend({
    4.10    url: config.urlBase + '/token',
    4.11 @@ -19,6 +20,7 @@
    4.12      expires_in: 'int',
    4.13      token_created: 'date',
    4.14      name: 'string',
    4.15 +    profileID: 'string',
    4.16    },
    4.17  
    4.18    derived: {
    4.19 @@ -29,6 +31,12 @@
    4.20        let d = this.token_created
    4.21        return !!this.refresh_token && (new Date() >= d.setSeconds(d.getSeconds() + this.expires_in - 900))
    4.22      },
    4.23 +    profile: {
    4.24 +      deps: ['profileID'],
    4.25 +      fn () {
    4.26 +        return app.profiles.get(this.profileID)
    4.27 +      },
    4.28 +    },
    4.29    },
    4.30  
    4.31    login (email, password) {
    4.32 @@ -51,6 +59,8 @@
    4.33        if (isObject(serverAttrs) && !moc.set(serverAttrs, options)) {
    4.34          return false
    4.35        }
    4.36 +      const token = jwtDecode(moc.access_token)
    4.37 +      moc.profileID = token.sub
    4.38        moc.trigger('sync', moc, resp, options)
    4.39      }
    4.40      options.error = function(resp) {
     5.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     5.2 +++ b/src/pages/payment.jsx	Fri May 29 00:30:16 2015 -0400
     5.3 @@ -0,0 +1,172 @@
     5.4 +import app from 'ampersand-app'
     5.5 +import React from 'react'
     5.6 +import ScriptLoaderMixin from 'react-script-loader'
     5.7 +import LaddaButton from 'react-ladda'
     5.8 +import LaddaCSS from '../../node_modules/ladda/dist/ladda.min.css'
     5.9 +import HeroUnit from '../components/hero'
    5.10 +import ValidationError from '../components/validation-error'
    5.11 +import onboardStyles from '../styles/onboarding.scss'
    5.12 +import config from '../config'
    5.13 +
    5.14 +export default React.createClass({
    5.15 +  displayName: 'PaymentMethodPage',
    5.16 +  mixins: [ScriptLoaderMixin.ReactScriptLoaderMixin, React.addons.LinkedStateMixin],
    5.17 +  getScriptURL () {
    5.18 +    return 'https://js.stripe.com/v2/'
    5.19 +  },
    5.20 +
    5.21 +  getInitialState () {
    5.22 +    return {
    5.23 +      stripeLoading: true,
    5.24 +      stripeFailedToLoad: false,
    5.25 +      active: false,
    5.26 +      errors: [],
    5.27 +      number: null,
    5.28 +      name: null,
    5.29 +      cvc: null,
    5.30 +      expireMonth: null,
    5.31 +      expireYear: null,
    5.32 +    }
    5.33 +  },
    5.34 +
    5.35 +  nameValidationOutputs: {},
    5.36 +  numberValidationOutputs: {},
    5.37 +  cvcValidationOutputs: {},
    5.38 +  expirationValidationOutputs: {},
    5.39 +
    5.40 +  onScriptLoaded () {
    5.41 +    Stripe.setPublishableKey(config.stripeKey)
    5.42 +    this.setState({stripeLoading: false})
    5.43 +  },
    5.44 +
    5.45 +  onScriptError () {
    5.46 +    this.setState({stripeFailedToLoad: true})
    5.47 +  },
    5.48 +
    5.49 +  getChargeDate () {
    5.50 +    let created = new Date()
    5.51 +    if (app.me && app.me.profile && app.me.profile.created && app.me.profile.created < created) {
    5.52 +      console.log("using register date...")
    5.53 +      created = app.me.profile.created
    5.54 +    }
    5.55 +    let month = created.getMonth()
    5.56 +    let day = created.getDate()
    5.57 +    month = month + 1
    5.58 +    if (day > 1) {
    5.59 +      day = 1
    5.60 +      month = month + 1
    5.61 +    }
    5.62 +    let result = new Date(created.toString())
    5.63 +    result.setDate(day)
    5.64 +    result.setMonth(month)
    5.65 +    return result
    5.66 +  },
    5.67 +
    5.68 +  addCard (e) {
    5.69 +    e.preventDefault()
    5.70 +    if (this.state.stripeLoading || this.state.stripeFailedToLoad) {
    5.71 +      return
    5.72 +    }
    5.73 +    this.setState({active: true})
    5.74 +    const t = this
    5.75 +    const errors = []
    5.76 +    Stripe.card.createToken({
    5.77 +      number: this.state.number,
    5.78 +      cvc: this.state.cvc,
    5.79 +      exp_month: this.state.expireMonth,
    5.80 +      exp_year: this.state.expireYear,
    5.81 +      name: this.state.name,
    5.82 +    }, function(status, response) {
    5.83 +      console.log(status)
    5.84 +      console.log(response)
    5.85 +      if (response.error) {
    5.86 +        if (response.error.type == 'card_error') {
    5.87 +          switch (response.error.code) {
    5.88 +            case 'incorrect_number':
    5.89 +              errors.push({'error': 'invalid_value', 'field': '/number'})
    5.90 +              break
    5.91 +            case 'invalid_number':
    5.92 +              errors.push({'error': 'invalid_format', 'field': '/number'})
    5.93 +              break
    5.94 +            case 'invalid_expiry_month':
    5.95 +              errors.push({'error': 'invalid_format', 'field': '/expireMonth'})
    5.96 +              break
    5.97 +            case 'invalid_expiry_year':
    5.98 +              errors.push({'error': 'invalid_format', 'field': '/expireYear'})
    5.99 +              break
   5.100 +            case 'invalid_cvc':
   5.101 +              errors.push({'error': 'invalid_format', 'field': '/cvc'})
   5.102 +              break
   5.103 +            case 'expired_card':
   5.104 +              errors.push({'error': 'invalid_value', 'field': '/expiration'})
   5.105 +              break
   5.106 +            case 'incorrect_cvc':
   5.107 +              errors.push({'error': 'invalid_value', 'field': '/cvc'})
   5.108 +              break
   5.109 +            case 'incorrect_zip':
   5.110 +              errors.push({'error': 'invalid_value', 'field': '/zip'})
   5.111 +              break
   5.112 +            case 'card_declined':
   5.113 +              errors.push({'error': 'insufficient', 'field': '/balance'})
   5.114 +              break
   5.115 +            case 'missing':
   5.116 +              errors.push({'error': 'missing', 'field': '/customer/card'})
   5.117 +              break
   5.118 +            case 'processing_error':
   5.119 +              errors.push({'error': 'act_of_god', 'field': '/'})
   5.120 +              break
   5.121 +            case 'rate_limit':
   5.122 +              errors.push({'error': 'access_denied', 'field': '/rate'})
   5.123 +              break
   5.124 +            default:
   5.125 +              errors.push({'error': 'act_of_god', 'field': '/'})
   5.126 +              break
   5.127 +          }
   5.128 +        } else {
   5.129 +          console.log('Error:', response.error.message)
   5.130 +        }
   5.131 +      } else {
   5.132 +        console.log('Sending '+response.id+' to server to create customer')
   5.133 +      }
   5.134 +      t.setState({active: false, errors: errors})
   5.135 +    })
   5.136 +  },
   5.137 +
   5.138 +  render () {
   5.139 +    return (
   5.140 +      <div className='container'>
   5.141 +        <HeroUnit title='Add a Payment Method'>Gotta keep our servers online and food on the table.</HeroUnit>
   5.142 +        <article className='onboarding payment'>
   5.143 +          <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>
   5.144 +          <form onSubmit={this.addCard}>
   5.145 +            <div>
   5.146 +              <label htmlFor='name'>Cardholder Name</label>
   5.147 +              <input id='name' type='text' placeholder='This is the name on your card' valueLink={this.linkState('name')} />
   5.148 +              <ValidationError errors={this.state.errors} field='/name' outputs={this.nameValidationOutputs} />
   5.149 +
   5.150 +              <label htmlFor='cardNumber'>Card Number</label>
   5.151 +              <input id='cardNumber' type='text' placeholder='4242 4242 4242 4242' valueLink={this.linkState('number')} />
   5.152 +              <ValidationError errors={this.state.errors} field='/number' outputs={this.numberValidationOutputs} />
   5.153 +
   5.154 +              <label htmlFor='cvc'>Security Code / CVC</label>
   5.155 +              <input id='cvc' className='cvc' type='password' placeholder='123' valueLink={this.linkState('cvc')} />
   5.156 +
   5.157 +              <label htmlFor='expireMonth' className='expiration'>Expires</label>
   5.158 +              <input id='expireMonth' className='expiration month' type='text' placeholder='01' valueLink={this.linkState('expireMonth')} />
   5.159 +              <input id='expireYear'  className='expiration year' type='text' placeholder='15' valueLink={this.linkState('expireYear')} />
   5.160 +              <ValidationError errors={this.state.errors} field='/cvc' outputs={this.cvcValidationOutputs} />
   5.161 +              <ValidationError errors={this.state.errors} field='/expiration' outputs={this.expirationValidationOutputs} />
   5.162 +
   5.163 +              <div className='actionbuttons'>
   5.164 +                <button type='button' onClick={this.skip} className='ladda-button' disabled={this.state.active}>Not Now</button>
   5.165 +                <LaddaButton style='expand-right' active={this.state.active}>
   5.166 +                  <button type='submit' className='primary' disabled={this.state.stripeLoading || this.state.stripeFailedToLoad || this.state.active || this.state.errors.length}>Add Card</button>
   5.167 +                </LaddaButton>
   5.168 +              </div>
   5.169 +            </div>
   5.170 +          </form>
   5.171 +        </article>
   5.172 +      </div>
   5.173 +    )
   5.174 +  }
   5.175 +})
     6.1 --- a/src/pages/register.jsx	Sun May 03 23:51:47 2015 -0400
     6.2 +++ b/src/pages/register.jsx	Fri May 29 00:30:16 2015 -0400
     6.3 @@ -4,7 +4,10 @@
     6.4  import LaddaButton from 'react-ladda'
     6.5  import LaddaCSS from '../../node_modules/ladda/dist/ladda.min.css'
     6.6  import HeroUnit from '../components/hero'
     6.7 +import ValidationError from '../components/validation-error'
     6.8  import onboardingStyles from '../styles/onboarding.scss'
     6.9 +import flashStyles from '../styles/_flashes.scss'
    6.10 +import debounce from 'lodash.debounce'
    6.11  
    6.12  export default React.createClass({
    6.13    displayName: 'RegisterPage',
    6.14 @@ -17,10 +20,102 @@
    6.15        passphrase: null,
    6.16        passphraseConfirmation: null,
    6.17        active: false,
    6.18 -      valid: false,
    6.19 +      errors: [],
    6.20      }
    6.21    },
    6.22  
    6.23 +  emailValidationOutputs: {
    6.24 +    'missing': 'We need to know how to contact you. We promise not to share it.',
    6.25 +    'overflow': 'Hm, that’s a bit long. Do you have a shorter email address?',
    6.26 +    'invalid_format': 'That doesn’t look like an email address… Double check it?',
    6.27 +  },
    6.28 +
    6.29 +  emailConfirmationValidationOutputs: {
    6.30 +    'invalid_value': 'Oops! Those emails don’t match. Maybe double-check them?',
    6.31 +  },
    6.32 +
    6.33 +  passphraseValidationOutputs: {
    6.34 +    'insufficient': 'A longer passphrase would be better.',
    6.35 +    'missing': 'Looks like you forgot to enter a passphrase. You need one!',
    6.36 +    'overflow': 'We can’t store that long a passphrase. Try a shorter one.',
    6.37 +  },
    6.38 +
    6.39 +  passphraseConfirmationValidationOutputs: {
    6.40 +    'invalid_value': 'Oops! Those passphrases don’t match. Maybe double-check them?',
    6.41 +  },
    6.42 +
    6.43 +  debouncedValidateForm (event) {
    6.44 +    this._validateForm(event)
    6.45 +  },
    6.46 +
    6.47 +  _validateForm (event) {
    6.48 +    const fields = {
    6.49 +      email: this.state.email,
    6.50 +      emailConfirmation: this.state.emailConfirmation,
    6.51 +      passphrase: this.state.passphrase,
    6.52 +      passphraseConfirmation: this.state.passphraseConfirmation,
    6.53 +    }
    6.54 +    if (event != null) {
    6.55 +      if (event.target.id == 'emailInput') {
    6.56 +        fields.email = event.target.value
    6.57 +      }
    6.58 +      else if (event.target.id == 'emailConfirmationInput') {
    6.59 +        fields.emailConfirmation = event.target.value
    6.60 +      }
    6.61 +      else if (event.target.id == 'passphraseInput') {
    6.62 +        fields.passphrase = event.target.value
    6.63 +      }
    6.64 +      else if (event.target.id == 'passphraseConfirmationInput') {
    6.65 +        fields.passphraseConfirmation = event.target.value
    6.66 +      }
    6.67 +    }
    6.68 +    const errors = this.validate(fields, event == null)
    6.69 +    this.setState({errors: errors})
    6.70 +    return errors.length <= 0
    6.71 +  },
    6.72 +
    6.73 +  validateForm (event) {
    6.74 +    event.persist()
    6.75 +    this.debouncedValidateForm(event)
    6.76 +  },
    6.77 +
    6.78 +  validate (fields, all) {
    6.79 +    const errors = []
    6.80 +    if (fields.email != null) {
    6.81 +      if (fields.email.length == 0) {
    6.82 +        errors.push({'error': 'missing', 'field': '/email'})
    6.83 +      } else if (fields.email.length > 64) {
    6.84 +        errors.push({'error': 'overflow', 'field': '/email'})
    6.85 +      } else if (!fields.email.match(/.+@.+\..+/)) {
    6.86 +        errors.push({'error': 'invalid_format', 'field': '/email'})
    6.87 +      }
    6.88 +    } else if (all) {
    6.89 +      errors.push({'error': 'missing', 'field': '/email'})
    6.90 +    }
    6.91 +    if (fields.emailConfirmation != null || all) {
    6.92 +      if (fields.emailConfirmation != fields.email && (fields.email || fields.emailConfirmation)) {
    6.93 +        errors.push({'error': 'invalid_value', 'field': '/email_confirmation'})
    6.94 +      }
    6.95 +    }
    6.96 +    if (fields.passphrase != null) {
    6.97 +      if (fields.passphrase.length == 0) {
    6.98 +        errors.push({'error': 'missing', 'field': '/passphrase'})
    6.99 +      } else if (fields.passphrase.length < 6) {
   6.100 +        errors.push({'error': 'insufficient', 'field': '/passphrase'})
   6.101 +      } else if (fields.passphrase.length > 64) {
   6.102 +        errors.push({'error': 'overflow', 'field': '/passphrase'})
   6.103 +      }
   6.104 +    } else if (all) {
   6.105 +      errors.push({'error': 'missing', 'field': '/passphrase'})
   6.106 +    }
   6.107 +    if (fields.passphraseConfirmation != null || all) {
   6.108 +      if (fields.passphraseConfirmation != fields.passphrase && (fields.passphrase || fields.passphraseConfirmation)) {
   6.109 +        errors.push({'error': 'invalid_value', 'field': '/passphrase_confirmation'})
   6.110 +      }
   6.111 +    }
   6.112 +    return errors
   6.113 +  },
   6.114 +
   6.115    componentDidMount () {
   6.116      app.profiles.on('request', (moc, xhr, options) => {
   6.117        this.setState({active: true})
   6.118 @@ -33,12 +128,20 @@
   6.119      })
   6.120      app.me.on('sync', (moc, xhr, options) => {
   6.121        this.setState({active: false})
   6.122 -      console.log("logged in, continuing on to billing")
   6.123 +      app.router.navigate('/register/payment')
   6.124      })
   6.125    },
   6.126  
   6.127 +  componentWillMount () {
   6.128 +    this.debouncedValidateForm = debounce(this.debouncedValidateForm, 900)
   6.129 +  },
   6.130 +
   6.131    register (e) {
   6.132      e.preventDefault()
   6.133 +    const success = this._validateForm(null)
   6.134 +    if (!success) {
   6.135 +      return
   6.136 +    }
   6.137      app.profiles.register(this.state.email, this.state.passphrase)
   6.138    },
   6.139  
   6.140 @@ -54,22 +157,26 @@
   6.141          <article className='onboarding register'>
   6.142            <form onSubmit={this.register}>
   6.143              <div>
   6.144 -              <label htmlFor='emailRegisterInput'>Email</label>
   6.145 -              <input id='emailRegisterInput' type='email' placeholder='Ours is quack@useducky.com' valueLink={this.linkState('email')} disabled={this.state.active} />
   6.146 +              <label htmlFor='emailInput'>Email</label>
   6.147 +              <input id='emailInput' type='email' placeholder='Ours is quack@useducky.com' valueLink={this.linkState('email')} disabled={this.state.active} onInput={this.validateForm} />
   6.148 +              <ValidationError errors={this.state.errors} field='/email' outputs={this.emailValidationOutputs} />
   6.149  
   6.150 -              <label htmlFor='emailVerificationInput'>Verify Email</label>
   6.151 -              <input id='emailVerificationInput' type='email' placeholder='Typos are the absolute worst.' valueLink={this.linkState('emailConfirmation')} disabled={this.state.active} />
   6.152 +              <label htmlFor='emailConfirmationInput'>Verify Email</label>
   6.153 +              <input id='emailConfirmationInput' type='email' placeholder='Typos are the absolute worst.' valueLink={this.linkState('emailConfirmation')} disabled={this.state.active} onInput={this.validateForm} />
   6.154 +              <ValidationError errors={this.state.errors} field='/email_confirmation' outputs={this.emailConfirmationValidationOutputs} />
   6.155  
   6.156 -              <label htmlFor='passwordRegisterInput'>Passphrase</label>
   6.157 -              <input id='passwordRegisterInput' type='password' placeholder='We use a sentence. Try it!' valueLink={this.linkState('passphrase')} disabled={this.state.active} />
   6.158 +              <label htmlFor='passphraseInput'>Passphrase</label>
   6.159 +              <input id='passphraseInput' type='password' placeholder='We use a sentence. Try it!' valueLink={this.linkState('passphrase')} disabled={this.state.active} onInput={this.validateForm} />
   6.160 +              <ValidationError errors={this.state.errors} field='/passphrase' outputs={this.passphraseValidationOutputs} />
   6.161  
   6.162 -              <label htmlFor='passwordVerificationInput'>Verify Passphrase</label>
   6.163 -              <input id='passwordVerificationInput' type='password' placeholder='Just to make sure you know it.' valueLink={this.linkState('passphraseConfirmation')} disabled={this.state.active} />
   6.164 +              <label htmlFor='passphraseConfirmationInput'>Verify Passphrase</label>
   6.165 +              <input id='passphraseConfirmationInput' type='password' placeholder='Just to make sure you know it.' valueLink={this.linkState('passphraseConfirmation')} disabled={this.state.active} onInput={this.validateForm} />
   6.166 +              <ValidationError errors={this.state.errors} field='/passphrase_confirmation' outputs={this.passphraseConfirmationValidationOutputs} />
   6.167              </div>
   6.168              <div className='actionbuttons'>
   6.169                <button onClick={this.onBackClick} disabled={this.state.active} type='button' className='ladda-button'>Back</button>
   6.170                <LaddaButton style='expand-right' active={this.state.active}>
   6.171 -                <button type='submit' className='primary' disabled={this.state.active || !this.state.valid}>Register</button>
   6.172 +                <button type='submit' className='primary' disabled={this.state.active || this.state.errors.length}>Register</button>
   6.173                </LaddaButton>
   6.174              </div>
   6.175            </form>
     7.1 --- a/src/router.jsx	Sun May 03 23:51:47 2015 -0400
     7.2 +++ b/src/router.jsx	Fri May 29 00:30:16 2015 -0400
     7.3 @@ -3,12 +3,14 @@
     7.4  import MessagePage from './pages/message'
     7.5  import OnboardingPage from './pages/onboard'
     7.6  import RegisterPage from './pages/register'
     7.7 +import PaymentMethodPage from './pages/payment'
     7.8  import LoginPage from './pages/login'
     7.9  
    7.10  export default Router.extend({
    7.11    routes: {
    7.12      '': 'home',
    7.13      'register': 'register',
    7.14 +    'register/payment': 'payment',
    7.15      'login': 'login',
    7.16      'logout': 'logout',
    7.17      '*404': 'fourOhFour'
    7.18 @@ -22,6 +24,10 @@
    7.19      React.render(<RegisterPage/>, document.body)
    7.20    },
    7.21  
    7.22 +  payment () {
    7.23 +    React.render(<PaymentMethodPage/>, document.body)
    7.24 +  },
    7.25 +
    7.26    login () {
    7.27      React.render(<LoginPage/>, document.body)
    7.28    },
     8.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     8.2 +++ b/src/styles/_flashes.scss	Fri May 29 00:30:16 2015 -0400
     8.3 @@ -0,0 +1,42 @@
     8.4 +$base-spacing: 1.5em !default;
     8.5 +$alert-color: #fff6bf !default;
     8.6 +$error-color: #fbe3e4 !default;
     8.7 +$notice-color: #e5edf8 !default;
     8.8 +$success-color: #e6efc2 !default;
     8.9 +
    8.10 +@mixin flash($color) {
    8.11 +  background-color: $color;
    8.12 +  color: darken($color, 60%);
    8.13 +  display: block;
    8.14 +  font-weight: 500;
    8.15 +  margin-bottom: $base-spacing / 2;
    8.16 +  padding: $base-spacing / 2;
    8.17 +  text-align: center;
    8.18 +
    8.19 +  a {
    8.20 +    color: darken($color, 70%);
    8.21 +    text-decoration: underline;
    8.22 +
    8.23 +    &:focus,
    8.24 +    &:hover {
    8.25 +      color: darken($color, 90%);
    8.26 +    }
    8.27 +  }
    8.28 +}
    8.29 +
    8.30 +.flash-alert {
    8.31 +  @include flash($alert-color);
    8.32 +}
    8.33 +
    8.34 +.flash-error {
    8.35 +  @include flash($error-color);
    8.36 +}
    8.37 +
    8.38 +.flash-notice {
    8.39 +  @include flash($notice-color);
    8.40 +}
    8.41 +
    8.42 +.flash-success {
    8.43 +  @include flash($success-color);
    8.44 +}
    8.45 +
     9.1 --- a/src/styles/onboarding.scss	Sun May 03 23:51:47 2015 -0400
     9.2 +++ b/src/styles/onboarding.scss	Fri May 29 00:30:16 2015 -0400
     9.3 @@ -46,6 +46,44 @@
     9.4  			margin: 0px auto;
     9.5  		}
     9.6  	}
     9.7 +	&.payment {
     9.8 +		#{$all-text-inputs} {
     9.9 +			width: 60%;
    9.10 +		}
    9.11 +
    9.12 +		input.expiration {
    9.13 +			width: 10%;
    9.14 +		}
    9.15 +
    9.16 +		label.expiration {
    9.17 +			max-width: 20%;
    9.18 +			margin-left: 2%;
    9.19 +			margin-right: 2%;
    9.20 +		}
    9.21 +		
    9.22 +		input.month {
    9.23 +			margin-right: 1%;
    9.24 +		}
    9.25 +
    9.26 +		input.year {
    9.27 +			margin-left: 1%;
    9.28 +		}
    9.29 +		
    9.30 +		input.cvc {
    9.31 +			width: 13%;
    9.32 +		}
    9.33 +
    9.34 +		label {
    9.35 +			width: 35%;
    9.36 +			text-align: right;
    9.37 +			padding-right: 1em;
    9.38 +		}
    9.39 +
    9.40 +		form {
    9.41 +			font-size: 75%;
    9.42 +			margin: 0px auto;
    9.43 +		}
    9.44 +	}
    9.45  	&.login {
    9.46  		label {
    9.47  			width: 25%;
    10.1 --- a/webpack.config.js	Sun May 03 23:51:47 2015 -0400
    10.2 +++ b/webpack.config.js	Fri May 29 00:30:16 2015 -0400
    10.3 @@ -22,7 +22,7 @@
    10.4    var manifest = {
    10.5      entry: path.join(__dirname, 'src', 'main.js'),
    10.6      output: {
    10.7 -      path: path.join(__dirname, 'build'),
    10.8 +      path: path.join(__dirname, 'build', 'static'),
    10.9        publicPath: '/static/',
   10.10        filename: 'bundle.js'
   10.11      },