Merge branch 'feature/teacher-registration-flow' of https://github.com/rschamp/scratch-www into feature/teacher-registration-flow

* 'feature/teacher-registration-flow' of https://github.com/rschamp/scratch-www: (46 commits)
  Add serverside email validation
  Clean up
  Move registration step to main component
  Submit the rest of the fields when registering
  This help text is redundant now
  Fix organization "other" field enablement
  Get the form creating regular accounts
  Add method to api for submitting forms
  Update language chooser for formsy
  Add missing string
  Add progress indicators
  Style pass
  Remove unused label component
  Remove unused mixin factory
  Fixup api refactor
  Add serverside username validation
  L10n pass
  Explain the GeneralError component
  Add help text when a field is not required
  L10n WIP
  ...

# Conflicts:
#	src/l10n.json
#	src/views/splash/splash.jsx
This commit is contained in:
Matthew Taylor 2016-06-17 15:32:49 -04:00
commit 9d6fb63d18
51 changed files with 1641 additions and 208 deletions

View file

@ -47,6 +47,8 @@ env:
- SENTRY_DSN_VAR=SENTRY_DSN_$TRAVIS_BRANCH
- SENTRY_DSN=${!SENTRY_DSN_VAR}
- SENTRY_DSN=${SENTRY_DSN:-$SENTRY_DSN_STAGING}
# SMARTY_STREETS_API_KEY
- secure: "uQKNgJaJEju8ErGUxIbLE0Y6ee4j6OFFbBpqyuKrNMk6apvvvXLp3lTdGZJq6j/ZwQeQ384m5bbfmhFwr7piPFj7W/zBXVKcifbF6ShfP7skMl834Kkefs3uEWU0VZw3nURgzNInSOPqqGLsISFywpwBXUWKfL0Q87KHNU0/I0EkwvImm3SAlNpR38m3yKcMF3h0zK8Fh2mO7iyHEIhtssdWabaRjf3t6Mr5vikACeXYJg+k4oEQZtsnSNnlLYWumdEDsxwonMozGKUBqlXwhHCdYNOJ1DUGuntbXOnylLt1/LA9I9B4hWQOrRDwqjyIOI+2dpADoCN040+Zr1VSrJhk7Wb7ogeaQLzZ4W/3dX54rbsnFHa+MuKqOsAxQ0Tjfk5xWq/pbLRsAyW6Pl7Q1v4yWOQ2COnM/tfJ6UaH9bxppOyKsX8n33rFjlvZU6CtY1GGa7fpB2zOKI5B5OovLjHeokIe/Tx+4coEDZqt44qkTGWr/eWDxrvkQqpQ29F9My3wBgB3gdou+3lWExS0a9M2wwp4EIduXEKNZXLGDuVefH5f3eFy09wH+nhctmMF8uhMbPefFubEi7fqXTkxntmDTy+/pD2A2w1jJhBwLhwlik335k+Wrbl3dclt7cjJ6fRVX9b+AuBCbGr633vM4xbk90whwXizSECIt5InGSw="
- SKIP_CLEANUP=true
- NODE_ENV=production
- WWW_VERSION=${TRAVIS_COMMIT:0:5}

View file

@ -43,8 +43,12 @@
"exenv": "1.2.0",
"fastly": "1.2.1",
"file-loader": "0.8.4",
"formsy-react": "0.18.0",
"formsy-react-components": "0.7.1",
"git-bundle-sha": "0.0.2",
"glob": "5.0.15",
"google-libphonenumber": "1.0.21",
"iso-3166-2": "0.4.0",
"json-loader": "0.5.2",
"json2po-stream": "1.0.3",
"jsx-loader": "0.13.2",
@ -67,6 +71,7 @@
"react-onclickoutside": "4.1.1",
"react-redux": "4.4.5",
"react-slick": "0.12.2",
"react-telephone-input": "3.4.5",
"redux": "3.5.2",
"redux-thunk": "2.0.1",
"sass-lint": "1.5.1",

View file

@ -34,6 +34,10 @@ $cols10: (10 * ($column + $gutter) - $gutter) / $em;
$cols11: (11 * ($column + $gutter) - $gutter) / $em;
$cols12: (12 * ($column + $gutter) - $gutter) / $em;
$desktop: 942px;
$tablet: 640px;
$mobile: 480px;
//
// Column-widths in a function, in ems
//
@ -42,50 +46,67 @@ $cols12: (12 * ($column + $gutter) - $gutter) / $em;
width: ($cols * ($column + $gutter) - $gutter) / $em;
}
$desktop: 942px;
$tablet: 640px;
$mobile: 480px;
//4 columns
@media only screen and (max-width: $mobile - 1) {
#view {
text-align: center;
}
@mixin submobile ($parent-selector, $child-selector) {
@media only screen and (max-width: $mobile - 1) {
#{$parent-selector} {
text-align: center;
}
.inner {
margin: 0 auto;
width: 100%;
#{$child-selector} {
margin: 0 auto;
width: 100%;
}
@content;
}
}
//6 columns
@media only screen and (min-width: $mobile) and (max-width: $tablet - 1) {
#view {
text-align: center;
}
@mixin mobile ($parent-selector, $child-selector) {
@media only screen and (min-width: $mobile) and (max-width: $tablet - 1) {
#{$parent-selector} {
text-align: center;
}
.inner {
margin: 0 auto;
width: $mobile;
#{$child-selector} {
margin: 0 auto;
width: $mobile;
}
@content;
}
}
//8 columns
@media only screen and (min-width: $tablet) and (max-width: $desktop - 1) {
#view {
text-align: center;
}
@mixin tablet ($parent-selector, $child-selector) {
@media only screen and (min-width: $tablet) and (max-width: $desktop - 1) {
#{$parent-selector} {
text-align: center;
}
.inner {
margin: 0 auto;
width: $tablet;
#{$child-selector} {
margin: 0 auto;
width: $tablet;
}
}
}
//12 columns
@media only screen and (min-width: $desktop) {
.inner {
margin: 0 auto;
width: $desktop;
@mixin desktop ($parent-selector, $child-selector) {
@media only screen and (min-width: $desktop) {
#{$child-selector} {
margin: 0 auto;
width: $desktop;
}
}
}
@mixin responsive-layout ($parent-selector, $child-selector) {
@include submobile($parent-selector, $child-selector);
@include mobile($parent-selector, $child-selector);
@include tablet($parent-selector, $child-selector);
@include desktop($parent-selector, $child-selector);
}
@include responsive-layout("#view", ".inner");

View file

@ -0,0 +1,17 @@
var classNames = require('classnames');
var React = require('react');
require('./card.scss');
var Card = React.createClass({
displayName: 'Card',
render: function () {
return (
<div className={classNames(['card', this.props.className])}>
{this.props.children}
</div>
);
}
});
module.exports = Card;

View file

@ -0,0 +1,7 @@
@import "../../colors";
@import "../../frameless";
.card {
border-radius: 8px / $em;
background-color: $ui-white;
}

View file

@ -0,0 +1,18 @@
var classNames = require('classnames');
var React = require('react');
require('./deck.scss');
var Deck = React.createClass({
displayName: 'Deck',
render: function () {
return (
<div className={classNames(['deck', this.props.className])}>
<img src="/images/logo_sm.png" />
{this.props.children}
</div>
);
}
});
module.exports = Deck;

View file

@ -0,0 +1,7 @@
@import "../../frameless";
@include responsive-layout (".deck", ".slide");
.deck {
min-height: 100vh;
}

View file

@ -0,0 +1,22 @@
var classNames = require('classnames');
var FRCCheckboxGroup = require('formsy-react-components').CheckboxGroup;
var React = require('react');
var defaultValidationHOC = require('./validations.jsx').defaultValidationHOC;
var inputHOC = require('./input-hoc.jsx');
require('./row.scss');
var CheckboxGroup = React.createClass({
type: 'CheckboxGroup',
render: function () {
var classes = classNames(
'checkbox-group',
this.props.className
);
return (
<FRCCheckboxGroup {... this.props} className={classes} />
);
}
});
module.exports = inputHOC(defaultValidationHOC(CheckboxGroup));

View file

@ -0,0 +1,22 @@
var classNames = require('classnames');
var FRCCheckbox = require('formsy-react-components').Checkbox;
var React = require('react');
var defaultValidationHOC = require('./validations.jsx').defaultValidationHOC;
var inputHOC = require('./input-hoc.jsx');
require('./row.scss');
var Checkbox = React.createClass({
type: 'Checkbox',
render: function () {
var classes = classNames(
'checkbox-row',
this.props.className
);
return (
<FRCCheckbox {... this.props} rowClassName={classes} />
);
}
});
module.exports = inputHOC(defaultValidationHOC(Checkbox));

View file

@ -0,0 +1,31 @@
var classNames = require('classnames');
var Formsy = require('formsy-react');
var React = require('react');
var GeneralError = require('./general-error.jsx');
var validations = require('./validations.jsx').validations;
for (var validation in validations) {
Formsy.addValidationRule(validation, validations[validation]);
}
var Form = React.createClass({
getDefaultProps: function () {
return {
noValidate: true
};
},
render: function () {
var classes = classNames(
'form',
this.props.className
);
return (
<Formsy.Form {... this.props} className={classes}>
<GeneralError name="all" />
{this.props.children}
</Formsy.Form>
);
}
});
module.exports = Form;

View file

@ -0,0 +1,20 @@
var Formsy = require('formsy-react');
var React = require('react');
/*
* A special formsy-react component that only outputs
* error messages. If you want to display errors that
* don't apply to a specific field, insert one of these,
* give it a name, and apply your validation error to
* the name of the GeneralError component.
*/
module.exports = Formsy.HOC(React.createClass({
render: function () {
if (!this.props.showError()) return null;
return (
<p className="error">
{this.props.getErrorMessage()}
</p>
);
}
}));

View file

@ -0,0 +1,20 @@
var React = require('react');
module.exports = function InputComponentMixin (Component) {
var InputComponent = React.createClass({
getDefaultProps: function () {
return {
messages: {
'general.notRequired': 'Not Required'
}
};
},
render: function () {
return (
<Component help={this.props.required ? null : this.props.messages['general.notRequired']}
{...this.props} />
);
}
});
return InputComponent;
};

View file

@ -1,22 +1,27 @@
var React = require('react');
var classNames = require('classnames');
var FRCInput = require('formsy-react-components').Input;
var React = require('react');
var defaultValidationHOC = require('./validations.jsx').defaultValidationHOC;
var inputHOC = require('./input-hoc.jsx');
require('./input.scss');
require('./row.scss');
var Input = React.createClass({
type: 'Input',
propTypes: {
getDefaultProps: function () {
return {};
},
render: function () {
var classes = classNames(
'input',
this.props.className
);
return (
<input {... this.props} className={classes} />
return (this.props.type === 'submit' || this.props.noformsy ?
<input {... this.props} className={classes} /> :
<FRCInput {... this.props} className={classes} />
);
}
});
module.exports = Input;
module.exports = inputHOC(defaultValidationHOC(Input));

View file

@ -0,0 +1,67 @@
var classNames = require('classnames');
var React = require('react');
var FormsyMixin = require('formsy-react').Mixin;
var ReactPhoneInput = require('react-telephone-input/lib/withStyles');
var allCountries = require('react-telephone-input/lib/country_data').allCountries;
var defaultValidationHOC = require('./validations.jsx').defaultValidationHOC;
var validationHOCFactory = require('./validations.jsx').validationHOCFactory;
var Row = require('formsy-react-components').Row;
var ComponentMixin = require('formsy-react-components').ComponentMixin;
var inputHOC = require('./input-hoc.jsx');
var allIso2 = allCountries.map(function (country) {return country.iso2;});
require('./row.scss');
var PhoneInput = React.createClass({
displayName: 'PhoneInput',
mixins: [
FormsyMixin,
ComponentMixin
],
getDefaultProps: function () {
return {
validations: {
isPhone: true
},
flagsImagePath: '/images/flags.png',
defaultCountry: 'us'
};
},
onChangeInput: function (number, country) {
var value = {national_number: number, country_code: country};
this.setValue(value);
this.props.onChange(this.props.name, value);
},
render: function () {
var defaultCountry = PhoneInput.getDefaultProps().defaultCountry;
if (allIso2.indexOf(this.props.defaultCountry.toLowerCase()) !== -1) {
defaultCountry = this.props.defaultCountry.toLowerCase();
}
return (
<Row {... this.getRowProperties()}
htmlFor={this.getId()}
className={classNames('phone-input', this.props.className)}
>
<div className="input-group">
<ReactPhoneInput className="form-control"
{... this.props}
defaultCountry={defaultCountry}
onChange={this.onChangeInput}
id={this.getId()}
label={null}
disabled={this.isFormDisabled() || this.props.disabled}
/>
</div>
{this.renderHelp()}
{this.renderErrorMessage()}
</Row>
);
}
});
var phoneValidationHOC = validationHOCFactory({
isPhone: 'Please enter a valid phone number'
});
module.exports = inputHOC(defaultValidationHOC(phoneValidationHOC(PhoneInput)));

View file

@ -0,0 +1,22 @@
var classNames = require('classnames');
var FRCRadioGroup = require('formsy-react-components').RadioGroup;
var React = require('react');
var defaultValidationHOC = require('./validations.jsx').defaultValidationHOC;
var inputHOC = require('./input-hoc.jsx');
require('./row.scss');
var RadioGroup = React.createClass({
type: 'RadioGroup',
render: function () {
var classes = classNames(
'radio-group',
this.props.className
);
return (
<FRCRadioGroup {... this.props} className={classes} />
);
}
});
module.exports = inputHOC(defaultValidationHOC(RadioGroup));

View file

@ -0,0 +1,11 @@
/*
* Styles for the Row component used by formsy-react-components
* Should be imported for each component that extends one of
* the formsy-react-components
*/
.form-group {
.required-symbol {
display: none;
}
}

View file

@ -1,6 +1,11 @@
var React = require('react');
var classNames = require('classnames');
var defaults = require('lodash.defaultsdeep');
var FRCSelect = require('formsy-react-components').Select;
var React = require('react');
var defaultValidationHOC = require('./validations.jsx').defaultValidationHOC;
var inputHOC = require('./input-hoc.jsx');
require('./row.scss');
require('./select.scss');
var Select = React.createClass({
@ -13,12 +18,14 @@ var Select = React.createClass({
'select',
this.props.className
);
var props = this.props;
if (this.props.required && !this.props.value) {
props = defaults({}, this.props, {value: this.props.options[0].value});
}
return (
<select {... this.props} className={classes}>
{this.props.children}
</select>
<FRCSelect {... props} className={classes} />
);
}
});
module.exports = Select;
module.exports = inputHOC(defaultValidationHOC(Select));

View file

@ -0,0 +1,23 @@
var classNames = require('classnames');
var FRCTextarea = require('formsy-react-components').Textarea;
var React = require('react');
var defaultValidationHOC = require('./validations.jsx').defaultValidationHOC;
var inputHOC = require('./input-hoc.jsx');
require('./row.scss');
require('./textarea.scss');
var TextArea = React.createClass({
type: 'TextArea',
render: function () {
var classes = classNames(
'textarea',
this.props.className
);
return (
<FRCTextarea {... this.props} className={classes} />
);
}
});
module.exports = inputHOC(defaultValidationHOC(TextArea));

View file

@ -0,0 +1,34 @@
@import "../../colors";
$base-bg: $ui-white;
$focus-bg: lighten($ui-blue, 35%);
$fail-bg: lighten($ui-orange, 35%);
$pass-bg: lighten($ui-aqua, 35%);
.textarea {
transition: all 1s ease;
margin: .5em 0;
border: 1px solid $active-gray;
border-radius: 5px;
background-color: $base-bg;
padding: .75em 1em;
color: $type-gray;
font-size: .8rem;
&:focus {
transition: all 1s ease;
outline: none;
border: 1px solid $active-dark-gray;
background-color: $focus-bg;
}
&.fail {
border: 1px solid $active-dark-gray;
background-color: $fail-bg;
}
&.pass {
border: 1px solid $active-dark-gray;
background-color: $pass-bg;
}
}

View file

@ -0,0 +1,46 @@
var defaults = require('lodash.defaultsdeep');
var libphonenumber = require('google-libphonenumber');
var phoneNumberUtil = libphonenumber.PhoneNumberUtil.getInstance();
var React = require('react');
module.exports = {};
module.exports.validations = {
notEquals: function (values, value, neq) {
return value !== neq;
},
notEqualsField: function (values, value, field) {
return value !== values[field];
},
isPhone: function (values, value) {
if (typeof value === 'undefined') return true;
if (value && value.national_number === '+') return true;
try {
var parsed = phoneNumberUtil.parse(value.national_number, value.country_code.iso2);
} catch (err) {
return false;
}
return phoneNumberUtil.isValidNumber(parsed);
}
};
module.exports.validationHOCFactory = function (defaultValidationErrors) {
return function (Component) {
var ValidatedComponent = React.createClass({
render: function () {
var validationErrors = defaults(
defaultValidationErrors,
this.props.validationErrors
);
return (
<Component {...this.props} validationErrors={validationErrors} />
);
}
});
return ValidatedComponent;
};
};
module.exports.defaultValidationHOC = module.exports.validationHOCFactory({
isDefaultRequiredValue: 'This field is required'
});

View file

@ -1,9 +1,9 @@
var classNames = require('classnames');
var React = require('react');
var Api = require('../../mixins/api.jsx');
var jar = require('../../lib/jar.js');
var languages = require('../../../languages.json');
var Form = require('../forms/form.jsx');
var Select = require('../forms/select.jsx');
/**
@ -11,18 +11,14 @@ var Select = require('../forms/select.jsx');
*/
var LanguageChooser = React.createClass({
type: 'LanguageChooser',
mixins: [
Api
],
getDefaultProps: function () {
return {
languages: languages,
locale: window._locale
};
},
onSetLanguage: function (e) {
e.preventDefault();
jar.set('scratchlanguage', e.target.value);
onSetLanguage: function (name, value) {
jar.set('scratchlanguage', value);
window.location.reload();
},
render: function () {
@ -30,17 +26,17 @@ var LanguageChooser = React.createClass({
'language-chooser',
this.props.className
);
var languageOptions = Object.keys(this.props.languages).map(function (value) {
return {value: value, label: this.props.languages[value]};
}.bind(this));
return (
<div className={classes}>
<Select name="language" defaultValue={this.props.locale} onChange={this.onSetLanguage}>
{Object.keys(this.props.languages).map(function (value) {
return <option value={value} key={value}>
{this.props.languages[value]}
</option>;
}.bind(this))}
</Select>
</div>
<Form className={classes}>
<Select name="language"
options={languageOptions}
defaultValue={this.props.locale}
onChange={this.onSetLanguage}
required />
</Form>
);
}
});

View file

@ -1,9 +1,9 @@
var React = require('react');
var ReactDOM = require('react-dom');
var FormattedMessage = require('react-intl').FormattedMessage;
var log = require('../../lib/log.js');
var Form = require('../forms/form.jsx');
var Input = require('../forms/input.jsx');
var Button = require('../forms/button.jsx');
var Spinner = require('../spinner/spinner.jsx');
@ -21,13 +21,9 @@ var Login = React.createClass({
waiting: false
};
},
handleSubmit: function (event) {
event.preventDefault();
handleSubmit: function (formData) {
this.setState({waiting: true});
this.props.onLogIn({
'username': ReactDOM.findDOMNode(this.refs.username).value,
'password': ReactDOM.findDOMNode(this.refs.password).value
}, function (err) {
this.props.onLogIn(formData, function (err) {
if (err) log.error(err);
this.setState({waiting: false});
}.bind(this));
@ -39,19 +35,19 @@ var Login = React.createClass({
}
return (
<div className="login">
<form onSubmit={this.handleSubmit}>
<Form onSubmit={this.handleSubmit}>
<label htmlFor="username" key="usernameLabel">
<FormattedMessage
id='general.username'
defaultMessage={'Username'} />
</label>
<Input type="text" ref="username" name="username" maxLength="30" key="usernameInput" />
<Input type="text" ref="username" name="username" maxLength="30" key="usernameInput" required />
<label htmlFor="password" key="passwordLabel">
<FormattedMessage
id='general.password'
defaultMessage={'Password'} />
</label>
<Input type="password" ref="password" name="password" key="passwordInput" />
<Input type="password" ref="password" name="password" key="passwordInput" required />
{this.state.waiting ? [
<Button className="submit-button white" type="submit" disabled="disabled" key="submitButton">
<Spinner />
@ -69,7 +65,7 @@ var Login = React.createClass({
defaultMessage={'Forgot Password?'} />
</a>
{error}
</form>
</Form>
</div>
);
}

View file

@ -7,7 +7,7 @@ var injectIntl = ReactIntl.injectIntl;
var sessionActions = require('../../../redux/session.js');
var Api = require('../../../mixins/api.jsx');
var api = require('../../../lib/api');
var Avatar = require('../../avatar/avatar.jsx');
var Button = require('../../forms/button.jsx');
var Dropdown = require('../../dropdown/dropdown.jsx');
@ -24,9 +24,6 @@ Modal.setAppElement(document.getElementById('view'));
var Navigation = React.createClass({
type: 'Navigation',
mixins: [
Api
],
getInitialState: function () {
return {
accountNavOpen: false,
@ -85,7 +82,7 @@ var Navigation = React.createClass({
return '/users/' + this.props.session.session.user.username + '/';
},
getMessageCount: function () {
this.api({
api({
method: 'get',
uri: '/users/' + this.props.session.session.user.username + '/messages/count'
}, function (err, body) {
@ -110,7 +107,7 @@ var Navigation = React.createClass({
handleLogIn: function (formData, callback) {
this.setState({'loginError': null});
formData['useMessages'] = true;
this.api({
api({
method: 'post',
host: '',
uri: '/accounts/login/',
@ -142,7 +139,7 @@ var Navigation = React.createClass({
},
handleLogOut: function (e) {
e.preventDefault();
this.api({
api({
host: '',
method: 'post',
uri: '/accounts/logout/',
@ -220,7 +217,8 @@ var Navigation = React.createClass({
<Input type="text"
aria-label={formatMessage({id: 'general.search'})}
placeholder={formatMessage({id: 'general.search'})}
name="q" />
name="q"
noformsy />
</form>
</li>
{this.props.session.status === sessionActions.Status.FETCHED ? (

View file

@ -0,0 +1,42 @@
var classNames = require('classnames');
var React = require('react');
module.exports = React.createClass({
displayName: 'Progression',
propTypes: {
step: function (props, propName, componentName) {
var stepValidator = function (props, propName) {
if (props[propName] > -1 && props[propName] < props.children.length) {
return null;
} else {
return new Error('Prop `step` out of range');
}
};
return (
React.PropTypes.number.isRequired(props, propName, componentName) ||
stepValidator(props, propName, componentName)
);
}
},
getDefaultProps: function () {
return {
step: 0
};
},
render: function () {
var childProps = {
activeStep: this.props.step,
totalSteps: React.Children.count(this.props.children)
};
return (
<div {... this.props}
className={classNames('progression', this.props.className)}>
{React.Children.map(this.props.children, function (child, id) {
if (id === this.props.step) {
return React.cloneElement(child, childProps);
}
}, this)}
</div>
);
}
});

View file

@ -0,0 +1,17 @@
var classNames = require('classnames');
var React = require('react');
require('./slide.scss');
var Slide = React.createClass({
displayName: 'Slide',
render: function () {
return (
<div className={classNames(['slide', this.props.className])}>
{this.props.children}
</div>
);
}
});
module.exports = Slide;

View file

@ -0,0 +1,2 @@
.slide {
}

View file

@ -2,6 +2,7 @@
.spinner {
position: relative;
margin: 0 auto;
width: 20px;
height: 20px;
@ -17,10 +18,14 @@
animation: circleFadeDelay 1.2s infinite ease-in-out both;
margin: 0 auto;
border-radius: 100%;
background-color: darken($ui-blue, 8%);
background-color: darken($ui-white, 8%);
width: 15%;
height: 15%;
content: "";
.white & {
background-color: darken($ui-blue, 8%);
}
}
}

View file

@ -0,0 +1,28 @@
var classNames = require('classnames');
var React = require('react');
require('./stepnavigation.scss');
var StepNavigation = React.createClass({
type: 'Navigation',
render: function () {
return (
<ul className={classNames('step-navigation', this.props.className)}>
{Array.apply(null, Array(this.props.steps)).map(function (v, step) {
return (
<li key={step}
className={classNames({
active: step < this.props.active,
selected: step === this.props.active
})}
>
<div className="indicator" />
</li>
);
}.bind(this))}
</ul>
);
}
});
module.exports = StepNavigation;

View file

@ -0,0 +1,31 @@
@import "../../colors";
@import "../../frameless";
.step-navigation {
margin: 0;
padding: 0;
list-style: none;
li {
display: inline-block;
border-radius: 8px / $em;
padding: 4px / $em;
.indicator {
border-radius: 4px / $em;
background-color: $ui-white;
width: 8px / $em;
height: 8px / $em;
}
&.active {
.indicator {
background-color: $ui-aqua;
}
}
&.selected {
border: 1px solid $ui-white;
}
}
}

View file

@ -2,20 +2,27 @@
"general.accountSettings": "Account settings",
"general.about": "About",
"general.aboutScratch": "About Scratch",
"general.birthMonth": "Birth Month",
"general.birthYear": "Birth Year",
"general.donate": "Donate",
"general.collaborators": "Collaborators",
"general.community": "Community",
"general.confirmEmail": "Confirm Email",
"general.contactUs": "Contact Us",
"general.copyright": "Scratch is a project of the Lifelong Kindergarten Group at the MIT Media Lab",
"general.country": "Country",
"general.create": "Create",
"general.credits": "Credits",
"general.discuss": "Discuss",
"general.dmca": "DMCA",
"general.emailAddress": "Email Address",
"general.explore": "Explore",
"general.faq": "FAQ",
"general.female": "Female",
"general.forParents": "For Parents",
"general.forEducators": "For Educators",
"general.forDevelopers": "For Developers",
"general.gender": "Gender",
"general.guidelines": "Community Guidelines",
"general.help": "Help",
"general.jobs": "Jobs",
@ -23,11 +30,27 @@
"general.legal": "Legal",
"general.loadMore": "Load More",
"general.learnMore": "Learn More",
"general.male": "Male",
"general.messages": "Messages",
"general.monthJanuary": "January",
"general.monthFebruary": "February",
"general.monthMarch": "March",
"general.monthApril": "April",
"general.monthMay": "May",
"general.monthJune": "June",
"general.monthJuly": "July",
"general.monthAugust": "August",
"general.monthSeptember": "September",
"general.monthOctober": "October",
"general.monthNovember": "November",
"general.monthDecember": "December",
"general.myClass": "My Class",
"general.myClasses": "My Classes",
"general.myStuff": "My Stuff",
"general.notRequired": "Not Required",
"general.other": "Other",
"general.offlineEditor": "Offline Editor",
"general.password": "Password",
"general.press": "Press",
"general.privacyPolicy": "Privacy Policy",
"general.projects": "Projects",
@ -51,7 +74,13 @@
"general.tipsPongGame": "Create a Pong Game",
"general.termsOfUse": "Terms of Use",
"general.username": "Username",
"general.validationEmail": "Please enter a valid email address",
"general.validationEmailMatch": "The emails do not match",
"general.validationUsernameExists": "Sorry, that username already exists",
"general.validationUsernameVulgar": "Hmm, that looks inappropriate",
"general.validationUsernameInvalid": "Invalid username",
"general.viewAll": "View All",
"general.website": "Website",
"general.whatsHappening": "What's Happening?",
"general.wiki": "Scratch Wiki",

83
src/lib/api.js Normal file
View file

@ -0,0 +1,83 @@
var defaults = require('lodash.defaults');
var xhr = require('xhr');
var jar = require('./jar');
var log = require('./log');
var urlParams = require('./url-params');
/**
* Helper method that constructs requests to the scratch api.
* Custom arguments:
* - useCsrf [boolean] handles unique csrf token retrieval for POST requests. This prevents
* CSRF forgeries (see: https://www.squarefree.com/securitytips/web-developers.html#CSRF)
*
* It also takes in other arguments specified in the xhr library spec.
*/
module.exports = function (opts, callback) {
defaults(opts, {
host: process.env.API_HOST,
headers: {},
responseType: 'json',
useCsrf: false
});
defaults(opts.headers, {
'X-Requested-With': 'XMLHttpRequest'
});
opts.uri = opts.host + opts.uri;
if (opts.params) {
opts.uri = [opts.uri, urlParams(opts.params)]
.join(opts.uri.indexOf('?') === -1 ? '?' : '&');
}
if (opts.formData) {
opts.body = urlParams(opts.formData);
opts.headers['Content-Type'] = 'application/x-www-form-urlencoded';
}
var apiRequest = function (opts) {
if (opts.host !== '') {
// For IE < 10, we must use XDR for cross-domain requests. XDR does not support
// custom headers.
defaults(opts, {useXDR: true});
delete opts.headers;
if (opts.authentication) {
var authenticationParams = ['x-token=' + opts.authentication];
var parts = opts.uri.split('?');
var qs = (parts[1] || '').split('&').concat(authenticationParams).join('&');
opts.uri = parts[0] + '?' + qs;
}
}
xhr(opts, function (err, res, body) {
if (err) log.error(err);
// Legacy API responses come as lists, and indicate to redirect the client like
// [{success: true, redirect: "/location/to/redirect"}]
try {
if ('redirect' in body[0]) window.location = body[0].redirect;
} catch (err) {
// do nothing
}
callback(err, body, res);
});
}.bind(this);
if (typeof jar.get('scratchlanguage') !== 'undefined') {
opts.headers['Accept-Language'] = jar.get('scratchlanguage') + ', en;q=0.8';
}
if (opts.authentication) {
opts.headers['X-Token'] = opts.authentication;
}
if (opts.useCsrf) {
jar.use('scratchcsrftoken', '/csrf_token/', function (err, csrftoken) {
if (err) return log.error('Error while retrieving CSRF token', err);
opts.headers['X-CSRFToken'] = csrftoken;
apiRequest(opts);
}.bind(this));
} else {
apiRequest(opts);
}
};

22
src/lib/country-data.js Normal file
View file

@ -0,0 +1,22 @@
module.exports = {};
var countries = module.exports.data = require('iso-3166-2').data;
module.exports.countryOptions = Object.keys(countries).map(function (code) {
return {value: code.toLowerCase(), label: countries[code].name};
}).sort(function (a, b) {
return a.label < b.label ? -1 : 1;
});
module.exports.subdivisionOptions =
Object.keys(countries).reduce(function (subByCountry, code) {
subByCountry[code.toLowerCase()] = Object.keys(countries[code].sub).map(function (subCode) {
return {
value: subCode.toLowerCase(),
label: countries[code].sub[subCode].name,
type: countries[code].sub[subCode].type
};
}).sort(function (a, b) {
return a.label < b.label ? -1 : 1;
});
return subByCountry;
}, {});

21
src/lib/smarty-streets.js Normal file
View file

@ -0,0 +1,21 @@
var defaults = require('lodash.defaults');
var api = require('./api');
module.exports = function smartyStreetApi (params, callback) {
defaults(params, {
'auth-id': process.env.SMARTY_STREETS_API_KEY
});
api({
host: 'https://api.smartystreets.com',
uri: '/street-address',
params: params
}, function (err, body, res) {
if (err) return callback(err);
if (res.statusCode !== 200) {
return callback(
'There was an error contacting the address validation server.'
);
}
return callback(err, body);
});
};

22
src/lib/url-params.js Normal file
View file

@ -0,0 +1,22 @@
/* Turn an object into an url param string
* urlParams({a: 1, b: 2, c: 3})
* // a=1&b=2&c=3
*/
module.exports = function urlParams (values) {
return Object
.keys(values)
.map(function (key) {
var value = typeof values[key] === 'undefined' ? '' : values[key];
function encodeKeyValuePair (value) {
return [key, value]
.map(encodeURIComponent)
.join('=');
}
if (Array.isArray(value)) {
return value.map(encodeKeyValuePair).join('&');
} else {
return encodeKeyValuePair(value);
}
})
.join('&');
};

View file

@ -1,76 +0,0 @@
var defaults = require('lodash.defaults');
var xhr = require('xhr');
var jar = require('../lib/jar.js');
var log = require('../lib/log.js');
/**
* Component mixin that constructs requests to the scratch api.
* Custom arguments:
* - useCsrf [boolean] handles unique csrf token retrieval for POST requests. This prevents
* CSRF forgeries (see: https://www.squarefree.com/securitytips/web-developers.html#CSRF)
*
* It also takes in other arguments specified in the xhr library spec.
*/
var Api = {
api: function (opts, callback) {
defaults(opts, {
host: window.env.API_HOST,
headers: {},
json: {},
useCsrf: false
});
defaults(opts.headers, {
'X-Requested-With': 'XMLHttpRequest'
});
opts.uri = opts.host + opts.uri;
var apiRequest = function (opts) {
if (opts.host !== '') {
// For IE < 10, we must use XDR for cross-domain requests. XDR does not support
// custom headers.
defaults(opts, {useXDR: true});
delete opts.headers;
if (opts.authentication) {
var authenticationParams = ['x-token=' + opts.authentication];
var parts = opts.uri.split('?');
var qs = (parts[1] || '').split('&').concat(authenticationParams).join('&');
opts.uri = parts[0] + '?' + qs;
}
}
xhr(opts, function (err, res, body) {
if (err) log.error(err);
// Legacy API responses come as lists, and indicate to redirect the client like
// [{success: true, redirect: "/location/to/redirect"}]
try {
if ('redirect' in body[0]) window.location = body[0].redirect;
} catch (err) {
// do nothing
}
callback(err, body);
});
}.bind(this);
if (typeof jar.get('scratchlanguage') !== 'undefined') {
opts.headers['Accept-Language'] = jar.get('scratchlanguage') + ', en;q=0.8';
}
if (opts.authentication) {
opts.headers['X-Token'] = opts.authentication;
}
if (opts.useCsrf) {
jar.use('scratchcsrftoken', '/csrf_token/', function (err, csrftoken) {
if (err) return log.error('Error while retrieving CSRF token', err);
opts.json.csrftoken = csrftoken;
opts.headers['X-CSRFToken'] = csrftoken;
apiRequest(opts);
}.bind(this));
} else {
apiRequest(opts);
}
}
};
module.exports = Api;

View file

@ -1,17 +0,0 @@
var jar = require('../lib/jar');
var cookieMixinFactory = function (cookieName, cookieSetter) {
var capitalizedCookieName = cookieName.charAt(0).toUpperCase() + cookieName.slice(1);
var getterName = 'get' + capitalizedCookieName;
var userName = 'use' + capitalizedCookieName;
var mixin = {};
mixin[getterName] = function (callback) {
jar.get(cookieName, callback);
};
mixin[userName] = function (callback) {
jar.use(cookieName, cookieSetter, callback);
};
return mixin;
};
module.exports = cookieMixinFactory;

View file

@ -1,5 +1,5 @@
var keyMirror = require('keymirror');
var api = require('../mixins/api.jsx').api;
var api = require('../lib/api');
var Types = keyMirror({
SET_DETAILS: null,

View file

@ -1,5 +1,5 @@
var keyMirror = require('keymirror');
var api = require('../mixins/api.jsx').api;
var api = require('../lib/api');
var Types = keyMirror({
SET_SCHEDULE: null,

View file

@ -1,7 +1,7 @@
var keyMirror = require('keymirror');
var defaults = require('lodash.defaults');
var api = require('../mixins/api.jsx').api;
var api = require('../lib/api');
var tokenActions = require('./token.js');
var Types = keyMirror({

View file

@ -91,6 +91,12 @@
"view": "jobs/jobs",
"title": "Jobs"
},
{
"name": "teacherregistration",
"pattern": "^/register-teacher/?$",
"view": "teacherregistration/teacherregistration",
"title": "Teacher Registration"
},
{
"name": "wedo2",
"pattern": "^/wedo/?$",

View file

@ -1,7 +1,4 @@
module.exports = {
// Bind environment
api_host: process.env.API_HOST || 'https://api.scratch.mit.edu',
// Search and metadata
title: 'Imagine, Program, Share',
description:

View file

@ -31,13 +31,6 @@
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="stylesheet" href="/css/lib/normalize.min.css" />
<!-- Environment -->
<script>
window.env = {
API_HOST: "{{&api_host}}"
};
</script>
<!-- Polyfills -->
<script src="/js/polyfill.min.js"></script>
</head>

View file

@ -3,7 +3,7 @@ var FormattedMessage = require('react-intl').FormattedMessage;
var React = require('react');
var render = require('../../lib/render.jsx');
var Api = require('../../mixins/api.jsx');
var api = require('../../lib/api');
var Page = require('../../components/page/www/page.jsx');
var Box = require('../../components/box/box.jsx');
@ -16,9 +16,6 @@ require('./explore.scss');
// @todo migrate to React-Router once available
var Explore = injectIntl(React.createClass({
type: 'Explore',
mixins: [
Api
],
getDefaultProps: function () {
var categoryOptions = ['all','animations','art','games','music','stories'];
var typeOptions = ['projects','studios'];
@ -57,7 +54,7 @@ var Explore = injectIntl(React.createClass({
if (this.props.tab != 'all') {
qText = '&q=' + this.props.category;
}
this.api({
api({
uri: '/search/' + this.props.itemType +
'?limit=' + this.props.loadNumber +
'&offset=' + this.state.offset +

View file

@ -3,7 +3,7 @@ var FormattedMessage = require('react-intl').FormattedMessage;
var React = require('react');
var render = require('../../lib/render.jsx');
var Api = require('../../mixins/api.jsx');
var api = require('../../lib/api');
var Page = require('../../components/page/www/page.jsx');
var Box = require('../../components/box/box.jsx');
@ -16,9 +16,6 @@ require('./search.scss');
// @todo migrate to React-Router once available
var Search = injectIntl(React.createClass({
type: 'Search',
mixins: [
Api
],
getDefaultProps: function () {
var query = window.location.search;
var pathname = window.location.pathname.toLowerCase();
@ -60,7 +57,7 @@ var Search = injectIntl(React.createClass({
if (this.props.searchTerm !== '') {
termText = '&q=' + this.props.searchTerm;
}
this.api({
api({
uri: '/search/' + this.props.tab +
'?limit=' + this.props.loadNumber +
'&offset=' + this.state.offset +

View file

@ -2,14 +2,13 @@ var connect = require('react-redux').connect;
var injectIntl = require('react-intl').injectIntl;
var omit = require('lodash.omit');
var React = require('react');
var render = require('../../lib/render.jsx');
var api = require('../../lib/api');
var permissionsActions = require('../../redux/permissions.js');
var render = require('../../lib/render.jsx');
var sessionActions = require('../../redux/session.js');
var shuffle = require('../../lib/shuffle.js').shuffle;
var Api = require('../../mixins/api.jsx');
var Activity = require('../../components/activity/activity.jsx');
var AdminPanel = require('../../components/adminpanel/adminpanel.jsx');
var DropdownBanner = require('../../components/dropdown-banner/banner.jsx');
@ -27,9 +26,6 @@ require('./splash.scss');
var Splash = injectIntl(React.createClass({
type: 'Splash',
mixins: [
Api
],
getInitialState: function () {
return {
projectCount: 14000000, // gets the shared project count
@ -97,42 +93,42 @@ var Splash = injectIntl(React.createClass({
}
},
getActivity: function () {
this.api({
api({
uri: '/proxy/users/' + this.props.session.session.user.username + '/activity?limit=5'
}, function (err, body) {
if (!err) this.setState({activity: body});
}.bind(this));
},
getFeaturedGlobal: function () {
this.api({
api({
uri: '/proxy/featured'
}, function (err, body) {
if (!err) this.setState({featuredGlobal: body});
}.bind(this));
},
getFeaturedCustom: function () {
this.api({
api({
uri: '/proxy/users/' + this.props.session.session.user.id + '/featured'
}, function (err, body) {
if (!err) this.setState({featuredCustom: body});
}.bind(this));
},
getNews: function () {
this.api({
api({
uri: '/news?limit=3'
}, function (err, body) {
if (!err) this.setState({news: body});
}.bind(this));
},
getProjectCount: function () {
this.api({
api({
uri: '/projects/count/all'
}, function (err, body) {
if (!err) this.setState({projectCount: body.count});
}.bind(this));
},
refreshHomepageCache: function () {
this.api({
api({
host: '',
uri: '/scratch_admin/homepage/clear-cache/',
method: 'post',
@ -168,7 +164,7 @@ var Splash = injectIntl(React.createClass({
this.setState({emailConfirmationModalOpen: false});
},
handleDismiss: function (cue) {
this.api({
api({
host: '',
uri: '/site-api/users/set-template-cue/',
method: 'post',

View file

@ -0,0 +1,61 @@
{
"teacherRegistration.usernameStepDescription": "Fill in the following forms to request an account. The approval process may take up to 24 hours.",
"teacherRegistration.usernameStepTitle": "Request a Teacher Account",
"teacherRegistration.validationUsernameRegexp": "Your username may only contain characters and -",
"teacherRegistration.validationUsernameMinLength": "Usernames must be at least 3 characters",
"teacherRegistration.validationUsernameMaxLength": "Usernames must be at most 20 characters",
"teacherRegistration.validationPasswordLength": "Passwords must be at least six characters",
"teacherRegistration.validationPasswordNotEquals": "Your password may not be \"password\"",
"teacherRegistration.validationPasswordNotUsername": "Your password may not be your username",
"teacherRegistration.showPassword": "Show password",
"teacherRegistration.nextStep": "Next Step",
"teacherRegistration.personalStepTitle": "Personal Information",
"teacherRegistration.personalStepDescription": "Your individual responses will not be displayed publicly, and will be kept confidential and secure",
"teacherRegistration.nameStepTitle": "First &amp; Last Name",
"teacherRegistration.nameStepDescription": "Your name will not be displayed publicly, and will be kept confidential and secure",
"teacherRegistration.firstName": "First Name",
"teacherRegistration.lastName": "Last Name",
"teacherRegistration.phoneStepTitle": "Phone Number",
"teacherRegistration.phoneNumber": "Phone Number",
"teacherRegistration.phoneStepDescription": "Your phone number will not be displayed publicly, and will be kept confidential and secure",
"teacherRegistration.phoneConsent": "Yes, the Scratch Team may call me to verify my Teacher Account if needed",
"teacherRegistration.validationPhoneConsent": "You must consent to verification of your Teacher Account",
"teacherRegistration.orgStepTitle": "Organization",
"teacherRegistration.orgStepDescription": "Your information will not be displayed publicly, and will be kept confidential and secure",
"teacherRegistration.organization": "Organization",
"teacherRegistration.orgTitle": "Your Role",
"teacherRegistration.orgType": "Type of Organization",
"teacherRegistration.checkAll": "Check all that apply",
"teacherRegistration.orgChoiceElementarySchool": "Elementary School",
"teacherRegistration.orgChoiceMiddleSchool": "Middle School",
"teacherRegistration.orgChoiceHighSchool": "High School",
"teacherRegistration.orgChoiceUniversity": "College/University",
"teacherRegistration.orgChoiceAfterschool": "Afterscool Program",
"teacherRegistration.orgChoiceMuseum": "Museum",
"teacherRegistration.orgChoiceLibrary": "Library",
"teacherRegistration.orgChoiceCamp": "Camp",
"teacherRegistration.orgChoiceOther": "Other",
"teacherRegistration.selectCountry": "select country",
"teacherRegistration.validationAddress": "This doesn't look like a real address",
"teacherRegistration.addressLine1": "Address Line 1",
"teacherRegistration.addressLine2": "Address Line 2",
"teacherRegistration.zipCode": "ZIP",
"teacherRegistration.stateProvince": "State",
"teacherRegistration.city": "City",
"teacherRegistration.addressStepTitle": "Address",
"teacherRegistration.addressStepDescription": "Your information will not be displayed publicly, and will be kept confidential and secure.",
"teacherRegistration.useScratchStepTitle": "How you plan to use Scratch",
"teacherRegistration.useScratchStepDescription": "Tell us a little about how you plan to use Scratch. Why do we ask for this information",
"teacherRegistration.howUseScratch": "How do you plan to use Scratch at your organization?",
"teacherRegistration.emailStepTitle": "Email Address",
"teacherRegistration.emailStepDescription": "We will send you a confirmation email that will allow you to access your Scratch Teacher Account.",
"teacherRegistration.validationEmailMatch": "The emails do not match",
"teacherRegistration.lastStepTitle": "Thank you for requesting a Scratch Teacher Account",
"teacherRegistration.lastStepDescription": "We are currently processing your application. ",
"teacherRegistration.confirmYourEmail": "Confirm Your Email",
"teacherRegistration.confirmYourEmailDescription": "If you haven't already, please click the link in the confirmation email sent to:",
"teacherRegistration.waitForApproval": "Wait for Approval",
"teacherRegistration.waitForApprovalDescription": "Your information is being reviewed. Please be patient, the approval process can take up to 24 hours. You will receive an email with your login information once your account has been created.",
"teacherRegistration.checkOutResources": "Get Started with Resources",
"teacherRegistration.checkOutResourcesDescription": "Explore materials for educators and facilitators written by the Scratch Team, including <a href='/educators#resources'>tips, tutorials, and guides</a>."
}

View file

@ -0,0 +1,639 @@
var React = require('react');
var api = require('../../lib/api');
var countryData = require('../../lib/country-data');
var intl = require('../../lib/intl.jsx');
var log = require('../../lib/log');
var smartyStreets = require('../../lib/smarty-streets');
var urlParams = require('../../lib/url-params');
var Button = require('../../components/forms/button.jsx');
var Card = require('../../components/card/card.jsx');
var Checkbox = require('../../components/forms/checkbox.jsx');
var CheckboxGroup = require('../../components/forms/checkbox-group.jsx');
var Form = require('../../components/forms/form.jsx');
var Input = require('../../components/forms/input.jsx');
var PhoneInput = require('../../components/forms/phone-input.jsx');
var RadioGroup = require('../../components/forms/radio-group.jsx');
var Select = require('../../components/forms/select.jsx');
var Slide = require('../../components/slide/slide.jsx');
var Spinner = require('../../components/spinner/spinner.jsx');
var StepNavigation = require('../../components/stepnavigation/stepnavigation.jsx');
var TextArea = require('../../components/forms/textarea.jsx');
var DEFAULT_COUNTRY = 'us';
var NextStepButton = React.createClass({
getDefaultProps: function () {
return {
waiting: false,
text: 'Next Step'
};
},
render: function () {
return (
<Button type="submit" disabled={this.props.waiting} className="card-button">
{this.props.waiting ?
<Spinner /> :
this.props.text
}
</Button>
);
}
});
module.exports = {
UsernameStep: intl.injectIntl(React.createClass({
getDefaultProps: function () {
return {
waiting: false
};
},
getInitialState: function () {
return {
showPassword: false,
waiting: false
};
},
onChangeShowPassword: function (field, value) {
this.setState({showPassword: value});
},
onValidSubmit: function (formData, reset, invalidate) {
this.setState({waiting: true});
api({
host: '',
uri: '/accounts/check_username/' + formData.user.username + '/'
}, function (err, res) {
var formatMessage = this.props.intl.formatMessage;
this.setState({waiting: false});
if (err) return invalidate({all: err});
res = res[0];
switch (res.msg) {
case 'valid username':
return this.props.onNextStep(formData);
case 'username exists':
return invalidate({
'user.username': formatMessage({id: 'general.validationUsernameExists'})
});
case 'bad username':
return invalidate({
'user.username': formatMessage({id: 'general.validationUsernameVulgar'})
});
case 'invalid username':
default:
return invalidate({
'user.username': formatMessage({id: 'general.validationUsernameInvalid'})
});
}
}.bind(this));
},
render: function () {
var formatMessage = this.props.intl.formatMessage;
return (
<Slide>
<h1><intl.FormattedMessage id="teacherRegistration.usernameStepTitle" /></h1>
<p className="description">
<intl.FormattedMessage id="teacherRegistration.usernameStepDescription" />
</p>
<Card>
<Form onValidSubmit={this.onValidSubmit}>
<Input label={formatMessage({id: 'general.username'})}
type="text"
name="user.username"
validations={{
matchRegexp: /^[\w-]*$/,
minLength: 3,
maxLength: 20
}}
validationErrors={{
matchRegexp: formatMessage({
id: 'teacherRegistration.validationUsernameRegexp'
}),
minLength: formatMessage({
id: 'teacherRegistration.validationUsernameMinLength'
}),
maxLength: formatMessage({
id: 'teacherRegistration.validationUsernameMaxLength'
})
}}
required />
<Input label={formatMessage({id: 'general.password'})}
type={this.state.showPassword ? 'text' : 'password'}
name="user.password"
validations={{
minLength: 6,
notEquals: 'password',
notEqualsField: 'user.username'
}}
validationErrors={{
minLength: formatMessage({
id: 'teacherRegistration.validationPasswordLength'
}),
notEquals: formatMessage({
id: 'teacherRegistration.validationPasswordNotEquals'
}),
notEqualsField: formatMessage({
id: 'teacherRegistration.validationPasswordNotUsername'
})
}}
required />
<Checkbox label={formatMessage({id: 'teacherRegistration.showPassword'})}
value={this.state.showPassword}
onChange={this.onChangeShowPassword}
help={null}
name="showPassword" />
<NextStepButton waiting={this.props.waiting || this.state.waiting}
text={<intl.FormattedMessage id="teacherRegistration.nextStep" />} />
</Form>
</Card>
<StepNavigation steps={this.props.totalSteps - 1} active={this.props.activeStep} />
</Slide>
);
}
})),
DemographicsStep: intl.injectIntl(React.createClass({
getDefaultProps: function () {
return {
defaultCountry: DEFAULT_COUNTRY,
waiting: false
};
},
getInitialState: function () {
return {otherDisabled: true};
},
getMonthOptions: function () {
return [
'January', 'February', 'March', 'April', 'May', 'June', 'July',
'August', 'September', 'October', 'November', 'December'
].map(function (label, id) {
return {
value: id+1,
label: this.props.intl.formatMessage({id: 'general.month' + label})};
}.bind(this));
},
getYearOptions: function () {
return Array.apply(null, Array(100)).map(function (v, id) {
var year = 2016 - id;
return {value: year, label: year};
});
},
onChooseGender: function (name, gender) {
this.setState({otherDisabled: gender !== 'other'});
},
render: function () {
var formatMessage = this.props.intl.formatMessage;
return (
<Slide>
<h1>
<intl.FormattedMessage id="teacherRegistration.personalStepTitle" />
</h1>
<p className="description">
<intl.FormattedMessage id="teacherRegistration.personalStepDescription" />
</p>
<Card>
<Form onValidSubmit={this.props.onNextStep}>
<Select label={formatMessage({id: 'general.birthMonth'})}
name="user.birth.month"
options={this.getMonthOptions()}
required />
<Select label={formatMessage({id: 'general.birthYear'})}
name="user.birth.year"
options={this.getYearOptions()} required />
<RadioGroup label={formatMessage({id: 'general.gender'})}
name="user.gender"
onChange={this.onChooseGender}
options={[
{value: 'female', label: formatMessage({id: 'general.female'})},
{value: 'male', label: formatMessage({id: 'general.male'})},
{value: 'other', label: formatMessage({id: 'general.other'})}
]}
required />
<Input name="user.genderOther"
type="text"
disabled={this.state.otherDisabled}
required={!this.state.otherDisabled}
help={null} />
<Select label={formatMessage({id: 'general.country'})}
name="user.country"
options={countryData.countryOptions}
value={this.props.defaultCountry}
required />
<Checkbox className="demographics-checkbox-is-robot"
label="I'm a robot!"
name="user.isRobot" />
<NextStepButton waiting={this.props.waiting}
text={<intl.FormattedMessage id="teacherRegistration.nextStep" />} />
</Form>
</Card>
<StepNavigation steps={this.props.totalSteps - 1} active={this.props.activeStep} />
</Slide>
);
}
})),
NameStep: intl.injectIntl(React.createClass({
getDefaultProps: function () {
return {
waiting: false
};
},
render: function () {
var formatMessage = this.props.intl.formatMessage;
return (
<Slide>
<h1>
<intl.FormattedHTMLMessage id="teacherRegistration.nameStepTitle" />
</h1>
<p className="description">
<intl.FormattedMessage id="teacherRegistration.nameStepDescription" />
</p>
<Card>
<Form onValidSubmit={this.props.onNextStep}>
<Input label={formatMessage({id: 'teacherRegistration.firstName'})}
type="text"
name="user.name.first"
required />
<Input label={formatMessage({id: 'teacherRegistration.lastName'})}
type="text"
name="user.name.last"
required />
<NextStepButton waiting={this.props.waiting}
text={<intl.FormattedMessage id="teacherRegistration.nextStep" />} />
</Form>
</Card>
<StepNavigation steps={this.props.totalSteps - 1} active={this.props.activeStep} />
</Slide>
);
}
})),
PhoneNumberStep: intl.injectIntl(React.createClass({
getDefaultProps: function () {
return {
defaultCountry: DEFAULT_COUNTRY,
waiting: false
};
},
render: function () {
var formatMessage = this.props.intl.formatMessage;
return (
<Slide>
<h1>
<intl.FormattedMessage id="teacherRegistration.phoneStepTitle" />
</h1>
<p>
<intl.FormattedMessage id="teacherRegistration.phoneStepDescription" />
</p>
<Card>
<Form onValidSubmit={this.props.onNextStep}>
<PhoneInput label={formatMessage({id: 'teacherRegistration.phoneNumber'})}
name="phone"
defaultCountry={
(this.props.formData.user && this.props.formData.user.country) ||
this.props.defaultCountry
}
required />
<Checkbox label={formatMessage({id: 'teacherRegistration.phoneConsent'})}
name="phoneConsent"
required="isFalse"
validationErrors={{
isFalse: formatMessage({id: 'teacherRegistration.validationPhoneConsent'})
}} />
<NextStepButton waiting={this.props.waiting}
text={<intl.FormattedMessage id="teacherRegistration.nextStep" />} />
</Form>
</Card>
<StepNavigation steps={this.props.totalSteps - 1} active={this.props.activeStep} />
</Slide>
);
}
})),
OrganizationStep: intl.injectIntl(React.createClass({
getInitialState: function () {
return {
otherDisabled: true
};
},
getDefaultProps: function () {
return {
waiting: false
};
},
organizationL10nStems: [
'orgChoiceElementarySchool',
'orgChoiceMiddleSchool',
'orgChoiceHighSchool',
'orgChoiceUniversity',
'orgChoiceAfterschool',
'orgChoiceMuseum',
'orgChoiceLibrary',
'orgChoiceCamp',
'orgChoiceOther'
],
getOrganizationOptions: function () {
return this.organizationL10nStems.map(function (choice, id) {
return {
value: id,
label: this.props.intl.formatMessage({
id: 'teacherRegistration.' + choice
})
};
}.bind(this));
},
onChooseOrganization: function (name, values) {
this.setState({otherDisabled: values.indexOf(this.organizationL10nStems.indexOf('orgChoiceOther')) === -1});
},
render: function () {
var formatMessage = this.props.intl.formatMessage;
return (
<Slide>
<h1>
<intl.FormattedMessage id="teacherRegistration.orgStepTitle" />
</h1>
<p>
<intl.FormattedMessage id="teacherRegistration.orgStepDescription" />
</p>
<Card>
<Form onValidSubmit={this.props.onNextStep}>
<Input label={formatMessage({id: 'teacherRegistration.organization'})}
type="text"
name="organization.name"
required />
<Input label={formatMessage({id: 'teacherRegistration.orgTitle'})}
type="text"
name="organization.title"
required />
<CheckboxGroup label={formatMessage({id: 'teacherRegistration.orgType'})}
help={formatMessage({id: 'teacherRegistration.checkAll'})}
name="organization.type"
value={[]}
options={this.getOrganizationOptions()}
onChange={this.onChooseOrganization}
required />
<Input type="text"
name="organization.other"
disabled={this.state.otherDisabled}
required={!this.state.otherDisabled} />
<Input label={formatMessage({id: 'general.website'})}
type="url"
name="organization.url" />
<NextStepButton waiting={this.props.waiting}
text={<intl.FormattedMessage id="teacherRegistration.nextStep" />} />
</Form>
</Card>
<StepNavigation steps={this.props.totalSteps - 1} active={this.props.activeStep} />
</Slide>
);
}
})),
AddressStep: intl.injectIntl(React.createClass({
getDefaultProps: function () {
return {
defaultCountry: DEFAULT_COUNTRY,
waiting: false
};
},
getInitialState: function () {
return {
countryChoice: (
(this.props.formData.user && this.props.formData.user.country) ||
this.props.defaultCountry
),
waiting: false
};
},
onChangeCountry: function (field, choice) {
this.setState({countryChoice: choice});
},
onValidSubmit: function (formData, reset, invalidate) {
if (formData.address.country !== 'us') {
return this.props.onNextStep(formData);
}
this.setState({waiting: true});
var address = {
street: formData.address.line1,
secondary: formData.address.line2 || '',
city: formData.address.city,
state: formData.address.state,
zipcode: formData.address.zip
};
smartyStreets(address, function (err, res) {
this.setState({waiting: false});
if (err) {
// We don't want to prevent registration because
// address validation isn't working. Log it and
// move on.
log.error(err);
return this.props.onNextStep(formData);
}
if (res && res.length > 0) {
return this.props.onNextStep(formData);
} else {
return invalidate({
'all': <FormattedMessage id="teacherRegistration.addressValidationError" />
});
}
}.bind(this));
},
render: function () {
var formatMessage = this.props.intl.formatMessage;
var stateOptions = countryData.subdivisionOptions[this.state.countryChoice];
stateOptions = [{}].concat(stateOptions);
var countryOptions = countryData.countryOptions.concat({
label: formatMessage({id: 'teacherRegistration.selectCountry'}),
disabled: true,
selected: true
}).sort(function (a, b) {
if (a.disabled) return -1;
if (b.disabled) return 1;
if (a.value === this.props.defaultCountry) return -1;
if (b.value === this.props.defaultCountry) return 1;
return 0;
}.bind(this));
return (
<Slide>
<h1>
<intl.FormattedMessage id="teacherRegistration.addressStepTitle" />
</h1>
<p className="description">
<intl.FormattedMessage id="teacherRegistration.addressStepDescription" />
</p>
<Card>
<Form onValidSubmit={this.onValidSubmit}>
<Select label={formatMessage({id: 'general.country'})}
name="address.country"
options={countryOptions}
onChange={this.onChangeCountry}
required />
<Input label={formatMessage({id: 'teacherRegistration.addressLine1'})}
type="text"
name="address.line1"
required />
<Input label={formatMessage({id: 'teacherRegistration.addressLine2'})}
type="text"
name="address.line2" />
<Input label={formatMessage({id: 'teacherRegistration.city'})}
type="text"
name="address.city"
required />
{stateOptions.length > 2 ?
<Select label={formatMessage({id: 'teacherRegistration.stateProvince'})}
name="address.state"
options={stateOptions}
required /> :
[]
}
<Input label={formatMessage({id: 'teacherRegistration.zipCode'})}
type="text"
name="address.zip"
required />
<NextStepButton waiting={this.props.waiting || this.state.waiting}
text={<intl.FormattedMessage id="teacherRegistration.nextStep" />} />
</Form>
</Card>
<StepNavigation steps={this.props.totalSteps - 1} active={this.props.activeStep} />
</Slide>
);
}
})),
UseScratchStep: intl.injectIntl(React.createClass({
getDefaultProps: function () {
return {
waiting: false
};
},
render: function () {
var formatMessage = this.props.intl.formatMessage;
return (
<Slide>
<h1>
<intl.FormattedMessage id="teacherRegistration.useScratchStepTitle" />
</h1>
<p className="description">
<intl.FormattedMessage id="teacherRegistration.useScratchStepDescription" />
</p>
<Card>
<Form onValidSubmit={this.props.onNextStep}>
<TextArea label={formatMessage({id: 'teacherRegistration.howUseScratch'})}
name="useScratch"
required />
<NextStepButton waiting={this.props.waiting}
text={<intl.FormattedMessage id="teacherRegistration.nextStep" />} />
</Form>
</Card>
<StepNavigation steps={this.props.totalSteps - 1} active={this.props.activeStep} />
</Slide>
);
}
})),
EmailStep: intl.injectIntl(React.createClass({
getDefaultProps: function () {
return {
waiting: false
};
},
getInitialState: function () {
return {
waiting: false
};
},
onValidSubmit: function (formData, reset, invalidate) {
this.setState({waiting: true});
api({
host: '',
uri: '/accounts/check_email/',
params: {email: formData.user.email}
}, function (err, res) {
this.setState({waiting: false});
if (err) return invalidate({all: err});
res = res[0];
switch (res.msg) {
case 'valid email':
return this.props.onNextStep(formData);
default:
return invalidate({'user.email': res.msg});
}
}.bind(this));
},
render: function () {
var formatMessage = this.props.intl.formatMessage;
return (
<Slide>
<h1>
<intl.FormattedMessage id="teacherRegistration.emailStepTitle" />
</h1>
<p className="description">
<intl.FormattedMessage id="teacherRegistration.emailStepDescription" />
</p>
<Card>
<Form onValidSubmit={this.onValidSubmit}>
<Input label={formatMessage({id: 'general.emailAddress'})}
type="text"
name="user.email"
validations="isEmail"
validationError={formatMessage({id: 'general.validationEmail'})}
required />
<Input label={formatMessage({id: 'general.confirmEmail'})}
type="text"
name="confirmEmail"
validations="equalsField:user.email"
validationErrors={{
equalsField: formatMessage({id: 'general.validationEmailMatch'})
}}
required />
<NextStepButton waiting={this.props.waiting}
text={<intl.FormattedMessage id="teacherRegistration.nextStep" />} />
</Form>
</Card>
<StepNavigation steps={this.props.totalSteps - 1} active={this.props.activeStep} />
</Slide>
);
}
})),
LastStep: intl.injectIntl(React.createClass({
render: function () {
return (
<Slide>
<h1>
<intl.FormattedMessage id="teacherRegistration.lastStepTitle" />
</h1>
<p className="description">
<intl.FormattedMessage id="teacherRegistration.lastStepDescription" />
</p>
<Card className="confirm">
<h2><intl.FormattedMessage id="teacherRegistration.confirmYourEmail" /></h2>
<p>
<intl.FormattedMessage id="teacherRegistration.confirmYourEmailDescription" /><br />
<strong>{this.props.formData.user && this.props.formData.user.email}</strong>
</p>
</Card>
<Card className="wait">
<h2><intl.FormattedMessage id="teacherRegistration.waitForApproval" /></h2>
<p>
<intl.FormattedMessage id="teacherRegistration.waitForApprovalDescription" />
</p>
</Card>
<Card className="resources">
<h2><intl.FormattedMessage id="teacherRegistration.checkOutResources" /></h2>
<p>
<intl.FormattedHTMLMessage id="teacherRegistration.checkOutResourcesDescription" />
</p>
</Card>
</Slide>
);
}
})),
RegistrationError: intl.injectIntl(React.createClass({
render: function () {
return (
<Slide>
<h1>Something went wrong</h1>
<Card>
<h2>There was an error while processing your registration</h2>
<p>
{this.props.registrationError}
</p>
</Card>
</Slide>
);
}
}))
};

View file

@ -0,0 +1,98 @@
var defaults = require('lodash.defaultsdeep');
var React = require('react');
var render = require('../../lib/render.jsx');
var api = require('../../lib/api');
var Deck = require('../../components/deck/deck.jsx');
var Progression = require('../../components/progression/progression.jsx');
var Steps = require('./steps.jsx');
require('./teacherregistration.scss');
var TeacherRegistration = React.createClass({
type: 'TeacherRegistration',
getInitialState: function () {
return {
formData: {},
registrationError: null,
step: 0,
waiting: false
};
},
advanceStep: function (formData) {
formData = formData || {};
this.setState({
step: this.state.step + 1,
formData: defaults({}, formData, this.state.formData)
});
},
register: function (formData) {
this.setState({waiting: true});
api({
host: '',
uri: '/classes/register_educator/',
method: 'post',
useCsrf: true,
formData: {
username: this.state.formData.user.username,
email: formData.user.email,
password: this.state.formData.user.password,
birth_month: this.state.formData.user.birth.month,
birth_year: this.state.formData.user.birth.year,
gender: (
this.state.formData.user.gender === 'other' ?
this.state.formData.user.genderOther :
this.state.formData.user.gender
),
country: this.state.formData.user.country,
is_robot: this.state.formData.user.isRobot,
first_name: this.state.formData.user.name.first,
last_name: this.state.formData.user.name.last,
phone_number: this.state.formData.phone.national_number,
organization_name: this.state.formData.organization.name,
organization_title: this.state.formData.organization.title,
organization_type: this.state.formData.organization.type,
organization_other: this.state.formData.organization.other,
organization_url: this.state.formData.organization.url,
address_country: this.state.formData.address.country,
address_line1: this.state.formData.address.line1,
address_line2: this.state.formData.address.line2,
address_city: this.state.formData.address.city,
address_state: this.state.formData.address.state,
address_zip: this.state.formData.address.zip,
how_use_scratch: this.state.formData.useScratch
}
}, function (err, res) {
this.setState({waiting: false});
if (err) return this.setState({registrationError: err});
if (res[0].success) return this.advanceStep(formData);
this.setState({registrationError: res[0].msg});
}.bind(this));
},
render: function () {
return (
<Deck className="teacher-registration">
{this.state.registrationError ?
<Steps.RegistrationError {... this.state} />
:
<Progression {... this.state}>
<Steps.UsernameStep {... this.state} onNextStep={this.advanceStep} />
<Steps.DemographicsStep {... this.state} onNextStep={this.advanceStep} />
<Steps.NameStep {... this.state} onNextStep={this.advanceStep} />
<Steps.PhoneNumberStep {... this.state} onNextStep={this.advanceStep} />
<Steps.OrganizationStep {... this.state} onNextStep={this.advanceStep} />
<Steps.AddressStep {... this.state} onNextStep={this.advanceStep} />
<Steps.UseScratchStep {... this.state} onNextStep={this.advanceStep} />
<Steps.EmailStep {... this.state} onNextStep={this.register} />
<Steps.LastStep {... this.state} />
</Progression>
}
</Deck>
);
}
});
render(<TeacherRegistration />, document.getElementById('app'));

View file

@ -0,0 +1,36 @@
@import "../../colors";
.teacher-registration {
background-color: $ui-purple;
.slide {
h1,
p {
color: $type-white;
}
}
.card {
&,
h2,
p {
color: $type-gray;
}
}
.card-button {
display: block;
border-top-left-radius: 0;
border-top-right-radius: 0;
background-color: $ui-aqua;
width: 100%;
}
.step-navigation {
text-align: center;
}
.demographics-checkbox-is-robot {
display: none;
}
}

BIN
static/images/flags.png Normal file

Binary file not shown.

After

(image error) Size: 69 KiB

View file

@ -79,11 +79,16 @@ module.exports = {
test: /\.scss$/,
loader: 'style!css!postcss-loader!sass'
},
{
test: /\.css$/,
loader: 'style!css!postcss-loader'
},
{
test: /\.(png|jpg|gif|eot|svg|ttf|woff)$/,
loader: 'url-loader'
}
]
],
noParse: /node_modules\/google-libphonenumber\/dist/
},
postcss: function () {
return [autoprefixer({browsers: ['last 3 versions']})];
@ -109,7 +114,9 @@ module.exports = {
}),
new webpack.DefinePlugin({
'process.env.NODE_ENV': '"' + (process.env.NODE_ENV || 'development') + '"',
'process.env.SENTRY_DSN': '"' + (process.env.SENTRY_DSN || '') + '"'
'process.env.SENTRY_DSN': '"' + (process.env.SENTRY_DSN || '') + '"',
'process.env.API_HOST': '"' + (process.env.API_HOST || 'https://api.scratch.mit.edu') + '"',
'process.env.SMARTY_STREETS_API_KEY': '"' + (process.env.SMARTY_STREETS_API_KEY || '') + '"'
}),
new webpack.optimize.CommonsChunkPlugin('common', 'js/common.bundle.js'),
new webpack.optimize.OccurenceOrderPlugin()