Merge branch 'staging'

This commit is contained in:
Berkeley Martinez
2016-03-07 15:35:53 -08:00
110 changed files with 3482 additions and 2085 deletions

View File

@ -1,6 +1,9 @@
{ {
"parserOption": {
"ecmaVersion": 6,
"ecmaFeatures": { "ecmaFeatures": {
"jsx": true "jsx": true
}
}, },
"env": { "env": {
"browser": true, "browser": true,
@ -12,6 +15,7 @@
"react" "react"
], ],
"globals": { "globals": {
"Promise": true,
"window": true, "window": true,
"$": true, "$": true,
"ga": true, "ga": true,
@ -58,7 +62,6 @@
"no-caller": 2, "no-caller": 2,
"no-div-regex": 2, "no-div-regex": 2,
"no-else-return": 0, "no-else-return": 0,
"no-empty-label": 2,
"no-eq-null": 1, "no-eq-null": 1,
"no-eval": 2, "no-eval": 2,
"no-extend-native": 2, "no-extend-native": 2,
@ -182,10 +185,7 @@
"always" "always"
], ],
"sort-vars": 0, "sort-vars": 0,
"space-after-keywords": [ "keyword-spacing": [ 2 ],
2,
"always"
],
"space-before-function-paren": [ "space-before-function-paren": [
2, 2,
"never" "never"
@ -197,7 +197,6 @@
"space-in-brackets": 0, "space-in-brackets": 0,
"space-in-parens": 0, "space-in-parens": 0,
"space-infix-ops": 2, "space-infix-ops": 2,
"space-return-throw-case": 2,
"space-unary-ops": [ "space-unary-ops": [
1, 1,
{ {
@ -214,7 +213,7 @@
"max-depth": 0, "max-depth": 0,
"max-len": [ "max-len": [
1, 2,
80, 80,
2 2
], ],
@ -232,7 +231,7 @@
"react/jsx-uses-vars": 1, "react/jsx-uses-vars": 1,
"react/no-did-mount-set-state": 2, "react/no-did-mount-set-state": 2,
"react/no-did-update-set-state": 2, "react/no-did-update-set-state": 2,
"react/no-multi-comp": 2, "react/no-multi-comp": [2, { "ignoreStateless": true } ],
"react/prop-types": 2, "react/prop-types": 2,
"react/react-in-jsx-scope": 1, "react/react-in-jsx-scope": 1,
"react/self-closing-comp": 1, "react/self-closing-comp": 1,

View File

@ -34,6 +34,7 @@ window.common = (function(global) {
} }
} }
} }
return null;
}, },
isAlive: function(key) { isAlive: function(key) {

View File

@ -51,10 +51,10 @@ $(document).ready(function() {
<h1>${err}</h1> <h1>${err}</h1>
`).subscribe(() => {}); `).subscribe(() => {});
} }
return null;
}, },
err => console.error(err) err => console.error(err)
); );
} }
common.resetBtn$ common.resetBtn$
@ -74,6 +74,7 @@ $(document).ready(function() {
common.codeStorage.updateStorage(challengeName, originalCode); common.codeStorage.updateStorage(challengeName, originalCode);
common.codeUri.querify(originalCode); common.codeUri.querify(originalCode);
common.updateOutputDisplay(output); common.updateOutputDisplay(output);
return null;
}, },
(err) => { (err) => {
if (err) { if (err) {
@ -112,6 +113,7 @@ $(document).ready(function() {
if (solved) { if (solved) {
common.showCompletion(); common.showCompletion();
} }
return null;
}, },
({ err }) => { ({ err }) => {
console.error(err); console.error(err);
@ -138,6 +140,7 @@ $(document).ready(function() {
return common.updateOutputDisplay('' + err); return common.updateOutputDisplay('' + err);
} }
common.displayTestResults(tests); common.displayTestResults(tests);
return null;
}, },
({ err }) => { ({ err }) => {
console.error(err); console.error(err);
@ -149,7 +152,7 @@ $(document).ready(function() {
challengeType === challengeTypes.BONFIRE || challengeType === challengeTypes.BONFIRE ||
challengeType === challengeTypes.JS challengeType === challengeTypes.JS
) { ) {
Observable.just({}) return Observable.just({})
.delay(500) .delay(500)
.flatMap(() => common.executeChallenge$()) .flatMap(() => common.executeChallenge$())
.catch(err => Observable.just({ err })) .catch(err => Observable.just({ err }))
@ -161,6 +164,7 @@ $(document).ready(function() {
} }
common.codeStorage.updateStorage(challengeName, originalCode); common.codeStorage.updateStorage(challengeName, originalCode);
common.displayTestResults(tests); common.displayTestResults(tests);
return null;
}, },
(err) => { (err) => {
console.error(err); console.error(err);
@ -168,4 +172,5 @@ $(document).ready(function() {
} }
); );
} }
return null;
}); });

View File

@ -110,7 +110,7 @@ window.common = (function({ common = { init: [] }}) {
return null; return null;
} }
execInProgress = true; execInProgress = true;
setTimeout(function() { return setTimeout(function() {
if ( if (
$($('.scroll-locker').children()[0]).height() - 800 > e.detail $($('.scroll-locker').children()[0]).height() - 800 > e.detail
) { ) {

View File

@ -92,16 +92,14 @@ window.common = (function({ $, common = { init: [] }}) {
} }
function handleActionClick(e) { function handleActionClick(e) {
var props = common.challengeSeed[0] || var props = common.challengeSeed[0] || { stepIndex: [] };
{ stepIndex: [] };
var $el = $(this); var $el = $(this);
var index = +$el.attr('id'); var index = +$el.attr('id');
var propIndex = props.stepIndex.indexOf(index); var propIndex = props.stepIndex.indexOf(index);
if (propIndex === -1) { if (propIndex === -1) {
return $el return $el.parent()
.parent()
.find('.disabled') .find('.disabled')
.removeClass('disabled'); .removeClass('disabled');
} }
@ -112,24 +110,20 @@ window.common = (function({ $, common = { init: [] }}) {
var prop = props.properties[propIndex]; var prop = props.properties[propIndex];
var api = props.apis[propIndex]; var api = props.apis[propIndex];
if (common[prop]) { if (common[prop]) {
return $el return $el.parent()
.parent()
.find('.disabled') .find('.disabled')
.removeClass('disabled'); .removeClass('disabled');
} }
$ return $.post(api)
.post(api)
.done(function(data) { .done(function(data) {
// assume a boolean indicates passing // assume a boolean indicates passing
if (typeof data === 'boolean') { if (typeof data === 'boolean') {
return $el return $el.parent()
.parent()
.find('.disabled') .find('.disabled')
.removeClass('disabled'); .removeClass('disabled');
} }
// assume api returns string when fails // assume api returns string when fails
$el return $el.parent()
.parent()
.find('.disabled') .find('.disabled')
.replaceWith('<p>' + data + '</p>'); .replaceWith('<p>' + data + '</p>');
}) })
@ -199,6 +193,7 @@ window.common = (function({ $, common = { init: [] }}) {
$(nextBtnClass).click(handleNextStepClick); $(nextBtnClass).click(handleNextStepClick);
$(actionBtnClass).click(handleActionClick); $(actionBtnClass).click(handleActionClick);
$(finishBtnClass).click(handleFinishClick); $(finishBtnClass).click(handleFinishClick);
return null;
}); });
return common; return common;

View File

@ -1,9 +0,0 @@
export default function toastSaga(err$, toast) {
err$
.doOnNext(() => toast({
type: 'error',
title: 'Oops, something went wrong',
message: `Something went wrong, please try again later`
}))
.subscribe(err => console.error(err));
}

View File

@ -1,69 +0,0 @@
import { Disposable, Observable } from 'rx';
export function location$(history) {
return Observable.create(function(observer) {
const dispose = history.listen(function(location) {
observer.onNext(location);
});
return Disposable.create(() => {
dispose();
});
});
}
const emptyLocation = {
pathname: '',
search: '',
hash: ''
};
let prevKey;
let isSyncing = false;
export default function historySaga(
history,
updateLocation,
goTo,
goBack,
routerState$
) {
routerState$.subscribe(
location => {
if (!location) {
return null;
}
// store location has changed, update history
if (!location.key || location.key !== prevKey) {
isSyncing = true;
history.transitionTo({ ...emptyLocation, ...location });
isSyncing = false;
}
}
);
location$(history)
.doOnNext(location => {
prevKey = location.key;
if (isSyncing) {
return null;
}
return updateLocation(location);
})
.subscribe(() => {});
goTo
.doOnNext((route = '/') => {
history.push(route);
})
.subscribe(() => {});
goBack
.doOnNext(() => {
history.goBack();
})
.subscribe(() => {});
}

View File

@ -1,99 +1,71 @@
import unused from './es6-shims'; // eslint-disable-line import './es6-shims';
import Rx from 'rx'; import Rx from 'rx';
import React from 'react'; import React from 'react';
import Fetchr from 'fetchr'; import debug from 'debug';
import debugFactory from 'debug';
import { Router } from 'react-router'; import { Router } from 'react-router';
import { createLocation, createHistory } from 'history'; import { routeReducer as routing, syncHistory } from 'react-router-redux';
import { hydrate } from 'thundercats'; import { createHistory } from 'history';
import { render$ } from 'thundercats-react';
import app$ from '../common/app'; import app$ from '../common/app';
import historySaga from './history-saga'; import provideStore from '../common/app/provide-store';
import errSaga from './err-saga';
const debug = debugFactory('fcc:client'); // client specific sagas
import sagas from './sagas';
// render to observable
import render from '../common/app/utils/render';
const log = debug('fcc:client');
const DOMContianer = document.getElementById('fcc'); const DOMContianer = document.getElementById('fcc');
const catState = window.__fcc__.data || {}; const initialState = window.__fcc__.data;
const services = new Fetchr({
xhrPath: '/services' const serviceOptions = { xhrPath: '/services' };
});
Rx.config.longStackSupport = !!debug.enabled; Rx.config.longStackSupport = !!debug.enabled;
const history = createHistory(); const history = createHistory();
const appLocation = createLocation( const appLocation = history.createLocation(
location.pathname + location.search location.pathname + location.search
); );
const routingMiddleware = syncHistory(history);
const devTools = window.devToolsExtension ? window.devToolsExtension() : f => f;
const shouldRouterListenForReplays = !!window.devToolsExtension;
const clientSagaOptions = { doc: document };
// returns an observable // returns an observable
app$({ history, location: appLocation }) app$({
.flatMap( location: appLocation,
({ AppCat }) => {
// instantiate the cat with service
const appCat = AppCat(null, services, history);
// hydrate the stores
return hydrate(appCat, catState).map(() => appCat);
},
// not using nextLocation at the moment but will be used for
// redirects in the future
({ nextLocation, props }, appCat) => ({ nextLocation, props, appCat })
)
.doOnNext(({ appCat }) => {
const appStore$ = appCat.getStore('appStore');
const {
toast,
updateLocation,
goTo,
goBack
} = appCat.getActions('appActions');
const routerState$ = appStore$
.map(({ location }) => location)
.filter(location => !!location);
// set page title
appStore$
.pluck('title')
.distinctUntilChanged()
.doOnNext(title => document.title = title)
.subscribe(() => {});
historySaga(
history, history,
updateLocation, serviceOptions,
goTo, initialState,
goBack, middlewares: [
routerState$ routingMiddleware,
); ...sagas.map(saga => saga(clientSagaOptions))
],
const err$ = appStore$ reducers: { routing },
.pluck('err') enhancers: [ devTools ]
.filter(err => !!err)
.distinctUntilChanged();
errSaga(err$, toast);
}) })
// allow store subscribe to subscribe to actions .flatMap(({ props, store }) => {
.delay(10)
.flatMap(({ props, appCat }) => { // because of weirdness in react-routers match function
// we replace the wrapped returned in props with the first one
// we passed in. This might be fixed in react-router 2.0
props.history = history; props.history = history;
return render$( if (shouldRouterListenForReplays && store) {
appCat, log('routing middleware listening for replays');
React.createElement(Router, props), routingMiddleware.listenForReplays(store);
}
log('rendering');
return render(
provideStore(React.createElement(Router, props), store),
DOMContianer DOMContianer
); );
}) })
.subscribe( .subscribe(
() => { () => debug('react rendered'),
debug('react rendered'); err => { throw err; },
}, () => debug('react closed subscription')
err => {
throw err;
},
() => {
debug('react closed subscription');
}
); );

View File

@ -93,6 +93,7 @@ main = (function(main, global) {
'<span>Free Code Camp\'s Main Chat</span>' + '<span>Free Code Camp\'s Main Chat</span>' +
'</div>' '</div>'
); );
return null;
}); });
@ -233,7 +234,7 @@ $(document).ready(function() {
}; };
$('#story-submit').unbind('click'); $('#story-submit').unbind('click');
$.post('/stories/', data) return $.post('/stories/', data)
.fail(function() { .fail(function() {
$('#story-submit').bind('click', storySubmitButtonHandler); $('#story-submit').bind('click', storySubmitButtonHandler);
}) })
@ -243,6 +244,7 @@ $(document).ready(function() {
return null; return null;
} }
window.location = '/stories/' + storyLink; window.location = '/stories/' + storyLink;
return null;
}); });
}; };
@ -296,12 +298,12 @@ $(document).ready(function() {
} }
function expandBlock(item) { function expandBlock(item) {
$(item).addClass('in').css('height', '100%'); $(item).addClass('in');
expandCaret(item); expandCaret(item);
} }
function collapseBlock(item) { function collapseBlock(item) {
$(item).removeClass('in').css('height', '100%'); $(item).removeClass('in');
collapseCaret(item); collapseCaret(item);
} }

0
client/sagas/README.md Normal file
View File

20
client/sagas/err-saga.js Normal file
View File

@ -0,0 +1,20 @@
// () =>
// (store: Store) =>
// (next: (action: Action) => Object) =>
// errSaga(action: Action) => Object|Void
export default () => ({ dispatch }) => next => {
return function errorSaga(action) {
const result = next(action);
if (!action.error) { return result; }
console.error(action.error);
return dispatch({
type: 'app.makeToast',
payload: {
type: 'error',
title: 'Oops, something went wrong',
message: 'Something went wrong, please try again later'
}
});
};
};

View File

@ -0,0 +1,24 @@
import { hardGoTo } from '../../common/app/redux/types';
const loc = typeof window !== 'undefined' ?
window.location :
{};
export default () => ({ dispatch }) => next => {
return function hardGoToSaga(action) {
const result = next(action);
if (action.type !== hardGoTo) {
return result;
}
if (!loc.pathname) {
dispatch({
type: 'app.error',
error: new Error('no location object found')
});
}
loc.pathname = action.payload || '/map';
return null;
};
};

6
client/sagas/index.js Normal file
View File

@ -0,0 +1,6 @@
import errSaga from './err-saga';
import titleSaga from './title-saga';
import localStorageSaga from './local-storage-saga';
import hardGoToSaga from './hard-go-to-saga';
export default [ errSaga, titleSaga, localStorageSaga, hardGoToSaga ];

View File

@ -0,0 +1,69 @@
import {
saveForm,
clearForm,
loadSavedForm
} from '../../common/app/routes/Jobs/redux/types';
import {
saveCompleted,
loadSavedFormCompleted
} from '../../common/app/routes/Jobs/redux/actions';
const formKey = 'newJob';
let enabled = false;
let store = typeof window !== 'undefined' ?
window.localStorage :
false;
try {
const testKey = '__testKey__';
store.setItem(testKey, testKey);
enabled = store.getItem(testKey) === testKey;
store.removeItem(testKey);
} catch (e) {
enabled = !e;
}
if (!enabled) {
console.error(new Error('No localStorage found'));
}
export default () => ({ dispatch }) => next => {
return function localStorageSaga(action) {
if (!enabled) { return next(action); }
if (action.type === saveForm) {
const form = action.payload;
try {
store.setItem(formKey, JSON.stringify(form));
next(action);
return dispatch(saveCompleted(form));
} catch (error) {
return dispatch({
type: 'app.handleError',
error
});
}
}
if (action.type === clearForm) {
store.removeItem(formKey);
return null;
}
if (action.type === loadSavedForm) {
const formString = store.getItem(formKey);
try {
const form = JSON.parse(formString);
return dispatch(loadSavedFormCompleted(form));
} catch (error) {
return dispatch({
type: 'app.handleError',
error
});
}
}
return next(action);
};
};

View File

@ -0,0 +1,17 @@
// (doc: Object) =>
// () =>
// (next: (action: Action) => Object) =>
// titleSage(action: Action) => Object|Void
export default ({ doc }) => ({ getState }) => next => {
return function titleSage(action) {
// get next state
const result = next(action);
if (action.type !== 'app.updateTitle') {
return result;
}
const state = getState();
const newTitle = state.app.title;
doc.title = newTitle;
return result;
};
};

View File

@ -1,50 +1,50 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import { Row } from 'react-bootstrap'; import { Row } from 'react-bootstrap';
import { ToastMessage, ToastContainer } from 'react-toastr'; import { ToastMessage, ToastContainer } from 'react-toastr';
import { contain } from 'thundercats-react'; import { compose } from 'redux';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchUser } from './redux/actions';
import contain from './utils/professor-x';
import Nav from './components/Nav'; import Nav from './components/Nav';
const toastMessageFactory = React.createFactory(ToastMessage.animation); const toastMessageFactory = React.createFactory(ToastMessage.animation);
export default contain( const mapStateToProps = createSelector(
{ state => state.app,
actions: ['appActions'], ({
store: 'appStore', username,
fetchAction: 'appActions.getUser', points,
picture,
toast
}) => ({
username,
points,
picture,
toast
})
);
const fetchContainerOptions = {
fetchAction: 'fetchUser',
isPrimed({ username }) { isPrimed({ username }) {
return !!username; return !!username;
},
map({
username,
points,
picture,
toast
}) {
return {
username,
points,
picture,
toast
};
},
getPayload(props) {
return {
isPrimed: !!props.username
};
} }
}, };
React.createClass({
displayName: 'FreeCodeCamp',
propTypes: { // export plain class for testing
appActions: PropTypes.object, export class FreeCodeCamp extends React.Component {
static displayName = 'FreeCodeCamp';
static propTypes = {
children: PropTypes.node, children: PropTypes.node,
username: PropTypes.string, username: PropTypes.string,
points: PropTypes.number, points: PropTypes.number,
picture: PropTypes.string, picture: PropTypes.string,
toast: PropTypes.object toast: PropTypes.object
}, };
componentWillReceiveProps({ toast: nextToast = {} }) { componentWillReceiveProps({ toast: nextToast = {} }) {
const { toast = {} } = this.props; const { toast = {} } = this.props;
@ -58,15 +58,15 @@ export default contain(
} }
); );
} }
}, }
render() { render() {
const { username, points, picture } = this.props; const { username, points, picture } = this.props;
const navProps = { username, points, picture }; const navProps = { username, points, picture };
return ( return (
<div> <div>
<Nav <Nav { ...navProps }/>
{ ...navProps }/>
<Row> <Row>
{ this.props.children } { this.props.children }
</Row> </Row>
@ -77,5 +77,13 @@ export default contain(
</div> </div>
); );
} }
}) }
const wrapComponent = compose(
// connect Component to Redux Store
connect(mapStateToProps, { fetchUser }),
// handles prefetching data
contain(fetchContainerOptions)
); );
export default wrapComponent(FreeCodeCamp);

View File

@ -1,65 +0,0 @@
import { Cat } from 'thundercats';
import stamp from 'stampit';
import { Disposable, Observable } from 'rx';
import { post$, postJSON$ } from '../utils/ajax-stream.js';
import { AppActions, AppStore } from './flux';
import HikesActions from './routes/Hikes/flux';
import JobActions from './routes/Jobs/flux';
const ajaxStamp = stamp({
methods: {
postJSON$,
post$
}
});
export default Cat().init(({ instance: cat, args: [services] }) => {
const serviceStamp = stamp({
methods: {
readService$(resource, params, config) {
return Observable.create(function(observer) {
services.read(resource, params, config, (err, res) => {
if (err) {
return observer.onError(err);
}
observer.onNext(res);
observer.onCompleted();
});
return Disposable.create(function() {
observer.dispose();
});
});
},
createService$(resource, params, body, config) {
return Observable.create(function(observer) {
services.create(resource, params, body, config, (err, res) => {
if (err) {
return observer.onError(err);
}
observer.onNext(res);
observer.onCompleted();
});
return Disposable.create(function() {
observer.dispose();
});
});
}
}
});
cat.register(HikesActions.compose(serviceStamp, ajaxStamp), null, services);
cat.register(AppActions.compose(serviceStamp), null, services);
cat.register(
JobActions.compose(serviceStamp, ajaxStamp),
null,
cat,
services
);
cat.register(AppStore, null, cat);
});

View File

@ -1,17 +0,0 @@
import Rx from 'rx';
import { match } from 'react-router';
import App from './App.jsx';
import AppCat from './Cat';
import childRoutes from './routes';
const route$ = Rx.Observable.fromNodeCallback(match);
const routes = Object.assign({ components: App }, childRoutes);
export default function app$({ location, history }) {
return route$({ routes, location, history })
.map(([nextLocation, props]) => {
return { nextLocation, props, AppCat };
});
}

View File

@ -0,0 +1 @@
Currently not used

View File

@ -28,15 +28,15 @@ const toggleButtonChild = (
</Col> </Col>
); );
export default React.createClass({ export default class extends React.Component {
displayName: 'Nav', static displayName = 'Nav';
propTypes: { static propTypes = {
points: PropTypes.number, points: PropTypes.number,
picture: PropTypes.string, picture: PropTypes.string,
signedIn: PropTypes.bool, signedIn: PropTypes.bool,
username: PropTypes.string username: PropTypes.string
}, };
renderLinks() { renderLinks() {
return navLinks.map(({ content, link, react, target }, index) => { return navLinks.map(({ content, link, react, target }, index) => {
@ -63,7 +63,7 @@ export default React.createClass({
</NavItem> </NavItem>
); );
}); });
}, }
renderPoints(username, points) { renderPoints(username, points) {
if (!username) { if (!username) {
@ -76,7 +76,7 @@ export default React.createClass({
[ { points } ] [ { points } ]
</FCCNavItem> </FCCNavItem>
); );
}, }
renderSignin(username, picture) { renderSignin(username, picture) {
if (username) { if (username) {
@ -100,7 +100,7 @@ export default React.createClass({
</NavItem> </NavItem>
); );
} }
}, }
render() { render() {
const { username, points, picture } = this.props; const { username, points, picture } = this.props;
@ -124,4 +124,4 @@ export default React.createClass({
</Navbar> </Navbar>
); );
} }
}); }

View File

@ -6,17 +6,21 @@ function goToServer(path) {
win.location = '/' + path; win.location = '/' + path;
} }
export default React.createClass({ export default class extends React.Component {
displayName: 'NotFound', static displayName = 'NotFound';
propTypes: {
static propTypes = {
params: PropTypes.object params: PropTypes.object
}, };
componentWillMount() { componentWillMount() {
goToServer(this.props.params.splat); goToServer(this.props.params.splat);
}, }
componentDidMount() { componentDidMount() {
}, }
render() { render() {
return <span></span>; return <span></span>;
} }
}); }

79
common/app/create-app.jsx Normal file
View File

@ -0,0 +1,79 @@
import { Observable } from 'rx';
import { match } from 'react-router';
import { compose, createStore, applyMiddleware } from 'redux';
// main app
import App from './App.jsx';
// app routes
import childRoutes from './routes';
// redux
import createReducer from './create-reducer';
import middlewares from './middlewares';
import sagas from './sagas';
// general utils
import servicesCreator from '../utils/services-creator';
const createRouteProps = Observable.fromNodeCallback(match);
const routes = { components: App, ...childRoutes };
//
// createApp(settings: {
// location?: Location|String,
// history?: History,
// initialState?: Object|Void,
// serviceOptions?: Object,
// middlewares?: Function[],
// sideReducers?: Object
// enhancers?: Function[],
// sagas?: Function[],
// }) => Observable
//
// Either location or history must be defined
export default function createApp({
location,
history,
initialState,
serviceOptions = {},
middlewares: sideMiddlewares = [],
enhancers: sideEnhancers = [],
reducers: sideReducers = {},
sagas: sideSagas = []
}) {
const sagaOptions = {
services: servicesCreator(null, serviceOptions)
};
const enhancers = [
applyMiddleware(
...middlewares,
...sideMiddlewares,
...[ ...sagas, ...sideSagas].map(saga => saga(sagaOptions)),
),
// enhancers must come after middlewares
// on client side these are things like Redux DevTools
...sideEnhancers
];
const reducer = createReducer(sideReducers);
// create composed store enhancer
// use store enhancer function to enhance `createStore` function
// call enhanced createStore function with reducer and initialState
// to create store
const store = compose(...enhancers)(createStore)(reducer, initialState);
// createRouteProps({
// redirect: LocationDescriptor,
// history: History,
// routes: Object
// }) => Observable
return createRouteProps({ routes, location, history })
.map(([ redirect, props ]) => ({
redirect,
props,
reducer,
store
}));
}

View File

@ -0,0 +1,19 @@
import { combineReducers } from 'redux';
import { reducer as formReducer } from 'redux-form';
import { reducer as app } from './redux';
import { reducer as hikesApp } from './routes/Hikes/redux';
import {
reducer as jobsApp,
formNormalizer as jobsNormalizer
} from './routes/Jobs/redux';
export default function createReducer(sideReducers = {}) {
return combineReducers({
...sideReducers,
app,
hikesApp,
jobsApp,
form: formReducer.normalize(jobsNormalizer)
});
}

View File

@ -1,103 +0,0 @@
import { Store } from 'thundercats';
const { createRegistrar, setter, fromMany } = Store;
const initValue = {
title: 'Learn To Code | Free Code Camp',
username: null,
picture: null,
points: 0,
hikesApp: {
hikes: [],
// lecture state
currentHike: {},
showQuestions: false
},
jobsApp: {
showModal: false
}
};
export default Store({
refs: {
displayName: 'AppStore',
value: initValue
},
init({ instance: store, args: [cat] }) {
const register = createRegistrar(store);
// app
const {
updateLocation,
getUser,
setTitle,
toast
} = cat.getActions('appActions');
register(
fromMany(
setter(
fromMany(
getUser,
setTitle
)
),
updateLocation,
toast
)
);
// hikes
const {
toggleQuestions,
fetchHikes,
resetHike,
grabQuestion,
releaseQuestion,
moveQuestion,
answer
} = cat.getActions('hikesActions');
register(
fromMany(
toggleQuestions,
fetchHikes,
resetHike,
grabQuestion,
releaseQuestion,
moveQuestion,
answer
)
);
// jobs
const {
findJob,
saveJobToDb,
getJob,
getJobs,
openModal,
closeModal,
handleForm,
getSavedForm,
setPromoCode,
applyCode,
clearPromo
} = cat.getActions('JobActions');
register(
fromMany(
findJob,
saveJobToDb,
getJob,
getJobs,
openModal,
closeModal,
handleForm,
getSavedForm,
setPromoCode,
applyCode,
clearPromo
)
);
}
});

View File

@ -1,2 +0,0 @@
export AppActions from './Actions';
export AppStore from './Store';

View File

@ -1 +1 @@
export default from './app-stream.jsx'; export default from './create-app.jsx';

View File

View File

@ -0,0 +1,11 @@
/* eslint-disable react/display-name */
import React from 'react';
import { Provider } from 'react-redux';
export default function provideStore(element, store) {
return React.createElement(
Provider,
{ store },
element
);
}

View File

@ -0,0 +1,32 @@
import { createAction } from 'redux-actions';
import types from './types';
// updateTitle(title: String) => Action
export const updateTitle = createAction(types.updateTitle);
let id = 0;
// makeToast({ type?: String, message: String, title: String }) => Action
export const makeToast = createAction(
types.makeToast,
toast => {
id += 1;
return {
...toast,
id,
type: toast.type || 'info'
};
}
);
// fetchUser() => Action
// used in combination with fetch-user-saga
export const fetchUser = createAction(types.fetchUser);
// setUser(userInfo: Object) => Action
export const setUser = createAction(types.setUser);
// updatePoints(points: Number) => Action
export const updatePoints = createAction(types.updatePoints);
// hardGoTo(path: String) => Action
export const hardGoTo = createAction(types.hardGoTo);

View File

@ -0,0 +1,39 @@
import { Observable } from 'rx';
import { handleError, setUser, fetchUser } from './types';
export default ({ services }) => ({ dispatch }) => next => {
return function getUserSaga(action) {
if (action.type !== fetchUser) {
return next(action);
}
return services.readService$({ service: 'user' })
.map(({
username,
picture,
progressTimestamps = [],
isFrontEndCert,
isBackEndCert,
isFullStackCert
}) => {
return {
type: setUser,
payload: {
username,
picture,
points: progressTimestamps.length,
isFrontEndCert,
isBackEndCert,
isFullStackCert,
isSignedIn: true
}
};
})
.catch(error => Observable.just({
type: handleError,
error
}))
.doOnNext(dispatch);
};
};

View File

@ -0,0 +1,6 @@
export { default as reducer } from './reducer';
export { default as actions } from './actions';
export { default as types } from './types';
import fetchUserSaga from './fetch-user-saga';
export const sagas = [ fetchUserSaga ];

View File

@ -0,0 +1,35 @@
import { handleActions } from 'redux-actions';
import types from './types';
export default handleActions(
{
[types.updateTitle]: (state, { payload = 'Learn To Code' }) => ({
...state,
title: payload + ' | Free Code Camp'
}),
[types.makeToast]: (state, { payload: toast }) => ({
...state,
toast
}),
[types.setUser]: (state, { payload: user }) => ({ ...state, ...user }),
[types.challengeSaved]: (state, { payload: { points = 0 } }) => ({
...state,
points
}),
[types.updatePoints]: (state, { payload: points }) => ({
...state,
points
})
},
{
title: 'Learn To Code | Free Code Camp',
username: null,
picture: null,
points: 0,
isSignedIn: false
}
);

14
common/app/redux/types.js Normal file
View File

@ -0,0 +1,14 @@
import createTypes from '../utils/create-types';
export default createTypes([
'updateTitle',
'fetchUser',
'setUser',
'makeToast',
'updatePoints',
'handleError',
// used to hit the server
'hardGoTo'
], 'app');

View File

@ -1 +0,0 @@
future home of FAVS app

View File

@ -1,45 +1,56 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import { contain } from 'thundercats-react'; import { connect } from 'react-redux';
import { Col, Row } from 'react-bootstrap'; import { Col, Row } from 'react-bootstrap';
import { createSelector } from 'reselect';
import Lecture from './Lecture.jsx'; import Lecture from './Lecture.jsx';
import Questions from './Questions.jsx'; import Questions from './Questions.jsx';
import { resetHike } from '../redux/actions';
import { getCurrentHike } from '../redux/selectors';
export default contain( const mapStateToProps = createSelector(
{ getCurrentHike,
actions: ['hikesActions'] state => state.hikesApp.shouldShowQuestions,
}, (currentHike, shouldShowQuestions) => ({
React.createClass({ title: currentHike ? currentHike.title : '',
displayName: 'Hike', shouldShowQuestions
})
);
propTypes: { // export plain component for testing
currentHike: PropTypes.object, export class Hike extends React.Component {
hikesActions: PropTypes.object, static displayName = 'Hike';
static propTypes = {
// actions
resetHike: PropTypes.func,
// ui
title: PropTypes.string,
params: PropTypes.object, params: PropTypes.object,
showQuestions: PropTypes.bool shouldShowQuestions: PropTypes.bool
}, };
componentWillUnmount() { componentWillUnmount() {
this.props.hikesActions.resetHike(); this.props.resetHike();
}, }
componentWillReceiveProps({ params: { dashedName } }) { componentWillReceiveProps({ params: { dashedName } }) {
if (this.props.params.dashedName !== dashedName) { if (this.props.params.dashedName !== dashedName) {
this.props.hikesActions.resetHike(); this.props.resetHike();
}
} }
},
renderBody(showQuestions) { renderBody(showQuestions) {
if (showQuestions) { if (showQuestions) {
return <Questions />; return <Questions />;
} }
return <Lecture />; return <Lecture />;
}, }
render() { render() {
const { const {
currentHike: { title } = {}, title,
showQuestions shouldShowQuestions
} = this.props; } = this.props;
return ( return (
@ -53,11 +64,13 @@ export default contain(
<section <section
className={ 'text-center' } className={ 'text-center' }
title={ title }> title={ title }>
{ this.renderBody(showQuestions) } { this.renderBody(shouldShowQuestions) }
</section> </section>
</Row> </Row>
</Col> </Col>
); );
} }
}) }
);
// export redux aware component
export default connect(mapStateToProps, { resetHike })(Hike);

View File

@ -1,68 +1,71 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import { compose } from 'redux';
import { connect } from 'react-redux';
import { Row } from 'react-bootstrap'; import { Row } from 'react-bootstrap';
import { contain } from 'thundercats-react'; import PureComponent from 'react-pure-render/component';
// import debugFactory from 'debug'; import { createSelector } from 'reselect';
// import debug from 'debug';
import HikesMap from './Map.jsx'; import HikesMap from './Map.jsx';
import { updateTitle } from '../../../redux/actions';
import { fetchHikes } from '../redux/actions';
// const debug = debugFactory('freecc:hikes'); import contain from '../../../utils/professor-x';
export default contain( // const log = debug('fcc:hikes');
{
store: 'appStore', const mapStateToProps = createSelector(
map(state) { state => state.hikesApp.hikes.entities,
return state.hikesApp; state => state.hikesApp.hikes.results,
}, (hikesMap, hikesByDashedName)=> {
actions: ['appActions'], if (!hikesMap || !hikesByDashedName) {
fetchAction: 'hikesActions.fetchHikes', return { hikes: [] };
getPayload: ({ hikes, params }) => ({ }
isPrimed: (hikes && !!hikes.length), return {
dashedName: params.dashedName hikes: hikesByDashedName.map(dashedName => hikesMap[dashedName])
}), };
}
);
const fetchOptions = {
fetchAction: 'fetchHikes',
isPrimed: ({ hikes }) => hikes && !!hikes.length,
getActionArgs: ({ params: { dashedName } }) => [ dashedName ],
shouldContainerFetch(props, nextProps) { shouldContainerFetch(props, nextProps) {
return props.params.dashedName !== nextProps.params.dashedName; return props.params.dashedName !== nextProps.params.dashedName;
} }
}, };
React.createClass({
displayName: 'Hikes',
propTypes: { export class Hikes extends PureComponent {
appActions: PropTypes.object, static displayName = 'Hikes';
static propTypes = {
children: PropTypes.element, children: PropTypes.element,
currentHike: PropTypes.object,
hikes: PropTypes.array, hikes: PropTypes.array,
params: PropTypes.object, params: PropTypes.object,
showQuestions: PropTypes.bool updateTitle: PropTypes.func
}, };
componentWillMount() { componentWillMount() {
const { appActions } = this.props; const { updateTitle } = this.props;
appActions.setTitle('Videos'); updateTitle('Hikes');
}, }
renderMap(hikes) { renderMap(hikes) {
return ( return (
<HikesMap hikes={ hikes }/> <HikesMap hikes={ hikes }/>
); );
},
renderChild({ children, ...props }) {
if (!children) {
return null;
} }
return React.cloneElement(children, props);
},
render() { render() {
const { hikes } = this.props; const { hikes } = this.props;
const { dashedName } = this.props.params;
const preventOverflow = { overflow: 'hidden' }; const preventOverflow = { overflow: 'hidden' };
return ( return (
<div> <div>
<Row style={ preventOverflow }> <Row style={ preventOverflow }>
{ {
// render sub-route // render sub-route
this.renderChild({ ...this.props, dashedName }) || this.props.children ||
// if no sub-route render hikes map // if no sub-route render hikes map
this.renderMap(hikes) this.renderMap(hikes)
} }
@ -70,5 +73,10 @@ export default contain(
</div> </div>
); );
} }
}) }
);
// export redux and fetch aware component
export default compose(
connect(mapStateToProps, { fetchHikes, updateTitle }),
contain(fetchOptions)
)(Hikes);

View File

@ -1,54 +1,57 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import { contain } from 'thundercats-react'; import { connect } from 'react-redux';
import { Button, Col, Row } from 'react-bootstrap'; import { Button, Col, Row } from 'react-bootstrap';
import { History } from 'react-router';
import Vimeo from 'react-vimeo'; import Vimeo from 'react-vimeo';
import debugFactory from 'debug'; import { createSelector } from 'reselect';
import debug from 'debug';
const debug = debugFactory('freecc:hikes'); import { hardGoTo } from '../../../redux/actions';
import { toggleQuestionView } from '../redux/actions';
import { getCurrentHike } from '../redux/selectors';
export default contain( const log = debug('fcc:hikes');
{
actions: ['hikesActions'], const mapStateToProps = createSelector(
store: 'appStore', getCurrentHike,
map(state) { (currentHike) => {
const { const {
currentHike: {
dashedName, dashedName,
description, description,
challengeSeed: [id] = [0] challengeSeed: [id] = [0]
} = {} } = currentHike;
} = state.hikesApp;
return { return {
id,
dashedName, dashedName,
description, description
id
}; };
} }
}, );
React.createClass({
displayName: 'Lecture',
mixins: [History],
propTypes: { export class Lecture extends React.Component {
dashedName: PropTypes.string, static displayName = 'Lecture';
static propTypes = {
// actions
toggleQuestionView: PropTypes.func,
// ui
id: PropTypes.number,
description: PropTypes.array, description: PropTypes.array,
id: PropTypes.string, dashedName: PropTypes.string,
hikesActions: PropTypes.object hardGoTo: PropTypes.func
}, };
componentWillMount() {
if (!this.props.id) {
this.props.hardGoTo('/map');
}
}
shouldComponentUpdate(nextProps) { shouldComponentUpdate(nextProps) {
const { props } = this; const { props } = this;
return nextProps.id !== props.id; return nextProps.id !== props.id;
}, }
handleError: debug, handleError: log;
handleFinish(hikesActions) {
debug('loading questions');
hikesActions.toggleQuestions();
},
renderTranscript(transcript, dashedName) { renderTranscript(transcript, dashedName) {
return transcript.map((line, index) => ( return transcript.map((line, index) => (
@ -58,14 +61,15 @@ export default contain(
{ line } { line }
</p> </p>
)); ));
}, }
render() { render() {
const { const {
id = '1', id = '1',
description = [], description = [],
hikesActions toggleQuestionView
} = this.props; } = this.props;
const dashedName = 'foo'; const dashedName = 'foo';
return ( return (
@ -73,8 +77,8 @@ export default contain(
<Row> <Row>
<Vimeo <Vimeo
onError={ this.handleError } onError={ this.handleError }
onFinish= { () => this.handleFinish(hikesActions) } onFinish= { toggleQuestionView }
videoId={ id } /> videoId={ '' + id } />
</Row> </Row>
<Row> <Row>
<article> <article>
@ -84,12 +88,16 @@ export default contain(
block={ true } block={ true }
bsSize='large' bsSize='large'
bsStyle='primary' bsStyle='primary'
onClick={ () => this.handleFinish(hikesActions) }> onClick={ toggleQuestionView }>
Take me to the Questions Take me to the Questions
</Button> </Button>
</Row> </Row>
</Col> </Col>
); );
} }
}) }
);
export default connect(
mapStateToProps,
{ hardGoTo, toggleQuestionView }
)(Lecture);

View File

@ -17,7 +17,7 @@ export default React.createClass({
const vidElements = hikes.map(({ title, dashedName }) => { const vidElements = hikes.map(({ title, dashedName }) => {
return ( return (
<ListGroupItem key={ dashedName }> <ListGroupItem key={ dashedName }>
<Link to={ `/hikes/${dashedName}` }> <Link to={ `/videos/${dashedName}` }>
<h3>{ title }</h3> <h3>{ title }</h3>
</Link> </Link>
</ListGroupItem> </ListGroupItem>

View File

@ -1,50 +1,86 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import { spring, Motion } from 'react-motion'; import { spring, Motion } from 'react-motion';
import { contain } from 'thundercats-react'; import { connect } from 'react-redux';
import { Button, Col, Row } from 'react-bootstrap'; import { Button, Col, Row } from 'react-bootstrap';
import { CompositeDisposable } from 'rx';
import { createSelector } from 'reselect';
import {
answerQuestion,
moveQuestion,
releaseQuestion,
grabQuestion
} from '../redux/actions';
import { getCurrentHike } from '../redux/selectors';
const answerThreshold = 100; const answerThreshold = 100;
const springProperties = { stiffness: 120, damping: 10 };
const actionsToBind = {
answerQuestion,
moveQuestion,
releaseQuestion,
grabQuestion
};
export default contain( const mapStateToProps = createSelector(
{ getCurrentHike,
store: 'appStore', state => state.hikesApp,
actions: ['hikesActions'], state => state.app.isSignedIn,
map({ hikesApp, username }) { (currentHike, ui, isSignedIn) => {
const { const {
currentHike,
currentQuestion = 1, currentQuestion = 1,
mouse = [ 0, 0 ], mouse = [ 0, 0 ],
isCorrect = false,
delta = [ 0, 0 ], delta = [ 0, 0 ],
isCorrect = false,
isPressed = false, isPressed = false,
shake = false shouldShakeQuestion = false
} = hikesApp; } = ui;
const {
tests = []
} = currentHike;
return { return {
hike: currentHike, tests,
currentQuestion, currentQuestion,
mouse,
isCorrect, isCorrect,
mouse,
delta, delta,
isPressed, isPressed,
shake, shouldShakeQuestion,
isSignedIn: !!username isSignedIn
}; };
} }
}, );
React.createClass({
displayName: 'Questions',
propTypes: { class Question extends React.Component {
hike: PropTypes.object, constructor(...args) {
currentQuestion: PropTypes.number, super(...args);
this._subscriptions = new CompositeDisposable();
}
static displayName = 'Questions';
static propTypes = {
// actions
answerQuestion: PropTypes.func,
releaseQuestion: PropTypes.func,
moveQuestion: PropTypes.func,
grabQuestion: PropTypes.func,
// ui state
tests: PropTypes.array,
mouse: PropTypes.array, mouse: PropTypes.array,
isCorrect: PropTypes.bool,
delta: PropTypes.array, delta: PropTypes.array,
isCorrect: PropTypes.bool,
isPressed: PropTypes.bool, isPressed: PropTypes.bool,
shake: PropTypes.bool,
isSignedIn: PropTypes.bool, isSignedIn: PropTypes.bool,
hikesActions: PropTypes.object currentQuestion: PropTypes.number,
}, shouldShakeQuestion: PropTypes.bool
};
componentWillUnmount() {
this._subscriptions.dispose();
}
handleMouseUp(e, answer, info) { handleMouseUp(e, answer, info) {
e.stopPropagation(); e.stopPropagation();
@ -53,54 +89,52 @@ export default contain(
} }
const { const {
hike, releaseQuestion,
currentQuestion, answerQuestion
isSignedIn,
delta
} = this.props; } = this.props;
this.props.hikesActions.releaseQuestion(); releaseQuestion();
this.props.hikesActions.answer({ const subscription = answerQuestion({
e, e,
answer, answer,
hike,
delta,
currentQuestion,
isSignedIn,
info, info,
threshold: answerThreshold threshold: answerThreshold
}); })
}, .subscribe();
handleMouseMove(e) { this._subscriptions.add(subscription);
if (!this.props.isPressed) {
return null; return null;
} }
const { delta, hikesActions } = this.props;
hikesActions.moveQuestion({ e, delta }); handleMouseMove(isPressed, { delta, moveQuestion }) {
}, if (!isPressed) {
return null;
}
return e => moveQuestion({ e, delta });
}
onAnswer(answer, userAnswer, info) { onAnswer(answer, userAnswer, info) {
const { isSignedIn, hike, currentQuestion, hikesActions } = this.props; const { isSignedIn, answerQuestion } = this.props;
return (e) => { const subscriptions = this._subscriptions;
return e => {
if (e && e.preventDefault) { if (e && e.preventDefault) {
e.preventDefault(); e.preventDefault();
} }
return hikesActions.answer({ const subscription = answerQuestion({
answer, answer,
userAnswer, userAnswer,
currentQuestion,
hike,
info, info,
isSignedIn isSignedIn
}); })
}; .subscribe();
},
renderQuestion(number, question, answer, shake, info) { subscriptions.add(subscription);
const { hikesActions } = this.props; };
}
renderQuestion(number, question, answer, shouldShakeQuestion, info) {
const { grabQuestion, isPressed } = this.props;
const mouseUp = e => this.handleMouseUp(e, answer, info); const mouseUp = e => this.handleMouseUp(e, answer, info);
return ({ x }) => { return ({ x }) => {
const style = { const style = {
@ -109,28 +143,28 @@ export default contain(
}; };
return ( return (
<article <article
className={ shake ? 'animated swing shake' : '' } className={ shouldShakeQuestion ? 'animated swing shake' : '' }
onMouseDown={ hikesActions.grabQuestion } onMouseDown={ grabQuestion }
onMouseLeave={ mouseUp } onMouseLeave={ mouseUp }
onMouseMove={ this.handleMouseMove } onMouseMove={ this.handleMouseMove(isPressed, this.props) }
onMouseUp={ mouseUp } onMouseUp={ mouseUp }
onTouchEnd={ mouseUp } onTouchEnd={ mouseUp }
onTouchMove={ this.handleMouseMove } onTouchMove={ this.handleMouseMove(isPressed, this.props) }
onTouchStart={ hikesActions.grabQuestion } onTouchStart={ grabQuestion }
style={ style }> style={ style }>
<h4>Question { number }</h4> <h4>Question { number }</h4>
<p>{ question }</p> <p>{ question }</p>
</article> </article>
); );
}; };
}, }
render() { render() {
const { const {
hike: { tests = [] } = {}, tests = [],
mouse: [x], mouse: [xPosition],
currentQuestion, currentQuestion,
shake shouldShakeQuestion
} = this.props; } = this.props;
const [ question, answer, info ] = tests[currentQuestion - 1] || []; const [ question, answer, info ] = tests[currentQuestion - 1] || [];
@ -138,7 +172,7 @@ export default contain(
currentQuestion, currentQuestion,
question, question,
answer, answer,
shake, shouldShakeQuestion,
info info
); );
@ -148,7 +182,7 @@ export default contain(
xs={ 8 } xs={ 8 }
xsOffset={ 2 }> xsOffset={ 2 }>
<Row> <Row>
<Motion style={{ x: spring(x, { stiffness: 120, damping: 10 }) }}> <Motion style={{ x: spring(xPosition, springProperties) }}>
{ questionElement } { questionElement }
</Motion> </Motion>
<div className='spacer' /> <div className='spacer' />
@ -173,5 +207,6 @@ export default contain(
</Col> </Col>
); );
} }
}) }
);
export default connect(mapStateToProps, actionsToBind)(Question);

View File

@ -1 +0,0 @@
export default from './Actions';

View File

@ -1,11 +1,6 @@
import Hikes from './components/Hikes.jsx'; import Hikes from './components/Hikes.jsx';
import Hike from './components/Hike.jsx'; import Hike from './components/Hike.jsx';
/*
* show video /hikes/someVideo
* show question /hikes/someVideo/question1
*/
export default { export default {
path: 'videos', path: 'videos',
component: Hikes, component: Hikes,

View File

@ -0,0 +1,57 @@
import { createAction } from 'redux-actions';
import types from './types';
import { getMouse } from './utils';
// fetchHikes(dashedName?: String) => Action
// used with fetchHikesSaga
export const fetchHikes = createAction(types.fetchHikes);
// fetchHikesCompleted(hikes: Object) => Action
// hikes is a normalized response from server
// called within fetchHikesSaga
export const fetchHikesCompleted = createAction(
types.fetchHikesCompleted,
(hikes, currentHike) => ({ hikes, currentHike })
);
export const resetHike = createAction(types.resetHike);
export const toggleQuestionView = createAction(types.toggleQuestionView);
export const grabQuestion = createAction(types.grabQuestion, e => {
let { pageX, pageY, touches } = e;
if (touches) {
e.preventDefault();
// these re-assigns the values of pageX, pageY from touches
({ pageX, pageY } = touches[0]);
}
const delta = [pageX, pageY];
const mouse = [0, 0];
return { delta, mouse };
});
export const releaseQuestion = createAction(types.releaseQuestion);
export const moveQuestion = createAction(
types.moveQuestion,
({ e, delta }) => getMouse(e, delta)
);
// answer({
// e: Event,
// answer: Boolean,
// userAnswer: Boolean,
// info: String,
// threshold: Number
// }) => Action
export const answerQuestion = createAction(types.answerQuestion);
export const startShake = createAction(types.startShake);
export const endShake = createAction(types.primeNextQuestion);
export const goToNextQuestion = createAction(types.goToNextQuestion);
export const hikeCompleted = createAction(types.hikeCompleted);
export const goToNextHike = createAction(types.goToNextHike);

View File

@ -0,0 +1,146 @@
import { Observable } from 'rx';
import { push } from 'react-router-redux';
import types from './types';
import { getMouse } from './utils';
import { makeToast, updatePoints } from '../../../redux/actions';
import { hikeCompleted, goToNextHike } from './actions';
import { postJSON$ } from '../../../../utils/ajax-stream';
import { getCurrentHike } from './selectors';
function handleAnswer(getState, dispatch, next, action) {
const {
e,
answer,
userAnswer,
info,
threshold
} = action.payload;
const state = getState();
const { id, name, challengeType, tests } = getCurrentHike(state);
const {
app: { isSignedIn },
hikesApp: {
currentQuestion,
delta = [ 0, 0 ]
}
} = state;
let finalAnswer;
// drag answer, compute response
if (typeof userAnswer === 'undefined') {
const [positionX] = getMouse(e, delta);
// question released under threshold
if (Math.abs(positionX) < threshold) {
return next(action);
}
if (positionX >= threshold) {
finalAnswer = true;
}
if (positionX <= -threshold) {
finalAnswer = false;
}
} else {
finalAnswer = userAnswer;
}
// incorrect question
if (answer !== finalAnswer) {
if (info) {
dispatch(makeToast({
title: 'Hint',
message: info,
type: 'info'
}));
}
return Observable
.just({ type: types.endShake })
.delay(500)
.startWith({ type: types.startShake })
.doOnNext(dispatch);
}
if (tests[currentQuestion]) {
return Observable
.just({ type: types.goToNextQuestion })
.delay(300)
.startWith({ type: types.primeNextQuestion })
.doOnNext(dispatch);
}
let updateUser$;
if (isSignedIn) {
const body = { id, name, challengeType: +challengeType };
updateUser$ = postJSON$('/completed-challenge', body)
// if post fails, will retry once
.retry(3)
.flatMap(({ alreadyCompleted, points }) => {
return Observable.of(
makeToast({
message:
'Challenge saved.' +
(alreadyCompleted ? '' : ' First time Completed!'),
title: 'Saved',
type: 'info'
}),
updatePoints(points),
);
})
.catch(error => {
return Observable.just({
type: 'app.error',
error
});
});
} else {
updateUser$ = Observable.empty();
}
const challengeCompleted$ = Observable.of(
goToNextHike(),
makeToast({
title: 'Congratulations!',
message: 'Hike completed.' + (isSignedIn ? ' Saving...' : ''),
type: 'success'
})
);
return Observable.merge(challengeCompleted$, updateUser$)
.delay(300)
.startWith(hikeCompleted(finalAnswer))
.catch(error => Observable.just({
type: 'error',
error
}))
// end with action so we know it is ok to transition
.doOnCompleted(() => dispatch({ type: types.transitionHike }))
.doOnNext(dispatch);
}
export default () => ({ getState, dispatch }) => next => {
return function answerSaga(action) {
if (action.type === types.answerQuestion) {
return handleAnswer(getState, dispatch, next, action);
}
// let goToNextQuestion hit reducers first
const result = next(action);
if (action.type === types.transitionHike) {
const { hikesApp: { currentHike } } = getState();
// if no next hike currentHike will equal '' which is falsy
if (currentHike) {
dispatch(push(`/videos/${currentHike}`));
} else {
dispatch(push('/map'));
}
}
return result;
};
};

View File

@ -0,0 +1,45 @@
import { Observable } from 'rx';
import { normalize, Schema, arrayOf } from 'normalizr';
// import debug from 'debug';
import types from './types';
import { fetchHikesCompleted } from './actions';
import { handleError } from '../../../redux/types';
import { findCurrentHike } from './utils';
// const log = debug('fcc:fetch-hikes-saga');
const hike = new Schema('hike', { idAttribute: 'dashedName' });
export default ({ services }) => ({ dispatch }) => next => {
return function fetchHikesSaga(action) {
if (action.type !== types.fetchHikes) {
return next(action);
}
const dashedName = action.payload;
return services.readService$({ service: 'hikes' })
.map(hikes => {
const { entities, result } = normalize(
{ hikes },
{ hikes: arrayOf(hike) }
);
hikes = {
entities: entities.hike,
results: result.hikes
};
const currentHike = findCurrentHike(hikes, dashedName);
return fetchHikesCompleted(hikes, currentHike);
})
.catch(error => {
return Observable.just({
type: handleError,
error
});
})
.doOnNext(dispatch);
};
};

View File

@ -0,0 +1,8 @@
export actions from './actions';
export reducer from './reducer';
export types from './types';
import answerSaga from './answer-saga';
import fetchHikesSaga from './fetch-hikes-saga';
export const sagas = [ answerSaga, fetchHikesSaga ];

View File

@ -3,7 +3,7 @@ import { Observable } from 'rx';
import { Actions } from 'thundercats'; import { Actions } from 'thundercats';
import debugFactory from 'debug'; import debugFactory from 'debug';
const debug = debugFactory('freecc:hikes:actions'); const debug = debugFactory('fcc:hikes:actions');
const noOp = { transform: () => {} }; const noOp = { transform: () => {} };
function getCurrentHike(hikes = [{}], dashedName, currentHike) { function getCurrentHike(hikes = [{}], dashedName, currentHike) {

View File

@ -0,0 +1,99 @@
import { handleActions } from 'redux-actions';
import types from './types';
import { findNextHikeName } from './utils';
const initialState = {
hikes: {
results: [],
entities: {}
},
// ui
// hike dashedName
currentHike: '',
// 1 indexed
currentQuestion: 1,
// [ xPosition, yPosition ]
mouse: [ 0, 0 ],
// change in mouse position since pressed
// [ xDelta, yDelta ]
delta: [ 0, 0 ],
isPressed: false,
isCorrect: false,
shouldShakeQuestion: false,
shouldShowQuestions: false
};
export default handleActions(
{
[types.toggleQuestionView]: state => ({
...state,
shouldShowQuestions: !state.shouldShowQuestions,
currentQuestion: 1
}),
[types.grabQuestion]: (state, { payload: { delta, mouse } }) => ({
...state,
isPressed: true,
delta,
mouse
}),
[types.releaseQuestion]: state => ({
...state,
isPressed: false,
mouse: [ 0, 0 ]
}),
[types.moveQuestion]: (state, { payload: mouse }) => ({ ...state, mouse }),
[types.resetHike]: state => ({
...state,
currentQuestion: 1,
shouldShowQuestions: false,
mouse: [0, 0],
delta: [0, 0]
}),
[types.startShake]: state => ({ ...state, shouldShakeQuestion: true }),
[types.endShake]: state => ({ ...state, shouldShakeQuestion: false }),
[types.primeNextQuestion]: (state, { payload: userAnswer }) => ({
...state,
currentQuestion: state.currentQuestion + 1,
mouse: [ userAnswer ? 1000 : -1000, 0],
isPressed: false
}),
[types.goToNextQuestion]: state => ({
...state,
mouse: [ 0, 0 ]
}),
[types.hikeCompleted]: (state, { payload: userAnswer } ) => ({
...state,
isCorrect: true,
isPressed: false,
delta: [ 0, 0 ],
mouse: [ userAnswer ? 1000 : -1000, 0]
}),
[types.goToNextHike]: state => ({
...state,
currentHike: findNextHikeName(state.hikes, state.currentHike),
showQuestions: false,
currentQuestion: 1,
mouse: [ 0, 0 ]
}),
[types.fetchHikesCompleted]: (state, { payload }) => {
const { hikes, currentHike } = payload;
return {
...state,
hikes,
currentHike
};
}
},
initialState
);

View File

@ -0,0 +1,8 @@
// use this file for common selectors
import { createSelector } from 'reselect';
export const getCurrentHike = createSelector(
state => state.hikesApp.hikes.entities,
state => state.hikesApp.currentHike,
(hikesMap, currentHikeDashedName) => (hikesMap[currentHikeDashedName] || {})
);

View File

@ -0,0 +1,24 @@
import createTypes from '../../../utils/create-types';
export default createTypes([
'fetchHikes',
'fetchHikesCompleted',
'resetHike',
'toggleQuestionView',
'grabQuestion',
'releaseQuestion',
'moveQuestion',
'answerQuestion',
'startShake',
'endShake',
'primeNextQuestion',
'goToNextQuestion',
'transitionHike',
'hikeCompleted',
'goToNextHike'
], 'videos');

View File

@ -0,0 +1,77 @@
import debug from 'debug';
import _ from 'lodash';
const log = debug('fcc:hikes:utils');
function getFirstHike(hikes) {
return hikes.results[0];
}
// interface Hikes {
// results: String[],
// entities: {
// hikeId: Challenge
// }
// }
//
// findCurrentHike({
// hikes: Hikes,
// dashedName: String
// }) => String
export function findCurrentHike(hikes, dashedName) {
if (!dashedName) {
return getFirstHike(hikes) || {};
}
const filterRegex = new RegExp(dashedName, 'i');
return hikes
.results
.filter(dashedName => {
return filterRegex.test(dashedName);
})
.reduce((throwAway, hike) => {
return hike;
}, '');
}
export function getCurrentHike(hikes = {}, dashedName) {
if (!dashedName) {
return getFirstHike(hikes) || {};
}
return hikes.entities[dashedName];
}
// findNextHikeName(
// hikes: { results: String[] },
// dashedName: String
// ) => String
export function findNextHikeName({ results }, dashedName) {
if (!dashedName) {
log('find next hike no id provided');
return results[0];
}
const currentIndex = _.findIndex(
results,
_dashedName => _dashedName === dashedName
);
if (currentIndex >= results.length) {
return '';
}
return results[currentIndex + 1];
}
export function getMouse(e, [dx, dy]) {
let { pageX, pageY, touches, changedTouches } = e;
// touches can be empty on touchend
if (touches || changedTouches) {
e.preventDefault();
// these re-assigns the values of pageX, pageY from touches
({ pageX, pageY } = touches[0] || changedTouches[0]);
}
return [pageX - dx, pageY - dy];
}

View File

@ -1,277 +0,0 @@
import React, { PropTypes } from 'react';
import { Button, Input, Col, Row, Well } from 'react-bootstrap';
import { contain } from 'thundercats-react';
// real paypal buttons
// will take your money
const paypalIds = {
regular: 'Q8Z82ZLAX3Q8N',
highlighted: 'VC8QPSKCYMZLN'
};
export default contain(
{
store: 'appStore',
actions: [
'jobActions',
'appActions'
],
map({ jobsApp: {
currentJob: { id, isHighlighted } = {},
buttonId = isHighlighted ?
paypalIds.highlighted :
paypalIds.regular,
price = 1000,
discountAmount = 0,
promoCode = '',
promoApplied = false,
promoName = ''
}}) {
return {
id,
isHighlighted,
buttonId,
price,
discountAmount,
promoName,
promoCode,
promoApplied
};
}
},
React.createClass({
displayName: 'GoToPayPal',
propTypes: {
appActions: PropTypes.object,
id: PropTypes.string,
isHighlighted: PropTypes.bool,
buttonId: PropTypes.string,
price: PropTypes.number,
discountAmount: PropTypes.number,
promoName: PropTypes.string,
promoCode: PropTypes.string,
promoApplied: PropTypes.bool,
jobActions: PropTypes.object
},
componentDidMount() {
const { jobActions } = this.props;
jobActions.clearPromo();
},
goToJobBoard() {
const { appActions } = this.props;
setTimeout(() => appActions.goTo('/jobs'), 0);
},
renderDiscount(discountAmount) {
if (!discountAmount) {
return null;
}
return (
<Row>
<Col
md={ 3 }
mdOffset={ 3 }>
<h4>Promo Discount</h4>
</Col>
<Col
md={ 3 }>
<h4>-{ discountAmount }</h4>
</Col>
</Row>
);
},
renderHighlightPrice(isHighlighted) {
if (!isHighlighted) {
return null;
}
return (
<Row>
<Col
md={ 3 }
mdOffset={ 3 }>
<h4>Highlighting</h4>
</Col>
<Col
md={ 3 }>
<h4>+ 250</h4>
</Col>
</Row>
);
},
renderPromo() {
const {
id,
promoApplied,
promoCode,
promoName,
isHighlighted,
jobActions
} = this.props;
if (promoApplied) {
return (
<div>
<div className='spacer' />
<Row>
<Col
md={ 3 }
mdOffset={ 3 }>
{ promoName } applied
</Col>
</Row>
</div>
);
}
return (
<div>
<div className='spacer' />
<Row>
<Col
md={ 3 }
mdOffset={ 3 }>
Have a promo code?
</Col>
</Row>
<Row>
<Col
md={ 3 }
mdOffset={ 3 }>
<Input
onChange={ jobActions.setPromoCode }
type='text'
value={ promoCode } />
</Col>
<Col
md={ 3 }>
<Button
block={ true }
onClick={ () => {
jobActions.applyCode({
id,
code: promoCode,
type: isHighlighted ? 'isHighlighted' : null
});
}}>
Apply Promo Code
</Button>
</Col>
</Row>
</div>
);
},
render() {
const {
id,
isHighlighted,
buttonId,
price,
discountAmount
} = this.props;
return (
<div>
<Row>
<Col
md={ 10 }
mdOffset={ 1 }
sm={ 8 }
smOffset={ 2 }
xs={ 12 }>
<div>
<Row>
<Col
md={ 6 }
mdOffset={ 3 }>
<h2 className='text-center'>
One more step
</h2>
<div className='spacer' />
You're Awesome! just one more step to go.
Clicking on the link below will redirect to paypal.
</Col>
</Row>
<div className='spacer' />
<Well>
<Row>
<Col
md={ 3 }
mdOffset={ 3 }>
<h4>Job Posting</h4>
</Col>
<Col
md={ 6 }>
<h4>+ { price }</h4>
</Col>
</Row>
{ this.renderHighlightPrice(isHighlighted) }
{ this.renderDiscount(discountAmount) }
<Row>
<Col
md={ 3 }
mdOffset={ 3 }>
<h4>Total</h4>
</Col>
<Col
md={ 6 }>
<h4>${
price - discountAmount + (isHighlighted ? 250 : 0)
}</h4>
</Col>
</Row>
</Well>
{ this.renderPromo() }
<div className='spacer' />
<Row>
<Col
md={ 6 }
mdOffset={ 3 }>
<form
action='https://www.paypal.com/cgi-bin/webscr'
method='post'
onClick={ this.goToJobBoard }
target='_blank'>
<input
name='cmd'
type='hidden'
value='_s-xclick' />
<input
name='hosted_button_id'
type='hidden'
value={ buttonId } />
<input
name='custom'
type='hidden'
value={ '' + id } />
<Button
block={ true }
bsSize='large'
className='signup-btn'
type='submit'>
<i className='fa fa-paypal' />
Continue to PayPal
</Button>
<div className='spacer' />
<img
alt='An array of credit cards'
border='0'
src='http://i.imgur.com/Q2SdSZG.png'
style={{
width: '100%'
}} />
</form>
</Col>
</Row>
<div className='spacer' />
</div>
</Col>
</Row>
</div>
);
}
})
);

View File

@ -2,8 +2,12 @@ import React from 'react';
import { LinkContainer } from 'react-router-bootstrap'; import { LinkContainer } from 'react-router-bootstrap';
import { Button, Row, Col } from 'react-bootstrap'; import { Button, Row, Col } from 'react-bootstrap';
export default React.createClass({ export default class extends React.Component {
displayName: 'NoJobFound', static displayName = 'NoJobFound';
shouldComponentUpdate() {
return false;
}
render() { render() {
return ( return (
@ -28,4 +32,4 @@ export default React.createClass({
</div> </div>
); );
} }
}); }

View File

@ -0,0 +1,306 @@
import { CompositeDisposable } from 'rx';
import React, { PropTypes } from 'react';
import { Button, Input, Col, Row, Well } from 'react-bootstrap';
import { connect } from 'react-redux';
import { push } from 'react-router-redux';
import PureComponent from 'react-pure-render/component';
import { createSelector } from 'reselect';
import {
applyPromo,
clearPromo,
updatePromo
} from '../redux/actions';
// real paypal buttons
// will take your money
const paypalIds = {
regular: 'Q8Z82ZLAX3Q8N',
highlighted: 'VC8QPSKCYMZLN'
};
const bindableActions = {
applyPromo,
clearPromo,
push,
updatePromo
};
const mapStateToProps = createSelector(
state => state.jobsApp.newJob,
state => state.jobsApp,
(
{ id, isHighlighted } = {},
{
buttonId,
price = 1000,
discountAmount = 0,
promoCode = '',
promoApplied = false,
promoName = ''
}
) => {
if (!buttonId) {
buttonId = isHighlighted ?
paypalIds.highlighted :
paypalIds.regular;
}
return {
id,
isHighlighted,
price,
discountAmount,
promoName,
promoCode,
promoApplied
};
}
);
export class JobTotal extends PureComponent {
constructor(...args) {
super(...args);
this._subscriptions = new CompositeDisposable();
}
static displayName = 'JobTotal';
static propTypes = {
id: PropTypes.string,
isHighlighted: PropTypes.bool,
buttonId: PropTypes.string,
price: PropTypes.number,
discountAmount: PropTypes.number,
promoName: PropTypes.string,
promoCode: PropTypes.string,
promoApplied: PropTypes.bool
};
componentWillMount() {
if (!this.props.id) {
this.props.push('/jobs');
}
this.props.clearPromo();
}
componentWillUnmount() {
this._subscriptions.dispose();
}
renderDiscount(discountAmount) {
if (!discountAmount) {
return null;
}
return (
<Row>
<Col
md={ 3 }
mdOffset={ 3 }>
<h4>Promo Discount</h4>
</Col>
<Col
md={ 3 }>
<h4>-{ discountAmount }</h4>
</Col>
</Row>
);
}
renderHighlightPrice(isHighlighted) {
if (!isHighlighted) {
return null;
}
return (
<Row>
<Col
md={ 3 }
mdOffset={ 3 }>
<h4>Highlighting</h4>
</Col>
<Col
md={ 3 }>
<h4>+ 250</h4>
</Col>
</Row>
);
}
renderPromo() {
const {
id,
promoApplied,
promoCode,
promoName,
isHighlighted,
applyPromo,
updatePromo
} = this.props;
if (promoApplied) {
return (
<div>
<div className='spacer' />
<Row>
<Col
md={ 3 }
mdOffset={ 3 }>
{ promoName } applied
</Col>
</Row>
</div>
);
}
return (
<div>
<div className='spacer' />
<Row>
<Col
md={ 3 }
mdOffset={ 3 }>
Have a promo code?
</Col>
</Row>
<Row>
<Col
md={ 3 }
mdOffset={ 3 }>
<Input
onChange={ updatePromo }
type='text'
value={ promoCode } />
</Col>
<Col
md={ 3 }>
<Button
block={ true }
onClick={ () => {
const subscription = applyPromo({
id,
code: promoCode,
type: isHighlighted ? 'isHighlighted' : null
}).subscribe();
this._subscriptions.add(subscription);
}}>
Apply Promo Code
</Button>
</Col>
</Row>
</div>
);
}
render() {
const {
id,
isHighlighted,
buttonId,
price,
discountAmount,
push
} = this.props;
return (
<div>
<Row>
<Col
md={ 10 }
mdOffset={ 1 }
sm={ 8 }
smOffset={ 2 }
xs={ 12 }>
<div>
<Row>
<Col
md={ 6 }
mdOffset={ 3 }>
<h2 className='text-center'>
One more step
</h2>
<div className='spacer' />
You're Awesome! just one more step to go.
Clicking on the link below will redirect to paypal.
</Col>
</Row>
<div className='spacer' />
<Well>
<Row>
<Col
md={ 3 }
mdOffset={ 3 }>
<h4>Job Posting</h4>
</Col>
<Col
md={ 6 }>
<h4>+ { price }</h4>
</Col>
</Row>
{ this.renderHighlightPrice(isHighlighted) }
{ this.renderDiscount(discountAmount) }
<Row>
<Col
md={ 3 }
mdOffset={ 3 }>
<h4>Total</h4>
</Col>
<Col
md={ 6 }>
<h4>${
price - discountAmount + (isHighlighted ? 250 : 0)
}</h4>
</Col>
</Row>
</Well>
{ this.renderPromo() }
<div className='spacer' />
<Row>
<Col
md={ 6 }
mdOffset={ 3 }>
<form
action='https://www.paypal.com/cgi-bin/webscr'
method='post'
onClick={ () => setTimeout(push, 0, '/jobs') }
target='_blank'>
<input
name='cmd'
type='hidden'
value='_s-xclick' />
<input
name='hosted_button_id'
type='hidden'
value={ buttonId } />
<input
name='custom'
type='hidden'
value={ '' + id } />
<Button
block={ true }
bsSize='large'
className='signup-btn'
type='submit'>
<i className='fa fa-paypal' />
Continue to PayPal
</Button>
<div className='spacer' />
<img
alt='An array of credit cards'
border='0'
src='http://i.imgur.com/Q2SdSZG.png'
style={{
width: '100%'
}} />
</form>
</Col>
</Row>
<div className='spacer' />
</div>
</Col>
</Row>
</div>
);
}
}
export default connect(mapStateToProps, bindableActions)(JobTotal);

View File

@ -1,43 +1,60 @@
import React, { cloneElement, PropTypes } from 'react'; import React, { cloneElement, PropTypes } from 'react';
import { contain } from 'thundercats-react'; import { compose } from 'redux';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { LinkContainer } from 'react-router-bootstrap';
import PureComponent from 'react-pure-render/component';
import { Button, Row, Col } from 'react-bootstrap'; import { Button, Row, Col } from 'react-bootstrap';
import contain from '../../../utils/professor-x';
import ListJobs from './List.jsx'; import ListJobs from './List.jsx';
export default contain( import {
{ findJob,
store: 'appStore', fetchJobs
map({ jobsApp: { jobs, showModal }}) { } from '../redux/actions';
return { jobs, showModal };
},
fetchAction: 'jobActions.getJobs',
isPrimed({ jobs = [] }) {
return !!jobs.length;
},
actions: [
'appActions',
'jobActions'
]
},
React.createClass({
displayName: 'Jobs',
propTypes: { const mapStateToProps = createSelector(
state => state.jobsApp.jobs.entities,
state => state.jobsApp.jobs.results,
state => state.jobsApp,
(jobsMap, jobsById) => {
return { jobs: jobsById.map(id => jobsMap[id]) };
}
);
const bindableActions = {
findJob,
fetchJobs
};
const fetchOptions = {
fetchAction: 'fetchJobs',
isPrimed({ jobs }) {
return jobs.length > 1;
}
};
export class Jobs extends PureComponent {
static displayName = 'Jobs';
static propTypes = {
push: PropTypes.func,
findJob: PropTypes.func,
fetchJobs: PropTypes.func,
children: PropTypes.element, children: PropTypes.element,
appActions: PropTypes.object,
jobActions: PropTypes.object,
jobs: PropTypes.array, jobs: PropTypes.array,
showModal: PropTypes.bool showModal: PropTypes.bool
}, };
handleJobClick(id) { createJobClickHandler() {
const { appActions, jobActions } = this.props; const { findJob } = this.props;
if (!id) {
return null; return (id) => {
findJob(id);
};
} }
jobActions.findJob(id);
appActions.goTo(`/jobs/${id}`);
},
renderList(handleJobClick, jobs) { renderList(handleJobClick, jobs) {
return ( return (
@ -45,7 +62,7 @@ export default contain(
handleClick={ handleJobClick } handleClick={ handleJobClick }
jobs={ jobs }/> jobs={ jobs }/>
); );
}, }
renderChild(child, jobs) { renderChild(child, jobs) {
if (!child) { if (!child) {
@ -55,13 +72,12 @@ export default contain(
child, child,
{ jobs } { jobs }
); );
}, }
render() { render() {
const { const {
children, children,
jobs, jobs
appActions
} = this.props; } = this.props;
return ( return (
@ -80,13 +96,11 @@ export default contain(
sm={ 8 } sm={ 8 }
smOffset={ 2 } smOffset={ 2 }
xs={ 12 }> xs={ 12 }>
<Button <LinkContainer to='/jobs/new' >
className='signup-btn btn-block btn-cta' <Button className='signup-btn btn-block btn-cta'>
onClick={ ()=> {
appActions.goTo('/jobs/new');
}}>
Post a job: $1,000 Post a job: $1,000
</Button> </Button>
</LinkContainer>
<div className='spacer' /> <div className='spacer' />
</Col> </Col>
</Row> </Row>
@ -122,11 +136,15 @@ export default contain(
</Row> </Row>
<Row> <Row>
{ this.renderChild(children, jobs) || { this.renderChild(children, jobs) ||
this.renderList(this.handleJobClick, jobs) } this.renderList(this.createJobClickHandler(), jobs) }
</Row> </Row>
</Col> </Col>
</Row> </Row>
); );
} }
}) }
);
export default compose(
connect(mapStateToProps, bindableActions),
contain(fetchOptions)
)(Jobs);

View File

@ -1,14 +1,16 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import classnames from 'classnames'; import classnames from 'classnames';
import { LinkContainer } from 'react-router-bootstrap';
import { ListGroup, ListGroupItem } from 'react-bootstrap'; import { ListGroup, ListGroupItem } from 'react-bootstrap';
import PureComponent from 'react-pure-render/component';
export default React.createClass({ export default class ListJobs extends PureComponent {
displayName: 'ListJobs', static displayName = 'ListJobs';
propTypes: { static propTypes = {
handleClick: PropTypes.func, handleClick: PropTypes.func,
jobs: PropTypes.array jobs: PropTypes.array
}, };
addLocation(locale) { addLocation(locale) {
if (!locale) { if (!locale) {
@ -19,7 +21,7 @@ export default React.createClass({
{ locale } { locale }
</span> </span>
); );
}, }
renderJobs(handleClick, jobs = []) { renderJobs(handleClick, jobs = []) {
return jobs return jobs
@ -40,10 +42,14 @@ export default React.createClass({
'jobs-list-highlight': isHighlighted 'jobs-list-highlight': isHighlighted
}); });
const to = `/jobs/${id}`;
return ( return (
<LinkContainer
key={ id }
to={ to }>
<ListGroupItem <ListGroupItem
className={ className } className={ className }
key={ id }
onClick={ () => handleClick(id) }> onClick={ () => handleClick(id) }>
<div> <div>
<h4 className='pull-left' style={{ display: 'inline-block' }}> <h4 className='pull-left' style={{ display: 'inline-block' }}>
@ -60,9 +66,10 @@ export default React.createClass({
</h4> </h4>
</div> </div>
</ListGroupItem> </ListGroupItem>
</LinkContainer>
); );
}); });
}, }
render() { render() {
const { const {
@ -76,4 +83,4 @@ export default React.createClass({
</ListGroup> </ListGroup>
); );
} }
}); }

View File

@ -1,17 +1,15 @@
import { helpers } from 'rx'; import { helpers } from 'rx';
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import { History } from 'react-router'; import { push } from 'react-router-redux';
import { contain } from 'thundercats-react'; import { reduxForm } from 'redux-form';
import debugFactory from 'debug'; // import debug from 'debug';
import dedent from 'dedent'; import dedent from 'dedent';
import normalizeUrl from 'normalize-url';
import { getDefaults } from '../utils';
import { import {
inHTMLData, isAscii,
uriInSingleQuotedAttr isEmail,
} from 'xss-filters'; isURL
} from 'validator';
import { import {
Button, Button,
@ -20,30 +18,14 @@ import {
Row Row
} from 'react-bootstrap'; } from 'react-bootstrap';
import { import { saveForm, loadSavedForm } from '../redux/actions';
isAscii,
isEmail,
isURL
} from 'validator';
const debug = debugFactory('freecc:jobs:newForm'); // const log = debug('fcc:jobs:newForm');
const checkValidity = [
'position',
'locale',
'description',
'email',
'url',
'logo',
'company',
'isHighlighted',
'howToApply'
];
const hightlightCopy = ` const hightlightCopy = `
Highlight my post to make it stand out. (+$250) Highlight my post to make it stand out. (+$250)
`; `;
const isRemoteCopy = ` const isRemoteCopy = `
This job can be performed remotely. This job can be performed remotely.
`; `;
@ -60,196 +42,103 @@ const checkboxClass = dedent`
col-sm-6 col-md-offset-3 col-sm-6 col-md-offset-3
`; `;
function formatValue(value, validator, type = 'string') { const certTypes = {
const formatted = getDefaults(type); isFrontEndCert: 'isFrontEndCert',
if (validator && type === 'string' && typeof value === 'string') { isBackEndCert: 'isBackEndCert'
formatted.valid = validator(value);
}
if (value) {
formatted.value = value;
formatted.bsStyle = formatted.valid ? 'success' : 'error';
}
return formatted;
}
const normalizeOptions = {
stripWWW: false
}; };
function formatUrl(url, shouldKeepTrailingSlash = true) {
if (
typeof url === 'string' &&
url.length > 4 &&
url.indexOf('.') !== -1
) {
// prevent trailing / from being stripped during typing
let lastChar = '';
if (shouldKeepTrailingSlash && url.substring(url.length - 1) === '/') {
lastChar = '/';
}
return normalizeUrl(url, normalizeOptions) + lastChar;
}
return url;
}
function isValidURL(data) { function isValidURL(data) {
return isURL(data, { 'require_protocol': true }); return isURL(data, { 'require_protocol': true });
} }
const fields = [
'position',
'locale',
'description',
'email',
'url',
'logo',
'company',
'isHighlighted',
'isRemoteOk',
'isFrontEndCert',
'isBackEndCert',
'howToApply'
];
const fieldValidators = {
position: makeRequired(isAscii),
locale: makeRequired(isAscii),
description: makeRequired(helpers.identity),
email: makeRequired(isEmail),
url: makeRequired(isValidURL),
logo: makeOptional(isValidURL),
company: makeRequired(isAscii),
howToApply: makeRequired(isAscii)
};
function makeOptional(validator) {
return val => val ? validator(val) : true;
}
function makeRequired(validator) { function makeRequired(validator) {
return (val) => !!val && validator(val); return (val) => val ? validator(val) : false;
} }
export default contain({ function validateForm(values) {
store: 'appStore', return Object.keys(fieldValidators)
actions: 'jobActions', .map(field => {
map({ jobsApp: { form = {} } }) { if (fieldValidators[field](values[field])) {
const { return null;
position, }
locale, return { [field]: !fieldValidators[field](values[field]) };
description, })
email, .filter(Boolean)
url, .reduce((errors, error) => ({ ...errors, ...error }), {});
logo, }
company,
isFrontEndCert = true, function getBsStyle(field) {
isBackEndCert, if (field.pristine) {
isHighlighted, return null;
isRemoteOk, }
howToApply
} = form; return field.error ?
return { 'error' :
position: formatValue(position, makeRequired(isAscii)), 'success';
locale: formatValue(locale, makeRequired(isAscii)), }
description: formatValue(description, makeRequired(helpers.identity)),
email: formatValue(email, makeRequired(isEmail)), export class NewJob extends React.Component {
url: formatValue(formatUrl(url), isValidURL), static displayName = 'NewJob';
logo: formatValue(formatUrl(logo), isValidURL),
company: formatValue(company, makeRequired(isAscii)), static propTypes = {
isHighlighted: formatValue(isHighlighted, null, 'bool'), fields: PropTypes.object,
isRemoteOk: formatValue(isRemoteOk, null, 'bool'), handleSubmit: PropTypes.func,
howToApply: formatValue(howToApply, makeRequired(isAscii)), loadSavedForm: PropTypes.func,
isFrontEndCert, push: PropTypes.func,
isBackEndCert saveForm: PropTypes.func
}; };
},
subscribeOnWillMount() {
return typeof window !== 'undefined';
}
},
React.createClass({
displayName: 'NewJob',
propTypes: {
jobActions: PropTypes.object,
position: PropTypes.object,
locale: PropTypes.object,
description: PropTypes.object,
email: PropTypes.object,
url: PropTypes.object,
logo: PropTypes.object,
company: PropTypes.object,
isHighlighted: PropTypes.object,
isRemoteOk: PropTypes.object,
isFrontEndCert: PropTypes.bool,
isBackEndCert: PropTypes.bool,
howToApply: PropTypes.object
},
mixins: [History],
handleSubmit(e) {
e.preventDefault();
const pros = this.props;
let valid = true;
checkValidity.forEach((prop) => {
// if value exist, check if it is valid
if (pros[prop].value && pros[prop].type !== 'boolean') {
valid = valid && !!pros[prop].valid;
}
});
if (
!valid ||
!pros.isFrontEndCert &&
!pros.isBackEndCert
) {
debug('form not valid');
return;
}
const {
jobActions,
// form values
position,
locale,
description,
email,
url,
logo,
company,
isFrontEndCert,
isBackEndCert,
isHighlighted,
isRemoteOk,
howToApply
} = this.props;
// sanitize user output
const jobValues = {
position: inHTMLData(position.value),
locale: inHTMLData(locale.value),
description: inHTMLData(description.value),
email: inHTMLData(email.value),
url: formatUrl(uriInSingleQuotedAttr(url.value), false),
logo: formatUrl(uriInSingleQuotedAttr(logo.value), false),
company: inHTMLData(company.value),
isHighlighted: !!isHighlighted.value,
isRemoteOk: !!isRemoteOk.value,
howToApply: inHTMLData(howToApply.value),
isFrontEndCert,
isBackEndCert
};
const job = Object.keys(jobValues).reduce((accu, prop) => {
if (jobValues[prop]) {
accu[prop] = jobValues[prop];
}
return accu;
}, {});
job.postedOn = new Date();
debug('job sanitized', job);
jobActions.saveForm(job);
this.history.pushState(null, '/jobs/new/preview');
},
componentDidMount() { componentDidMount() {
const { jobActions } = this.props; this.props.loadSavedForm();
jobActions.getSavedForm(); }
},
handleChange(name, { target: { value } }) { handleSubmit(job) {
const { jobActions: { handleForm } } = this.props; this.props.saveForm(job);
handleForm({ [name]: value }); this.props.push('/jobs/new/preview');
}, }
handleCertClick(name) { handleCertClick(name) {
const { jobActions: { handleForm } } = this.props; const { fields } = this.props;
const otherButton = name === 'isFrontEndCert' ? Object.keys(certTypes).forEach(certType => {
'isBackEndCert' : if (certType === name) {
'isFrontEndCert'; return fields[certType].onChange(true);
}
handleForm({ return fields[certType].onChange(false);
[name]: true,
[otherButton]: false
}); });
}, }
render() { render() {
const { const {
fields: {
position, position,
locale, locale,
description, description,
@ -261,8 +150,9 @@ export default contain({
isRemoteOk, isRemoteOk,
howToApply, howToApply,
isFrontEndCert, isFrontEndCert,
isBackEndCert, isBackEndCert
jobActions: { handleForm } },
handleSubmit
} = this.props; } = this.props;
const { handleChange } = this; const { handleChange } = this;
@ -278,7 +168,7 @@ export default contain({
<div className='text-center'> <div className='text-center'>
<form <form
className='form-horizontal' className='form-horizontal'
onSubmit={ this.handleSubmit }> onSubmit={ handleSubmit(data => this.handleSubmit(data)) }>
<div className='spacer'> <div className='spacer'>
<h2>First, select your ideal applicant: </h2> <h2>First, select your ideal applicant: </h2>
@ -291,10 +181,10 @@ export default contain({
<Row> <Row>
<Button <Button
bsStyle='primary' bsStyle='primary'
className={ isFrontEndCert ? 'active' : '' } className={ isFrontEndCert.value ? 'active' : '' }
onClick={ () => { onClick={ () => {
if (!isFrontEndCert) { if (!isFrontEndCert.value) {
this.handleCertClick('isFrontEndCert'); this.handleCertClick(certTypes.isFrontEndCert);
} }
}}> }}>
<h4>Front End Development Certified</h4> <h4>Front End Development Certified</h4>
@ -310,10 +200,10 @@ export default contain({
<Row> <Row>
<Button <Button
bsStyle='primary' bsStyle='primary'
className={ isBackEndCert ? 'active' : ''} className={ isBackEndCert.value ? 'active' : ''}
onClick={ () => { onClick={ () => {
if (!isBackEndCert) { if (!isBackEndCert.value) {
this.handleCertClick('isBackEndCert'); this.handleCertClick(certTypes.isBackEndCert);
} }
}}> }}>
<h4>Back End Development Certified</h4> <h4>Back End Development Certified</h4>
@ -332,47 +222,43 @@ export default contain({
</div> </div>
<hr /> <hr />
<Input <Input
bsStyle={ position.bsStyle } bsStyle={ getBsStyle(position) }
label='Job Title' label='Job Title'
labelClassName={ labelClass } labelClassName={ labelClass }
onChange={ (e) => handleChange('position', e) }
placeholder={ placeholder={
'e.g. Full Stack Developer, Front End Developer, etc.' 'e.g. Full Stack Developer, Front End Developer, etc.'
} }
required={ true } required={ true }
type='text' type='text'
value={ position.value } wrapperClassName={ inputClass }
wrapperClassName={ inputClass } /> { ...position }
/>
<Input <Input
bsStyle={ locale.bsStyle } bsStyle={ getBsStyle(locale) }
label='Location' label='Location'
labelClassName={ labelClass } labelClassName={ labelClass }
onChange={ (e) => handleChange('locale', e) }
placeholder='e.g. San Francisco, Remote, etc.' placeholder='e.g. San Francisco, Remote, etc.'
required={ true } required={ true }
type='text' type='text'
value={ locale.value } wrapperClassName={ inputClass }
wrapperClassName={ inputClass } /> { ...locale }
/>
<Input <Input
bsStyle={ description.bsStyle } bsStyle={ getBsStyle(description) }
label='Description' label='Description'
labelClassName={ labelClass } labelClassName={ labelClass }
onChange={ (e) => handleChange('description', e) }
required={ true } required={ true }
rows='10' rows='10'
type='textarea' type='textarea'
value={ description.value } wrapperClassName={ inputClass }
wrapperClassName={ inputClass } /> { ...description }
/>
<Input <Input
checked={ isRemoteOk.value }
label={ isRemoteCopy } label={ isRemoteCopy }
onChange={
({ target: { checked } }) => handleForm({
isRemoteOk: !!checked
})
}
type='checkbox' type='checkbox'
wrapperClassName={ checkboxClass } /> wrapperClassName={ checkboxClass }
{ ...isRemoteOk }
/>
<div className='spacer' /> <div className='spacer' />
<hr /> <hr />
@ -381,16 +267,16 @@ export default contain({
<h2>How should they apply?</h2> <h2>How should they apply?</h2>
</div> </div>
<Input <Input
bsStyle={ howToApply.bsStyle } bsStyle={ getBsStyle(howToApply) }
label=' ' label=' '
labelClassName={ labelClass } labelClassName={ labelClass }
onChange={ (e) => handleChange('howToApply', e) }
placeholder={ howToApplyCopy } placeholder={ howToApplyCopy }
required={ true } required={ true }
rows='2' rows='2'
type='textarea' type='textarea'
value={ howToApply.value } wrapperClassName={ inputClass }
wrapperClassName={ inputClass } /> { ...howToApply }
/>
</Row> </Row>
<div className='spacer' /> <div className='spacer' />
@ -399,41 +285,42 @@ export default contain({
<h2>Tell us about your organization</h2> <h2>Tell us about your organization</h2>
</div> </div>
<Input <Input
bsStyle={ company.bsStyle } bsStyle={ getBsStyle(company) }
label='Company Name' label='Company Name'
labelClassName={ labelClass } labelClassName={ labelClass }
onChange={ (e) => handleChange('company', e) } onChange={ (e) => handleChange('company', e) }
type='text' type='text'
value={ company.value } wrapperClassName={ inputClass }
wrapperClassName={ inputClass } /> { ...company }
/>
<Input <Input
bsStyle={ email.bsStyle } bsStyle={ getBsStyle(email) }
label='Email' label='Email'
labelClassName={ labelClass } labelClassName={ labelClass }
onChange={ (e) => handleChange('email', e) }
placeholder='This is how we will contact you' placeholder='This is how we will contact you'
required={ true } required={ true }
type='email' type='email'
value={ email.value } wrapperClassName={ inputClass }
wrapperClassName={ inputClass } /> { ...email }
/>
<Input <Input
bsStyle={ url.bsStyle } bsStyle={ getBsStyle(url) }
label='URL' label='URL'
labelClassName={ labelClass } labelClassName={ labelClass }
onChange={ (e) => handleChange('url', e) }
placeholder='http://yourcompany.com' placeholder='http://yourcompany.com'
type='url' type='url'
value={ url.value } wrapperClassName={ inputClass }
wrapperClassName={ inputClass } /> { ...url }
/>
<Input <Input
bsStyle={ logo.bsStyle } bsStyle={ getBsStyle(logo) }
label='Logo' label='Logo'
labelClassName={ labelClass } labelClassName={ labelClass }
onChange={ (e) => handleChange('logo', e) }
placeholder='http://yourcompany.com/logo.png' placeholder='http://yourcompany.com/logo.png'
type='url' type='url'
value={ logo.value } wrapperClassName={ inputClass }
wrapperClassName={ inputClass } /> { ...logo }
/>
<div className='spacer' /> <div className='spacer' />
<hr /> <hr />
@ -456,17 +343,13 @@ export default contain({
<Input <Input
bsSize='large' bsSize='large'
bsStyle='success' bsStyle='success'
checked={ isHighlighted.value }
label={ hightlightCopy } label={ hightlightCopy }
onChange={
({ target: { checked } }) => handleForm({
isHighlighted: !!checked
})
}
type='checkbox' type='checkbox'
wrapperClassName={ wrapperClassName={
checkboxClass.replace('text-left', '') checkboxClass.replace('text-left', '')
} /> }
{ ...isHighlighted }
/>
</Row> </Row>
</div> </div>
@ -491,5 +374,18 @@ export default contain({
</div> </div>
); );
} }
}) }
);
export default reduxForm(
{
form: 'NewJob',
fields,
validate: validateForm
},
state => ({ initialValues: state.jobsApp.initialValues }),
{
loadSavedForm,
push,
saveForm
}
)(NewJob);

View File

@ -2,8 +2,8 @@ import React from 'react';
import { LinkContainer } from 'react-router-bootstrap'; import { LinkContainer } from 'react-router-bootstrap';
import { Button, Col, Row } from 'react-bootstrap'; import { Button, Col, Row } from 'react-bootstrap';
export default React.createClass({ export default class extends React.createClass {
displayName: 'NewJobCompleted', static displayName = 'NewJobCompleted';
render() { render() {
return ( return (
@ -36,4 +36,4 @@ export default React.createClass({
</div> </div>
); );
} }
}); }

View File

@ -1,40 +1,60 @@
import { CompositeDisposable } from 'rx';
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import { Button, Row, Col } from 'react-bootstrap'; import { Button, Row, Col } from 'react-bootstrap';
import { contain } from 'thundercats-react'; import { connect } from 'react-redux';
import PureComponent from 'react-pure-render/component';
import { goBack, push } from 'react-router-redux';
import ShowJob from './ShowJob.jsx'; import ShowJob from './ShowJob.jsx';
import JobNotFound from './JobNotFound.jsx'; import JobNotFound from './JobNotFound.jsx';
export default contain( import { clearForm, saveJob } from '../redux/actions';
{
store: 'appStore', const mapStateToProps = state => ({ job: state.jobsApp.newJob });
actions: [
'appActions', const bindableActions = {
'jobActions' goBack,
], push,
map({ jobsApp: { form: job = {} } }) { clearForm,
return { job }; saveJob
};
export class JobPreview extends PureComponent {
constructor(...args) {
super(...args);
this._subscriptions = new CompositeDisposable();
} }
},
React.createClass({
displayName: 'Preview',
propTypes: { static displayName = 'Preview';
appActions: PropTypes.object,
static propTypes = {
job: PropTypes.object, job: PropTypes.object,
jobActions: PropTypes.object saveJob: PropTypes.func,
}, clearForm: PropTypes.func,
push: PropTypes.func
};
componentDidMount() { componentWillMount() {
const { appActions, job } = this.props; const { push, job } = this.props;
// redirect user in client // redirect user in client
if (!job || !job.position || !job.description) { if (!job || !job.position || !job.description) {
appActions.goTo('/jobs/new'); push('/jobs/new');
}
}
componentWillUnmount() {
this._subscriptions.dispose();
}
handleJobSubmit() {
const { clearForm, saveJob, job } = this.props;
clearForm();
const subscription = saveJob(job).subscribe();
this._subscriptions.add(subscription);
} }
},
render() { render() {
const { appActions, job, jobActions } = this.props; const { job, goBack } = this.props;
if (!job || !job.position || !job.description) { if (!job || !job.position || !job.description) {
return <JobNotFound />; return <JobNotFound />;
@ -54,19 +74,13 @@ export default contain(
<Button <Button
block={ true } block={ true }
className='signup-btn' className='signup-btn'
onClick={ () => { onClick={ () => this.handleJobSubmit() }>
jobActions.clearSavedForm();
jobActions.saveJobToDb({
goTo: '/jobs/new/check-out',
job
});
}}>
Looks great! Let's Check Out Looks great! Let's Check Out
</Button> </Button>
<Button <Button
block={ true } block={ true }
onClick={ () => appActions.goBack() } > onClick={ goBack } >
Head back and make edits Head back and make edits
</Button> </Button>
</div> </div>
@ -75,5 +89,6 @@ export default contain(
</div> </div>
); );
} }
}) }
);
export default connect(mapStateToProps, bindableActions)(JobPreview);

View File

@ -1,6 +1,12 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import { History } from 'react-router'; import { compose } from 'redux';
import { contain } from 'thundercats-react'; import { connect } from 'react-redux';
import { push } from 'react-router-redux';
import PureComponent from 'react-pure-render/component';
import { createSelector } from 'reselect';
import contain from '../../../utils/professor-x';
import { fetchJobs } from '../redux/actions';
import ShowJob from './ShowJob.jsx'; import ShowJob from './ShowJob.jsx';
import JobNotFound from './JobNotFound.jsx'; import JobNotFound from './JobNotFound.jsx';
@ -51,54 +57,54 @@ function generateMessage(
"You've earned it, so feel free to apply."; "You've earned it, so feel free to apply.";
} }
export default contain( const mapStateToProps = createSelector(
{ state => state.app,
store: 'appStore', state => state.jobsApp.currentJob,
fetchAction: 'jobActions.getJob', state => state.jobsApp.jobs.entities,
map({ ({ username, isFrontEndCert, isBackEndCert }, currentJob, jobs) => ({
username, username,
isFrontEndCert, isFrontEndCert,
isBackEndCert, isBackEndCert,
jobsApp: { currentJob } job: jobs[currentJob] || {}
}) { })
return { );
username,
job: currentJob, const bindableActions = {
isFrontEndCert, push,
isBackEndCert fetchJobs
}; };
},
getPayload({ params: { id } }) { const fetchOptions = {
return id; fetchAction: 'fetchJobs',
getActionArgs({ params: { id } }) {
return [ id ];
}, },
isPrimed({ params: { id } = {}, job = {} }) { isPrimed({ params: { id } = {}, job = {} }) {
return job.id === id; return job.id === id;
}, },
// using es6 destructuring // using es6 destructuring
shouldContainerFetch({ job = {} }, { params: { id } } shouldRefetch({ job }, { params: { id } }) {
) {
return job.id !== id; return job.id !== id;
} }
}, };
React.createClass({
displayName: 'Show',
propTypes: { export class Show extends PureComponent {
static displayName = 'Show';
static propTypes = {
job: PropTypes.object, job: PropTypes.object,
isBackEndCert: PropTypes.bool, isBackEndCert: PropTypes.bool,
isFrontEndCert: PropTypes.bool, isFrontEndCert: PropTypes.bool,
username: PropTypes.string username: PropTypes.string
}, };
mixins: [History],
componentDidMount() { componentDidMount() {
const { job } = this.props; const { job, push } = this.props;
// redirect user in client // redirect user in client
if (!isJobValid(job)) { if (!isJobValid(job)) {
this.history.pushState(null, '/jobs'); push('/jobs');
}
} }
},
render() { render() {
const { const {
@ -132,5 +138,9 @@ export default contain(
{ ...this.props }/> { ...this.props }/>
); );
} }
}) }
);
export default compose(
connect(mapStateToProps, bindableActions),
contain(fetchOptions)
)(Show);

View File

@ -1 +0,0 @@
export default from './Actions';

View File

@ -2,7 +2,7 @@ import Jobs from './components/Jobs.jsx';
import NewJob from './components/NewJob.jsx'; import NewJob from './components/NewJob.jsx';
import Show from './components/Show.jsx'; import Show from './components/Show.jsx';
import Preview from './components/Preview.jsx'; import Preview from './components/Preview.jsx';
import GoToPayPal from './components/GoToPayPal.jsx'; import JobTotal from './components/JobTotal.jsx';
import NewJobCompleted from './components/NewJobCompleted.jsx'; import NewJobCompleted from './components/NewJobCompleted.jsx';
/* /*
@ -23,7 +23,7 @@ export default {
component: Preview component: Preview
}, { }, {
path: 'jobs/new/check-out', path: 'jobs/new/check-out',
component: GoToPayPal component: JobTotal
}, { }, {
path: 'jobs/new/completed', path: 'jobs/new/completed',
component: NewJobCompleted component: NewJobCompleted

View File

@ -0,0 +1,34 @@
import { createAction } from 'redux-actions';
import types from './types';
export const fetchJobs = createAction(types.fetchJobs);
export const fetchJobsCompleted = createAction(
types.fetchJobsCompleted,
(currentJob, jobs) => ({ currentJob, jobs })
);
export const findJob = createAction(types.findJob);
// saves to database
export const saveJob = createAction(types.saveJob);
// saves to localStorage
export const saveForm = createAction(types.saveForm);
export const saveCompleted = createAction(types.saveCompleted);
export const clearForm = createAction(types.clearForm);
export const loadSavedForm = createAction(types.loadSavedForm);
export const loadSavedFormCompleted = createAction(
types.loadSavedFormCompleted
);
export const clearPromo = createAction(types.clearPromo);
export const updatePromo = createAction(
types.updatePromo,
({ target: { value = '' } = {} } = {}) => value
);
export const applyPromo = createAction(types.applyPromo);
export const applyPromoCompleted = createAction(types.applyPromoCompleted);

View File

@ -0,0 +1,39 @@
import { Observable } from 'rx';
import { applyPromo } from './types';
import { applyPromoCompleted } from './actions';
import { postJSON$ } from '../../../../utils/ajax-stream';
export default () => ({ dispatch }) => next => {
return function applyPromoSaga(action) {
if (action.type !== applyPromo) {
return next(action);
}
const { id, code = '', type = null } = action.payload;
const body = {
id,
code: code.replace(/[^\d\w\s]/, '')
};
if (type) {
body.type = type;
}
return postJSON$('/api/promos/getButton', body)
.retry(3)
.map(({ promo }) => {
if (!promo || !promo.buttonId) {
throw new Error('No promo returned by server');
}
return applyPromoCompleted(promo);
})
.catch(error => Observable.just({
type: 'app.handleError',
error
}))
.doOnNext(dispatch);
};
};

View File

@ -0,0 +1,50 @@
import { Observable } from 'rx';
import { normalize, Schema, arrayOf } from 'normalizr';
import { fetchJobsCompleted } from './actions';
import { fetchJobs } from './types';
import { handleError } from '../../../redux/types';
const job = new Schema('job', { idAttribute: 'id' });
export default ({ services }) => ({ dispatch }) => next => {
return function fetchJobsSaga(action) {
if (action.type !== fetchJobs) {
return next(action);
}
const { payload: id } = action;
const data = { service: 'jobs' };
if (id) {
data.params = { id };
}
return services.readService$(data)
.map(jobs => {
if (!Array.isArray(jobs)) {
jobs = [jobs];
}
const { entities, result } = normalize(
{ jobs },
{ jobs: arrayOf(job) }
);
return fetchJobsCompleted(
result.jobs[0],
{
entities: entities.job,
results: result.jobs
}
);
})
.catch(error => {
return Observable.just({
type: handleError,
error
});
})
.doOnNext(dispatch);
};
};

View File

@ -0,0 +1,11 @@
export actions from './actions';
export reducer from './reducer';
export types from './types';
import fetchJobsSaga from './fetch-jobs-saga';
import saveJobSaga from './save-job-saga';
import applyPromoSaga from './apply-promo-saga';
export formNormalizer from './jobs-form-normalizer';
export const sagas = [ fetchJobsSaga, saveJobSaga, applyPromoSaga ];

View File

@ -0,0 +1,42 @@
import normalizeUrl from 'normalize-url';
import {
inHTMLData,
uriInSingleQuotedAttr
} from 'xss-filters';
const normalizeOptions = {
stripWWW: false
};
function ifDefinedNormalize(normalizer) {
return value => value ? normalizer(value) : value;
}
function formatUrl(url) {
if (
typeof url === 'string' &&
url.length > 4 &&
url.indexOf('.') !== -1
) {
// prevent trailing / from being stripped during typing
let lastChar = '';
if (url.substring(url.length - 1) === '/') {
lastChar = '/';
}
return normalizeUrl(url, normalizeOptions) + lastChar;
}
return url;
}
export default {
NewJob: {
position: ifDefinedNormalize(inHTMLData),
locale: ifDefinedNormalize(inHTMLData),
description: ifDefinedNormalize(inHTMLData),
email: ifDefinedNormalize(inHTMLData),
url: ifDefinedNormalize(value => formatUrl(uriInSingleQuotedAttr(value))),
logo: ifDefinedNormalize(value => formatUrl(uriInSingleQuotedAttr(value))),
company: ifDefinedNormalize(inHTMLData),
howToApply: ifDefinedNormalize(inHTMLData)
}
};

View File

@ -0,0 +1,85 @@
import { handleActions } from 'redux-actions';
import types from './types';
const replaceMethod = ''.replace;
function replace(str) {
if (!str) { return ''; }
return replaceMethod.call(str, /[^\d\w\s]/, '');
}
const initialState = {
// used by NewJob form
initialValues: {},
currentJob: '',
newJob: {},
jobs: {
entities: {},
results: []
}
};
export default handleActions(
{
[types.findJob]: (state, { payload: id }) => {
const currentJob = state.jobs.entities[id];
return {
...state,
currentJob: currentJob && currentJob.id ?
currentJob.id :
state.currentJob
};
},
[types.fetchJobsCompleted]: (state, { payload: { jobs, currentJob } }) => ({
...state,
currentJob,
jobs
}),
[types.updatePromo]: (state, { payload }) => ({
...state,
promoCode: replace(payload)
}),
[types.saveCompleted]: (state, { payload: newJob }) => {
return {
...state,
newJob
};
},
[types.loadSavedFormCompleted]: (state, { payload: initialValues }) => ({
...state,
initialValues
}),
[types.applyPromoCompleted]: (state, { payload: promo }) => {
const {
fullPrice: price,
buttonId,
discountAmount,
code: promoCode,
name: promoName
} = promo;
return {
...state,
price,
buttonId,
discountAmount,
promoCode,
promoApplied: true,
promoName
};
},
[types.clearPromo]: state => ({
/* eslint-disable no-undefined */
...state,
price: undefined,
buttonId: undefined,
discountAmount: undefined,
promoCode: undefined,
promoApplied: false,
promoName: undefined
/* eslint-enable no-undefined */
})
},
initialState
);

View File

@ -0,0 +1,32 @@
import { push } from 'react-router-redux';
import { Observable } from 'rx';
import { saveCompleted } from './actions';
import { saveJob } from './types';
import { handleError } from '../../../redux/types';
export default ({ services }) => ({ dispatch }) => next => {
return function saveJobSaga(action) {
const result = next(action);
if (action.type !== saveJob) {
return result;
}
const { payload: job } = action;
return services.createService$({
service: 'jobs',
params: { job }
})
.retry(3)
.flatMap(job => Observable.of(
saveCompleted(job),
push('/jobs/new/check-out')
))
.catch(error => Observable.just({
type: handleError,
error
}))
.doOnNext(dispatch);
};
};

View File

@ -0,0 +1,22 @@
import createTypes from '../../../utils/create-types';
export default createTypes([
'fetchJobs',
'fetchJobsCompleted',
'findJob',
'saveJob',
'saveForm',
'saveCompleted',
'clearForm',
'loadSavedForm',
'loadSavedFormCompleted',
'clearPromo',
'updatePromo',
'applyPromo',
'applyPromoCompleted'
], 'jobs');

9
common/app/sagas.js Normal file
View File

@ -0,0 +1,9 @@
import { sagas as appSagas } from './redux';
import { sagas as hikesSagas} from './routes/Hikes/redux';
import { sagas as jobsSagas } from './routes/Jobs/redux';
export default [
...appSagas,
...hikesSagas,
...jobsSagas
];

View File

@ -0,0 +1,42 @@
import React, { Children, PropTypes } from 'react';
class ProfessorContext extends React.Component {
constructor(props) {
super(props);
this.professor = props.professor;
}
static displayName = 'ProfessorContext';
static propTypes = {
professor: PropTypes.object,
children: PropTypes.element.isRequired
};
static childContextTypes = {
professor: PropTypes.object
};
getChildContext() {
return { professor: this.professor };
}
render() {
return Children.only(this.props.children);
}
}
/* eslint-disable react/display-name, react/prop-types */
ProfessorContext.wrap = function wrap(Component, professor) {
const props = {};
if (professor) {
props.professor = professor;
}
return React.createElement(
ProfessorContext,
props,
Component
);
};
export default ProfessorContext;

View File

@ -0,0 +1,10 @@
// createTypes(types: String[], prefix: String) => Object
export default function createTypes(types = [], prefix = '') {
if (!Array.isArray(types) || typeof prefix !== 'string') {
return {};
}
return types.reduce((types, type) => {
types[type] = prefix + '.' + type;
return types;
}, {});
}

View File

@ -0,0 +1,192 @@
import React, { PropTypes, createElement } from 'react';
import { Observable, CompositeDisposable } from 'rx';
import shouldComponentUpdate from 'react-pure-render/function';
import debug from 'debug';
// interface contain {
// (options?: Object, Component: ReactComponent) => ReactComponent
// (options?: Object) => (Component: ReactComponent) => ReactComponent
// }
//
// Action: { type: String, payload: Any, ...meta }
//
// ActionCreator(...args) => Observable
//
// interface options {
// fetchAction?: ActionCreator,
// getActionArgs?(props: Object, context: Object) => [],
// isPrimed?(props: Object, context: Object) => Boolean,
// handleError?(err) => Void
// shouldRefetch?(
// props: Object,
// nextProps: Object,
// context: Object,
// nextContext: Object
// ) => Boolean,
// }
const log = debug('fcc:professerx');
function getChildContext(childContextTypes, currentContext) {
const compContext = { ...currentContext };
// istanbul ignore else
if (!childContextTypes || !childContextTypes.professor) {
delete compContext.professor;
}
return compContext;
}
const __DEV__ = process.env.NODE_ENV !== 'production';
export default function contain(options = {}, Component) {
/* istanbul ignore else */
if (!Component) {
return contain.bind(null, options);
}
let action;
let isActionable = false;
let hasRefetcher = typeof options.shouldRefetch === 'function';
const getActionArgs = typeof options.getActionArgs === 'function' ?
options.getActionArgs :
(() => []);
const isPrimed = typeof options.isPrimed === 'function' ?
options.isPrimed :
(() => false);
return class Container extends React.Component {
constructor(props, context) {
super(props, context);
this.__subscriptions = new CompositeDisposable();
}
static displayName = `Container(${Component.displayName})`;
static contextTypes = {
...Component.contextTypes,
professor: PropTypes.object
};
componentWillMount() {
const { professor } = this.context;
const { props } = this;
if (!options.fetchAction) {
log(`${Component.displayName} has no fetch action defined`);
return null;
}
action = props[options.fetchAction];
isActionable = typeof action === 'function';
if (__DEV__ && typeof action !== 'function') {
throw new Error(
`${options.fetchAction} should return a function but got ${action}.
Check the fetch options for ${Component.displayName}.`
);
}
if (
!professor ||
!professor.fetchContext
) {
log(
`${Component.displayName} did not have professor defined on context`
);
return null;
}
const actionArgs = getActionArgs(
props,
getChildContext(Component.contextTypes, this.context)
);
return professor.fetchContext.push({
name: options.fetchAction,
action,
actionArgs,
component: Component.displayName || 'Anon'
});
}
componentDidMount() {
if (isPrimed(this.props, this.context)) {
log('container is primed');
return null;
}
if (!isActionable) {
log(`${Component.displayName} container is not actionable`);
return null;
}
const actionArgs = getActionArgs(this.props, this.context);
const fetch$ = action.apply(null, actionArgs);
if (__DEV__ && !Observable.isObservable(fetch$)) {
console.log(fetch$);
throw new Error(
`Action creator should return an Observable but got ${fetch$}.
Check the action creator for fetch action ${options.fetchAction}`
);
}
const subscription = fetch$.subscribe(
() => {},
options.handleError
);
return this.__subscriptions.add(subscription);
}
componentWillReceiveProps(nextProps, nextContext) {
if (
!isActionable ||
!hasRefetcher ||
!options.shouldRefetch(
this.props,
nextProps,
getChildContext(Component.contextTypes, this.context),
getChildContext(Component.contextTypes, nextContext)
)
) {
return;
}
const actionArgs = getActionArgs(
this.props,
getChildContext(Component.contextTypes, this.context)
);
const fetch$ = action.apply(null, actionArgs);
if (__DEV__ && !Observable.isObservable(fetch$)) {
throw new Error(
'fetch action should return observable'
);
}
const subscription = fetch$.subscribe(
() => {},
options.errorHandler
);
this.__subscriptions.add(subscription);
}
componentWillUnmount() {
if (this.__subscriptions) {
this.__subscriptions.dispose();
}
}
shouldComponentUpdate = shouldComponentUpdate;
render() {
const { props } = this;
return createElement(
Component,
props
);
}
};
}

View File

@ -0,0 +1,52 @@
import { Observable, Scheduler } from 'rx';
import ReactDOM from 'react-dom/server';
import debug from 'debug';
import ProfessorContext from './Professor-Context';
const log = debug('fcc:professor');
export function fetch({ fetchContext = [] }) {
if (fetchContext.length === 0) {
log('empty fetch context found');
return Observable.just(fetchContext);
}
return Observable.from(fetchContext, null, null, Scheduler.default)
.doOnNext(({ name }) => log(`calling ${name} action creator`))
.map(({ action, actionArgs }) => action.apply(null, actionArgs))
.doOnNext(fetch$ => {
if (!Observable.isObservable(fetch$)) {
throw new Error(
'action creator should return an observable'
);
}
})
.map(fetch$ => fetch$.doOnNext(action => log('action', action.type)))
.mergeAll()
.doOnCompleted(() => log('all fetch observables completed'));
}
export default function renderToString(Component) {
const fetchContext = [];
const professor = { fetchContext };
let ContextedComponent;
try {
ContextedComponent = ProfessorContext.wrap(Component, professor);
log('initiating fetcher registration');
ReactDOM.renderToStaticMarkup(ContextedComponent);
log('fetcher registration completed');
} catch (e) {
return Observable.throw(e);
}
return fetch(professor)
.last()
.delay(0)
.map(() => {
const markup = ReactDOM.renderToString(Component);
return {
markup,
fetchContext
};
});
}

View File

@ -0,0 +1,26 @@
import ReactDOM from 'react-dom';
import { Disposable, Observable } from 'rx';
import ProfessorContext from './Professor-Context';
export default function render(Component, DOMContainer) {
let ContextedComponent;
try {
ContextedComponent = ProfessorContext.wrap(Component);
} catch (e) {
return Observable.throw(e);
}
return Observable.create(observer => {
try {
ReactDOM.render(ContextedComponent, DOMContainer, function() {
observer.onNext(this);
});
} catch (e) {
return observer.onError(e);
}
return Disposable.create(() => {
return ReactDOM.unmountComponentAtNode(DOMContainer);
});
});
}

View File

@ -0,0 +1,37 @@
// original sourc
// https://github.com/rackt/react-redux/blob/master/src/utils/shallowEqual.js
// MIT license
export default function shallowEqual(objA, objB) {
if (objA === objB) {
return true;
}
if (
typeof objA !== 'object' ||
objA === null ||
typeof objB !== 'object' ||
objB === null
) {
return false;
}
var keysA = Object.keys(objA);
var keysB = Object.keys(objB);
if (keysA.length !== keysB.length) {
return false;
}
// Test for A's keys different from B.
var bHasOwnProperty = Object.prototype.hasOwnProperty.bind(objB);
for (var i = 0; i < keysA.length; i++) {
if (
!bHasOwnProperty(keysA[i]) ||
objA[keysA[i]] !== objB[keysA[i]]
) {
return false;
}
}
return true;
}

View File

@ -10,7 +10,7 @@ import {
const { defaultProfileImage } = require('../utils/constantStrings.json'); const { defaultProfileImage } = require('../utils/constantStrings.json');
const githubRegex = (/github/i); const githubRegex = (/github/i);
const debug = debugFactory('freecc:models:userIdent'); const debug = debugFactory('fcc:models:userIdent');
function createAccessToken(user, ttl, cb) { function createAccessToken(user, ttl, cb) {
if (arguments.length === 2 && typeof ttl === 'function') { if (arguments.length === 2 && typeof ttl === 'function') {
@ -73,7 +73,7 @@ export default function(UserIdent) {
} }
); );
} }
cb(err, user, identity); return cb(err, user, identity);
}); });
}); });
} }
@ -99,12 +99,12 @@ export default function(UserIdent) {
} else { } else {
query = { username: userObj.username }; query = { username: userObj.username };
} }
userModel.findOrCreate({ where: query }, userObj, function(err, user) { return userModel.findOrCreate({ where: query }, userObj, (err, user) => {
if (err) { if (err) {
return cb(err); return cb(err);
} }
var date = new Date(); var date = new Date();
userIdentityModel.create({ return userIdentityModel.create({
provider: getSocialProvider(provider), provider: getSocialProvider(provider),
externalId: profile.id, externalId: profile.id,
authScheme: authScheme, authScheme: authScheme,
@ -122,7 +122,7 @@ export default function(UserIdent) {
} }
); );
} }
cb(err, user, identity); return cb(err, user, identity);
}); });
}); });
}); });
@ -134,7 +134,7 @@ export default function(UserIdent) {
debug('no user identity instance found'); debug('no user identity instance found');
return next(); return next();
} }
userIdent.user(function(err, user) { return userIdent.user(function(err, user) {
let userChanged = false; let userChanged = false;
if (err) { return next(err); } if (err) { return next(err); }
if (!user) { if (!user) {
@ -175,11 +175,11 @@ export default function(UserIdent) {
if (userChanged) { if (userChanged) {
return user.save(function(err) { return user.save(function(err) {
if (err) { return next(err); } if (err) { return next(err); }
next(); return next();
}); });
} }
debug('exiting after user identity before save'); debug('exiting after user identity before save');
next(); return next();
}); });
}); });
} }

View File

@ -1,7 +1,7 @@
import { isAlphanumeric, isHexadecimal } from 'validator'; import { isAlphanumeric, isHexadecimal } from 'validator';
import debug from 'debug'; import debug from 'debug';
const log = debug('freecc:models:promo'); const log = debug('fcc:models:promo');
export default function promo(Promo) { export default function promo(Promo) {
Promo.getButton = function getButton(id, code, type = 'isNot') { Promo.getButton = function getButton(id, code, type = 'isNot') {

View File

@ -7,7 +7,7 @@ import debugFactory from 'debug';
import { saveUser, observeMethod } from '../../server/utils/rx'; import { saveUser, observeMethod } from '../../server/utils/rx';
import { blacklistedUsernames } from '../../server/utils/constants'; import { blacklistedUsernames } from '../../server/utils/constants';
const debug = debugFactory('freecc:user:remote'); const debug = debugFactory('fcc:user:remote');
const BROWNIEPOINTS_TIMEOUT = [1, 'hour']; const BROWNIEPOINTS_TIMEOUT = [1, 'hour'];
function getAboutProfile({ function getAboutProfile({
@ -271,7 +271,7 @@ module.exports = function(User) {
)); ));
}); });
} }
User.findOne({ where: { username } }, (err, user) => { return User.findOne({ where: { username } }, (err, user) => {
if (err) { if (err) {
return cb(err); return cb(err);
} }
@ -327,7 +327,7 @@ module.exports = function(User) {
.valueOf(); .valueOf();
const user$ = findUser({ where: { username: receiver }}); const user$ = findUser({ where: { username: receiver }});
user$ return user$
.tapOnNext((user) => { .tapOnNext((user) => {
if (!user) { if (!user) {
throw new Error(`could not find receiver for ${ receiver }`); throw new Error(`could not find receiver for ${ receiver }`);

View File

@ -19,7 +19,7 @@
import debugFactory from 'debug'; import debugFactory from 'debug';
import { Observable, AnonymousObservable, helpers } from 'rx'; import { Observable, AnonymousObservable, helpers } from 'rx';
const debug = debugFactory('freecc:ajax$'); const debug = debugFactory('fcc:ajax$');
const root = typeof window !== 'undefined' ? window : {}; const root = typeof window !== 'undefined' ? window : {};
// Gets the proper XMLHttpRequest for support for older IE // Gets the proper XMLHttpRequest for support for older IE

View File

@ -0,0 +1,48 @@
import { Observable, Disposable } from 'rx';
import Fetchr from 'fetchr';
import stampit from 'stampit';
function callbackObserver(observer) {
return (err, res) => {
if (err) {
return observer.onError(err);
}
observer.onNext(res);
return observer.onCompleted();
};
}
export default stampit({
init({ args: [ options ] }) {
this.services = new Fetchr(options);
},
methods: {
readService$({ service: resource, params, config }) {
return Observable.create(observer => {
this.services.read(
resource,
params,
config,
callbackObserver(observer)
);
return Disposable.create(() => observer.dispose());
});
},
createService$({ service: resource, params, body, config }) {
return Observable.create(observer => {
this.services.create(
resource,
params,
body,
config,
callbackObserver(observer)
);
return Disposable.create(() => observer.dispose());
});
}
}
});

View File

@ -1,5 +1,5 @@
// enable debug for gulp // enable debug for gulp
process.env.DEBUG = process.env.DEBUG || 'freecc:*'; process.env.DEBUG = process.env.DEBUG || 'fcc:*';
require('babel-core/register'); require('babel-core/register');
var Rx = require('rx'), var Rx = require('rx'),
@ -12,7 +12,7 @@ var Rx = require('rx'),
gutil = require('gulp-util'), gutil = require('gulp-util'),
reduce = require('gulp-reduce-file'), reduce = require('gulp-reduce-file'),
sortKeys = require('sort-keys'), sortKeys = require('sort-keys'),
debug = require('debug')('freecc:gulp'), debug = require('debug')('fcc:gulp'),
yargs = require('yargs'), yargs = require('yargs'),
concat = require('gulp-concat'), concat = require('gulp-concat'),
uglify = require('gulp-uglify'), uglify = require('gulp-uglify'),
@ -98,7 +98,10 @@ var paths = {
'public/bower_components/bootstrap/dist/js/bootstrap.min.js', 'public/bower_components/bootstrap/dist/js/bootstrap.min.js',
'public/bower_components/d3/d3.min.js', 'public/bower_components/d3/d3.min.js',
'public/bower_components/moment/min/moment.min.js', 'public/bower_components/moment/min/moment.min.js',
'public/bower_components/moment-timezone/builds/moment-timezone-with-data.min.js',
'public/bower_components/' +
'moment-timezone/builds/moment-timezone-with-data.min.js',
'public/bower_components/mousetrap/mousetrap.min.js', 'public/bower_components/mousetrap/mousetrap.min.js',
'public/bower_components/lightbox2/dist/js/lightbox.min.js', 'public/bower_components/lightbox2/dist/js/lightbox.min.js',
'public/bower_components/rxjs/dist/rx.all.min.js' 'public/bower_components/rxjs/dist/rx.all.min.js'
@ -194,7 +197,7 @@ gulp.task('serve', ['build-manifest'], function(cb) {
exec: path.join(__dirname, 'node_modules/.bin/babel-node'), exec: path.join(__dirname, 'node_modules/.bin/babel-node'),
env: { env: {
'NODE_ENV': process.env.NODE_ENV || 'development', 'NODE_ENV': process.env.NODE_ENV || 'development',
'DEBUG': process.env.DEBUG || 'freecc:*' 'DEBUG': process.env.DEBUG || 'fcc:*'
} }
}) })
.on('start', function() { .on('start', function() {

View File

@ -49,7 +49,7 @@
"emmet-codemirror": "^1.2.5", "emmet-codemirror": "^1.2.5",
"errorhandler": "^1.4.2", "errorhandler": "^1.4.2",
"es6-map": "~0.1.1", "es6-map": "~0.1.1",
"eslint": "~1.10.2", "eslint": "~2.2.0",
"eslint-plugin-react": "^4.1.0", "eslint-plugin-react": "^4.1.0",
"express": "^4.13.3", "express": "^4.13.3",
"express-flash": "~0.0.2", "express-flash": "~0.0.2",
@ -61,7 +61,7 @@
"gulp": "^3.9.0", "gulp": "^3.9.0",
"gulp-babel": "^6.1.1", "gulp-babel": "^6.1.1",
"gulp-concat": "^2.6.0", "gulp-concat": "^2.6.0",
"gulp-eslint": "^1.1.0", "gulp-eslint": "^2.0.0",
"gulp-jsonlint": "^1.1.0", "gulp-jsonlint": "^1.1.0",
"gulp-less": "^3.0.3", "gulp-less": "^3.0.3",
"gulp-nodemon": "^2.0.3", "gulp-nodemon": "^2.0.3",
@ -74,7 +74,7 @@
"gulp-util": "^3.0.6", "gulp-util": "^3.0.6",
"helmet": "^1.1.0", "helmet": "^1.1.0",
"helmet-csp": "^1.0.3", "helmet-csp": "^1.0.3",
"history": "^1.17.0", "history": "^2.0.0",
"jade": "^1.11.0", "jade": "^1.11.0",
"json-loader": "~0.5.2", "json-loader": "~0.5.2",
"less": "^2.5.1", "less": "^2.5.1",
@ -92,6 +92,7 @@
"node-uuid": "^1.4.3", "node-uuid": "^1.4.3",
"nodemailer": "^2.1.0", "nodemailer": "^2.1.0",
"normalize-url": "^1.3.1", "normalize-url": "^1.3.1",
"normalizr": "^2.0.0",
"object.assign": "^4.0.3", "object.assign": "^4.0.3",
"passport-facebook": "^2.0.0", "passport-facebook": "^2.0.0",
"passport-github": "^1.0.0", "passport-github": "^1.0.0",
@ -105,11 +106,18 @@
"react-bootstrap": "~0.28.1", "react-bootstrap": "~0.28.1",
"react-dom": "~0.14.3", "react-dom": "~0.14.3",
"react-motion": "~0.4.2", "react-motion": "~0.4.2",
"react-router": "^1.0.0", "react-pure-render": "^1.0.2",
"react-router-bootstrap": "https://github.com/FreeCodeCamp/react-router-bootstrap.git#freecodecamp", "react-redux": "^4.0.6",
"react-router": "^2.0.0",
"react-router-bootstrap": "~0.20.1",
"react-toastr": "^2.4.0", "react-toastr": "^2.4.0",
"react-router-redux": "^2.1.0",
"react-vimeo": "~0.1.0", "react-vimeo": "~0.1.0",
"redux": "^3.0.5",
"redux-actions": "^0.9.1",
"redux-form": "^4.1.4",
"request": "^2.65.0", "request": "^2.65.0",
"reselect": "^2.0.2",
"rev-del": "^1.0.5", "rev-del": "^1.0.5",
"rx": "^4.0.0", "rx": "^4.0.0",
"sanitize-html": "^1.11.1", "sanitize-html": "^1.11.1",

View File

@ -135,7 +135,6 @@
"Para obtener nuestra certificación verificada de Desarrollo de Interfaces, construirás 10 proyectos usando HTML, CSS, jQuery y JavaScript.", "Para obtener nuestra certificación verificada de Desarrollo de Interfaces, construirás 10 proyectos usando HTML, CSS, jQuery y JavaScript.",
"" ""
], ],
[ [
"http://i.imgur.com/Et3iD74.jpg", "http://i.imgur.com/Et3iD74.jpg",
"Una imagen de nuestro Certificado de Visualización de Datos", "Una imagen de nuestro Certificado de Visualización de Datos",
@ -414,6 +413,63 @@
"Vous pouvez également télécharger la salle de chat app pour votre ordinateur ou votre téléphone.", "Vous pouvez également télécharger la salle de chat app pour votre ordinateur ou votre téléphone.",
"https://gitter.im/apps" "https://gitter.im/apps"
] ]
],
"nameJa": "Githubアカウントを作成し、チャットに参加しましょう",
"descriptionJa": [
[
"http://i.imgur.com/EAR7Lvh.jpg",
"Gitterチャットルームの一場面です。",
"コーディングを始める前に、Free Code Camp のチャットルームに参加してください。いつでも、雑談や質問ができたりペアプログラミングをするための仲間を見つけ流ことができます。最初に Github アカウントが必要です。",
""
],
[
"http://i.imgur.com/n6GeSEm.gif",
"この gif は Github を開始するまでの流れを表しています。必要な欄に情報を入れて登録をしてください。そして Github からあなたのメールアドレス宛にメールが届きますのでアカウントを承認してください。",
"\"Open link in new tab\"をクリックして Github を開いてください。 必要な欄に情報を入力して GitHub アカウントを作ってください。実際に使われている email アドレスかを確認してください( GitHub にはこの情報が保存されます )。メールアドレス宛に GitHub からメールが来たことを確認してください。 メールにある\"verify email address\"をクリックして開いてください。<br><div class=\"small\">注意: もしすでに GitHub アカウントを持っていたら、あなたは \"Open link in new tab\" をクリックすることでこのステップを飛ばすことができます、新しく開かれたタブを閉じて \"go to my next step\" をクリックしてください。私たちはこの大事なステップが飛ばされてしまうのを防ぐために \"このステップを飛ばす\" ボタンは削除してあります。</div>",
"https://github.com/join"
],
[
"http://i.imgur.com/hFqAEr8.gif",
"この gif は Github の右上にあるプロフィール画像をクリックする方法です。あなたの写真をアップロードするか、自動で生成されるピクセルアートを利用してください。そして、残りの欄に情報を入力し submit ボタンを押してください。",
"GitHub の右上に表示されているピクセルアートをクリックしてください、そして settings を選んでください。あなたの画像をアップロードしてください。画像はあなたの顔が写っていると良いです。他のキャンパーズの仲間たちがチャットルームであなたを見かけるようになります。住んでいる場所や名前を登録することもできます",
"https://github.com/settings/profile"
],
[
"http://i.imgur.com/pYk0wOk.gif",
"この gif は GitHub のレポジトリへのスターをつける方法です。",
"オープンソースの Free Code Camp のレポジトリを開いてください。これは私たちボランティアチームの協力者が Free Code Camp で作っているものです。あなたは \"star\" を私たちのリポジトリに付けることができます。\"star を付けること\"は GitHub での \"いいね\" と一緒です。",
"https://github.com/freecodecamp/freecodecamp"
],
[
"http://i.imgur.com/OmRmLB4.gif",
"この git は私たちのチャットルームへのリンクをクリックして、\"sign in with GitHub\" ボタンをクリックしています。そして、テキストを入力してキャンパーズの仲間へメッセージを送る方法を表しています。",
"あなたは Github のアカウントを持っているので、私たちのチャットルームへ GitHub を利用してログインできます。\"Hellow world!\" と言って自己紹介をし、あなたがどうやって Free Code Camp を見つけたかや何故プログラミングを学びたいのかを私たちに話してください。",
"https://gitter.im/FreeCodeCamp/FreeCodeCamp"
],
[
"http://i.imgur.com/Ecs5XAd.gif",
"この gif は右上の settings ボタンを押すことで、通知の設定を変更する方法を表しています。",
"私たちのチャットルームはとても活発です。あなたは誰かがあなたに対してメンションを送った時にだけ通知してもらうように設定を変更した方が良いでしょう。",
""
],
[
"http://i.imgur.com/T0bGJPe.gif",
"この gif はどうやって該当するユーザに向けて個人的なメーッセージを送れるようにするかを表しています。",
"私たちのチャットルームは全て公開されているので、もしあなたが個人的な情報(メールアドレスや電話番号)を共有したい場合には、プライベートメッセージを利用してください。",
""
],
[
"http://i.imgur.com/vDTMJSh.gif",
"この gif はチャレンジとチャットルームへの行き来がタブを戻すことでできることを表しています。",
"私たちのチャレンジを通して作業をしている間はチャットルームを開いておくと良いでしょう。そうすることで、必要な時に助けを求めることができます。あなたは休憩をしているかのように他のキャンパーズと関わりを持てるでしょう。",
""
],
[
"http://i.imgur.com/WvQvNGN.gif",
"この gif は、チャットルームアプリをあなたのコンピュータに直接インストールするためにダウンロードする方法を表しています。",
"チャットルームのアプリをスマホや自分のパソコンにダウンロードして使うことができます。",
"https://gitter.im/apps"
]
] ]
}, },
{ {

View File

@ -8,15 +8,10 @@
"id": "aff0395860f5d3034dc0bfc9", "id": "aff0395860f5d3034dc0bfc9",
"title": "Validate US Telephone Numbers", "title": "Validate US Telephone Numbers",
"description": [ "description": [
"Return true if the passed string is a valid US phone number", "Return <code>true</code> if the passed string is a valid US phone number.",
"The user may fill out the form field any way they choose as long as it is a valid US number. The following are examples of valid formats for US numbers (refer to the tests below for other variants):", "The user may fill out the form field any way they choose as long as it is a valid US number. The following are examples of valid formats for US numbers (refer to the tests below for other variants):",
"<code>555-555-5555</code>", "<blockquote>555-555-5555\n(555)555-5555\n(555) 555-5555\n555 555 5555\n5555555555\n1 555 555 5555</blockquote>",
"<code>(555)555-5555</code>", "For this challenge you will be presented with a string such as <code>800-692-7753</code> or <code>8oo-six427676;laskdjf</code>. Your job is to validate or reject the US phone number based on any combination of the formats provided above. The area code is required. If the country code is provided, you must confirm that the country code is <code>1</code>. Return <code>true</code> if the string is a valid US phone number; otherwise return <code>false</code>.",
"<code>(555) 555-5555</code>",
"<code>555 555 5555</code>",
"<code>5555555555</code>",
"<code>1 555 555 5555</code>",
"For this challenge you will be presented with a string such as <code>800-692-7753</code> or <code>8oo-six427676;laskdjf</code>. Your job is to validate or reject the US phone number based on any combination of the formats provided above. The area code is required. If the country code is provided, you must confirm that the country code is <code>1</code>. Return true if the string is a valid US phone number; otherwise false.",
"Remember to use <a href='//github.com/FreeCodeCamp/freecodecamp/wiki/How-to-get-help-when-you-get-stuck' target='_blank'>Read-Search-Ask</a> if you get stuck. Try to pair program. Write your own code." "Remember to use <a href='//github.com/FreeCodeCamp/freecodecamp/wiki/How-to-get-help-when-you-get-stuck' target='_blank'>Read-Search-Ask</a> if you get stuck. Try to pair program. Write your own code."
], ],
"challengeSeed": [ "challengeSeed": [
@ -67,12 +62,7 @@
"descriptionEs": [ "descriptionEs": [
"Haz que la función devuelva true (verdadero) si el texto introducido es un número válido en los EEUU.", "Haz que la función devuelva true (verdadero) si el texto introducido es un número válido en los EEUU.",
"El usuario debe llenar el campo del formulario de la forma que desee siempre y cuando sea un número válido en los EEUU. Los números mostrados a continuación tienen formatos válidos en los EEUU:", "El usuario debe llenar el campo del formulario de la forma que desee siempre y cuando sea un número válido en los EEUU. Los números mostrados a continuación tienen formatos válidos en los EEUU:",
"<code>555-555-5555</code>", "<blockquote>555-555-5555\n(555)555-5555\n(555) 555-5555\n555 555 5555\n5555555555\n1 555 555 5555</blockquote>",
"<code>(555)555-5555</code>",
"<code>(555) 555-5555</code>",
"<code>555 555 5555</code>",
"<code>5555555555</code>",
"<code>1 555 555 5555</code>",
"Para esta prueba se te presentará una cadena de texto como por ejemplo: <code>800-692-7753</code> o <code>8oo-six427676;laskdjf</code>. Tu trabajo consiste en validar o rechazar el número telefónico tomando como base cualquier combinación de los formatos anteriormente presentados. El código de área es requrido. Si el código de país es provisto, debes confirmar que este es <code>1</code>. La función debe devolver true si la cadena de texto es un número telefónico válido en los EEUU; de lo contrario, debe devolver false.", "Para esta prueba se te presentará una cadena de texto como por ejemplo: <code>800-692-7753</code> o <code>8oo-six427676;laskdjf</code>. Tu trabajo consiste en validar o rechazar el número telefónico tomando como base cualquier combinación de los formatos anteriormente presentados. El código de área es requrido. Si el código de país es provisto, debes confirmar que este es <code>1</code>. La función debe devolver true si la cadena de texto es un número telefónico válido en los EEUU; de lo contrario, debe devolver false.",
"Recuerda utilizar <a href='//github.com/FreeCodeCamp/freecodecamp/wiki/How-to-get-help-when-you-get-stuck' target='_blank'>Read-Search-Ask</a> si te sientes atascado. Intenta programar en pareja. Escribe tu propio código." "Recuerda utilizar <a href='//github.com/FreeCodeCamp/freecodecamp/wiki/How-to-get-help-when-you-get-stuck' target='_blank'>Read-Search-Ask</a> si te sientes atascado. Intenta programar en pareja. Escribe tu propio código."
] ]
@ -270,19 +260,13 @@
"id": "a19f0fbe1872186acd434d5a", "id": "a19f0fbe1872186acd434d5a",
"title": "Friendly Date Ranges", "title": "Friendly Date Ranges",
"description": [ "description": [
"Convert a date range consisting of two dates formatted as YYYY-MM-DD into a more readable format.", "Convert a date range consisting of two dates formatted as <code>YYYY-MM-DD</code> into a more readable format.",
"", "The friendly display should use month names instead of numbers and ordinal dates instead of cardinal (<code>1st</code> instead of <code>1</code>).",
"The friendly display should use month names instead of numbers and ordinal dates instead of cardinal (\"1st\" instead of \"1\").",
"",
"Do not display information that is redundant or that can be inferred by the user: if the date range ends in less than a year from when it begins, do not display the ending year. If the range ends in the same month that it begins, do not display the ending year or month.", "Do not display information that is redundant or that can be inferred by the user: if the date range ends in less than a year from when it begins, do not display the ending year. If the range ends in the same month that it begins, do not display the ending year or month.",
"",
"Additionally, if the date range begins in the current year and ends within one year, the year should not be displayed at the beginning of the friendly range.", "Additionally, if the date range begins in the current year and ends within one year, the year should not be displayed at the beginning of the friendly range.",
"",
"Examples:", "Examples:",
"<code>friendly([\"2016-07-01\", \"2016-07-04\"])</code> should return <code>[\"July 1st\",\"4th\"]</code>", "<code>friendly([\"2016-07-01\", \"2016-07-04\"])</code> should return <code>[\"July 1st\",\"4th\"]</code>",
"",
"<code>friendly([\"2016-07-01\", \"2018-07-04\"])</code> should return <code>[\"July 1st, 2016\", \"July 4th, 2018\"]</code>.", "<code>friendly([\"2016-07-01\", \"2018-07-04\"])</code> should return <code>[\"July 1st, 2016\", \"July 4th, 2018\"]</code>.",
"",
"Remember to use <a href='//github.com/FreeCodeCamp/freecodecamp/wiki/How-to-get-help-when-you-get-stuck' target='_blank'>Read-Search-Ask</a> if you get stuck. Try to pair program. Write your own code." "Remember to use <a href='//github.com/FreeCodeCamp/freecodecamp/wiki/How-to-get-help-when-you-get-stuck' target='_blank'>Read-Search-Ask</a> if you get stuck. Try to pair program. Write your own code."
], ],
"challengeSeed": [ "challengeSeed": [
@ -416,9 +400,9 @@
"id": "a3f503de51cfab748ff001aa", "id": "a3f503de51cfab748ff001aa",
"title": "Pairwise", "title": "Pairwise",
"description": [ "description": [
"Return the sum of all indices of elements of 'arr' that can be paired with one other element to form a sum that equals the value in the second argument 'arg'. If multiple sums are possible, return the smallest sum. Once an element has been used, it cannot be reused to pair with another.", "Return the sum of all element indices of array <code>arr</code> that can be paired with one other element to form a sum that equals the value in the second argument <code>arg</code>. If multiple sums are possible, return the smallest sum. Once an element has been used, it cannot be reused to pair with another.",
"For example, pairwise([1, 4, 2, 3, 0, 5], 7) should return 11 because 4, 2, 3 and 5 can be paired with each other to equal 7 and their indices (1, 2, 3, and 5) sum to 11.", "For example, <code>pairwise([1, 4, 2, 3, 0, 5], 7)</code> should return <code>11</code> because 4, 2, 3 and 5 can be paired with each other to equal 7 and their indices (1, 2, 3, and 5) sum to 11.",
"pairwise([1, 3, 2, 4], 4) would only equal 1, because only the first two elements can be paired to equal 4, and the first element has an index of 0!", "<code>pairwise([1, 3, 2, 4], 4)</code> would only return <code>1</code>, because only the first two elements can be paired to equal 4, and the first element has an index of 0!",
"Remember to use <a href='//github.com/FreeCodeCamp/freecodecamp/wiki/How-to-get-help-when-you-get-stuck' target='_blank'>Read-Search-Ask</a> if you get stuck. Try to pair program. Write your own code." "Remember to use <a href='//github.com/FreeCodeCamp/freecodecamp/wiki/How-to-get-help-when-you-get-stuck' target='_blank'>Read-Search-Ask</a> if you get stuck. Try to pair program. Write your own code."
], ],
"challengeSeed": [ "challengeSeed": [

View File

@ -585,7 +585,8 @@
"tests": [ "tests": [
"assert.deepEqual(bouncer([7, \"ate\", \"\", false, 9]), [7, \"ate\", 9], 'message: <code>bouncer([7, \"ate\", \"\", false, 9])</code> should return <code>[7, \"ate\", 9]</code>.');", "assert.deepEqual(bouncer([7, \"ate\", \"\", false, 9]), [7, \"ate\", 9], 'message: <code>bouncer([7, \"ate\", \"\", false, 9])</code> should return <code>[7, \"ate\", 9]</code>.');",
"assert.deepEqual(bouncer([\"a\", \"b\", \"c\"]), [\"a\", \"b\", \"c\"], 'message: <code>bouncer([\"a\", \"b\", \"c\"])</code> should return <code>[\"a\", \"b\", \"c\"]</code>.');", "assert.deepEqual(bouncer([\"a\", \"b\", \"c\"]), [\"a\", \"b\", \"c\"], 'message: <code>bouncer([\"a\", \"b\", \"c\"])</code> should return <code>[\"a\", \"b\", \"c\"]</code>.');",
"assert.deepEqual(bouncer([false, null, 0, NaN, undefined, \"\"]), [], 'message: <code>bouncer([false, null, 0, NaN, undefined, \"\"])</code> should return <code>[]</code>.');" "assert.deepEqual(bouncer([false, null, 0, NaN, undefined, \"\"]), [], 'message: <code>bouncer([false, null, 0, NaN, undefined, \"\"])</code> should return <code>[]</code>.');",
"assert.deepEqual(bouncer([1, null, NaN, 2, undefined]), [1, 2], 'message: <code>bouncer([1, null, NaN, 2, undefined])</code> should return <code>[1, 2]</code>.');"
], ],
"type": "bonfire", "type": "bonfire",
"MDNlinks": [ "MDNlinks": [

View File

@ -60,31 +60,31 @@
[ [
"http://i.imgur.com/WBetuBa.jpg", "http://i.imgur.com/WBetuBa.jpg",
"Un programador frustado golpeando la pantalla de su computador.", "Un programador frustado golpeando la pantalla de su computador.",
"Nuestros desafíos sobre algoritmos son difíciles. Algunos pueden requerir muchas horas para resolverse. Podrás frustarte, pero no te rindas.", "Nuestros desafíos sobre algoritmos son difíciles. Algunos pueden requerir muchas horas para resolverse. Podrás frustarte, pero no te rindas. Se vuelve fácil con práctica.",
"" ""
], ],
[ [
"http://i.imgur.com/p2TpOQd.jpg", "http://i.imgur.com/p2TpOQd.jpg",
"Un tierno perro que salta sobre un obstáculo, pica el ojo y te apunta con su pata.", "Un tierno perro que salta sobre un obstáculo, pica el ojo y te apunta con su pata.",
"Cuando te atasques, usa la metodología Leer-Buscar-Preguntar. No te preocupes - ya lo has entendido.", "Cuando te atasques, usa la metodología Leer-Buscar-Preguntar. No te preocupes - lo tienes resuelto.",
"" ""
], ],
[ [
"http://i.imgur.com/G1saeDt.gif", "http://i.imgur.com/G1saeDt.gif",
"Un gif que muestra cómo crear una cuenta en Codepen.", "Un gif que muestra cómo crear una cuenta en Codepen.",
"Para nuestros desafíos de interfaces, usaremos un editor muy famoso llamado Codepen, el cual es completamente basado en el navegador. Abre CodePen y pulsa en \"Sign up\" en la esquina superior derecha, luego ve hacia abajo donde se encuentra el plan gratuito (free plan) y pulsa en \"Sign up\". Da clic en el botón que dice \"Use info from GitHub\", luego agrega tu dirección de correo electrónico y crea una contraseña. Pulsa el botón que dice \"Sign up\". Luego, en la esquina superior derecha , da clic en \"New pen\".", "Para nuestros desafíos de interfaces, usaremos un editor de código basado en el navegador que es muy famoso llamado Codepen. Pulsa en el botón de abajo \"Open link in new tab\" para abrir la página de registro de CodePen. Rellena el formulario y pulsa \"Sign up\". <br><div class=\"small\">Nota: Si ya tienes una cuenta de CodePen, puedes omitir este paso pulsando \"Open link in new tab\", cierra la nueva pestaña que se abre, entonces pulsa \"go to my next step\". Eliminamos nuestro botón \"skip step\" porque mucha gente solamente pulsa el botón sin realizar estos importantes pasos.</div>",
"http://codepen.io/signup/free" "http://codepen.io/signup/free"
], ],
[ [
"http://i.imgur.com/U4y9RJ1.gif", "http://i.imgur.com/U4y9RJ1.gif",
"Un gif que muestra que puedes escribir \"hello world\" en el editor, lo cual escribirá \"hello world\" en la ventana de vista previa. También puedes mover las ventanas para cambiar su tamaño, y cambiar su orientación.", "Un gif que muestra que puedes escribir \"hello world\" en el editor, lo cual escribirá \"hello world\" en la ventana de vista previa. También puedes mover las ventanas para cambiar su tamaño, y cambiar su orientación.",
"En la ventana de HTML, crea un elemento <code>h1</code> con el texto \"Hola mundo\". Puedes arrastrar los bordes de las ventanas para cambiar su tamaño. También puedes pulsar el botón de \"Change View\" para cambiar la orientación de las ventanas.", "En la ventana de HTML, crea un elemento h1 con el texto \"Hola mundo\". Puedes arrastrar los bordes de las ventanas para cambiar su tamaño. También puedes pulsar el botón de \"Change View\" para cambiar la orientación de las ventanas.",
"" ""
], ],
[ [
"http://i.imgur.com/G9KFQDL.gif", "http://i.imgur.com/G9KFQDL.gif",
"Un gif que muestra el proceso de agregar Bootstrap a tu proyecto.", "Un gif que muestra el proceso de agregar Bootstrap a tu proyecto.",
"Pulsa el engrane en la esquina superior izquierda de la ventana de CSS, luego ve hacia abajo hasta donde dice \"Quick add\" y elige Bootstrap. Ahora dale a tu elemento <code>h1</code> la clase \"text-primary\" para cambiar su color y verificar que Bootstrap está activado.", "Pulsa el engrane en la esquina superior izquierda de la ventana de CSS, luego ve hacia abajo hasta donde dice \"Quick add\" y elige Bootstrap. Ahora dale a tu elemento h1 la clase \"text-primary\" para cambiar su color y verificar que Bootstrap está activado.",
"" ""
] ]
], ],
@ -112,13 +112,13 @@
"challengeType": 3, "challengeType": 3,
"nameEs": "Construye una página Tributo", "nameEs": "Construye una página Tributo",
"descriptionEs": [ "descriptionEs": [
"<span class='text-info'>Objetivo:</span> Crea una aplicación con <a href='http://codepen.io' target='_blank'>CodePen.io</a> que funcionalmente sea similar a esta: <a href='http://codepen.io/FreeCodeCamp/full/wMQrXV' target='_blank'>http://codepen.io/FreeCodeCamp/full/wMQrXV</a>", "<strong>Objetivo:</strong> Crea una aplicación con <a href='http://codepen.io' target='_blank'>CodePen.io</a> que funcionalmente sea similar a esta: <a href='http://codepen.io/FreeCodeCamp/full/wMQrXV' target='_blank'>http://codepen.io/FreeCodeCamp/full/wMQrXV</a>",
"<span class='text-info'>Regla #1:</span> No veas el código del proyecto de ejemplo en CodePen. Encuentra la forma de hacerlo por tu cuenta.", "<strong>Regla #1:</strong> No veas el código del proyecto de ejemplo. Encuentra la forma de hacerlo por tu cuenta.",
"<span class='text-info'>Regla #2:</span> Satisface las siguientes <a href='http://en.wikipedia.org/wiki/User_story' target='_blank'>historias de usuario</a>. Usa cualquier librería que necesites. Dale tu estilo personal.", "<strong>Regla #2:</strong> Satisface las siguientes <a href='http://en.wikipedia.org/wiki/User_story' target='_blank'>historias de usuario</a>. Usa cualquier librería que necesites. Dale tu estilo personal.",
"<span class='text-info'>Historia de usuario:</span> Puedo ver una página tributo con una imagen y un texto.", "<strong>Historia de usuario:</strong> Puedo ver una página tributo con una imagen y texto.",
"<span class='text-info'>Historia de usuario:</span> Puedo pulsar un enlace que me llevará a un sitio web externo con mayor información sobre el tema.", "<strong>Historia de usuario:</strong> Puedo pulsar en un enlace que me llevará a un sitio web externo con mayor información sobre el tema.",
"Recuerda utilizar <a href='//github.com/FreeCodeCamp/freecodecamp/wiki/How-to-get-help-when-you-get-stuck' target='_blank'>Leer-Buscar-Preguntar</a> si te sientes atascado.", "Recuerda utilizar <a href='//github.com/FreeCodeCamp/freecodecamp/wiki/How-to-get-help-when-you-get-stuck' target='_blank'>Leer-Buscar-Preguntar</a> si te sientes atascado.",
"Cuando hayas terminado, pulsa el botón de \"I've completed this challenge\" e incluye un link a tu CodePen. ", "Cuando hayas terminado, pulsa el botón \"I've completed this challenge\" e incluye un link a tu CodePen. ",
"Puedes obtener retroalimentación sobre tu proyecto por parte de otros campistas, compartiéndolo en nuestra <a href='//gitter.im/freecodecamp/codereview' target='_blank'>Sala de chat para revisión de código</a>. También puedes compartirlo en Twitter y en el campamento de tu ciudad (en Facebook)." "Puedes obtener retroalimentación sobre tu proyecto por parte de otros campistas, compartiéndolo en nuestra <a href='//gitter.im/freecodecamp/codereview' target='_blank'>Sala de chat para revisión de código</a>. También puedes compartirlo en Twitter y en el campamento de tu ciudad (en Facebook)."
], ],
"isRequired": true "isRequired": true
@ -167,18 +167,18 @@
], ],
"nameEs": "Construye una página web para tu portafolio", "nameEs": "Construye una página web para tu portafolio",
"descriptionEs": [ "descriptionEs": [
"<span class='text-info'>Objetivo:</span> Crea una aplicación con <a href='http://codepen.io' target='_blank'>CodePen.io</a> cuya funcionalidad sea similar a la de esta: <a href='http://codepen.io/FreeCodeCamp/full/VemmoX/' target='_blank'>http://codepen.io/FreeCodeCamp/full/VemmoX/</a>.", "<strong>Objetivo:</strong> Crea una aplicación con <a href='http://codepen.io' target='_blank'>CodePen.io</a> cuya funcionalidad sea similar a la de esta: <a href='http://codepen.io/FreeCodeCamp/full/VemmoX/' target='_blank'>http://codepen.io/FreeCodeCamp/full/VemmoX/</a>.",
"<span class='text-info'>Regla #1:</span> No veas el código del proyecto de ejemplo en CodePen. Encuentra la forma de hacerlo por tu cuenta.", "<strong>Regla #1:</strong> No veas el código del proyecto de ejemplo. Encuentra la forma de hacerlo por tu cuenta.",
"<span class='text-info'>Regla #2:</span> Satisface las siguientes <a href='http://en.wikipedia.org/wiki/User_story' target='_blank'>historias de usuario</a>. Usa cualquier librería que necesites. Dale tu estilo personal.", "<strong>Regla #2:</strong> Satisface las siguientes <a href='http://en.wikipedia.org/wiki/User_story' target='_blank'>historias de usuario</a>. Usa cualquier librería que necesites. Dale tu estilo personal.",
"<span class='text-info'>Historia de usuario:</span> Puedo acceder a todo el contenido de la página del portafolio con sólo desplazarme en la ventana.", "<strong>Historia de usuario:</strong> Puedo acceder a todo el contenido de la página del portafolio con sólo desplazarme en la ventana.",
"<span class='text-info'>Historia de usuario:</span> Puedo pulsar diferentes botones que me llevarán a las páginas de las diferentes cuentas de redes sociales del creador del portafolio.", "<strong>Historia de usuario:</strong> Puedo pulsar diferentes botones que me llevarán a las páginas de las diferentes cuentas de redes sociales del creador del portafolio.",
"<span class='text-info'>Historia de usuario:</span> Puedo ver una imagen de los diferentes proyectos que el creador del portafolio ha construido (si no has construido ningún sitio web antes, usa plantillas.)", "<strong>Historia de usuario:</strong> Puedo ver una imagenes en miniatura de los diferentes proyectos que el creador del portafolio ha construido (si no has construido ningún sitio web antes, usa marcadores de posición.)",
"<span class='text-info'>Historia de usuario opcional:</span> Puedo navegar a las diferentes secciones de la página web pulsando botones de navegación.", "<strong>Historia de usuario:</strong> Puedo navegar a las diferentes secciones de la página web pulsando botones de navegación.",
"No te preocupes si no tienes nada que mostrar en tu portafolio todavía - en los siguientes desafíos crearás varias apps en CodePen, así que puedes regresar luego para actualizar tu portafolio.", "No te preocupes si no tienes nada que mostrar en tu portafolio todavía - en los siguientes desafíos crearás varias aplicaciones en CodePen, así que puedes regresar luego para actualizar tu portafolio.",
"Hay varias buenas plantillas, pero para este desafío, tendrás que construir la página web de tu portafolio completamente por tu cuenta. Usar Bootstrap hará el trabajo mucho más fácil para ti.", "Hay varias plantillas buenas, pero para este desafío, tendrás que construir la página web de tu portafolio completamente por tu cuenta. Usar Bootstrap hará el trabajo mucho más fácil para ti.",
"Ten en mente que CodePen.io ignora la función Window.open(), así que si quieres abrir alguna ventana usando jQuery, necesitarás utilizar como objetivo un elemento de ancla invisible como el siguiente: <code>&lt;a target='_blank'&gt;</a></code>.", "Ten en mente que CodePen.io ignora la función Window.open(), así que si quieres abrir alguna ventana usando jQuery, necesitarás utilizar como objetivo un elemento de ancla invisible como el siguiente: <code>&lt;a target='_blank'&gt;</a></code>.",
"Recuerda utilizar <a href='//github.com/FreeCodeCamp/freecodecamp/wiki/How-to-get-help-when-you-get-stuck' target='_blank'>Read-Search-Ask</a> si te sientes atascado.", "Recuerda utilizar <a href='//github.com/FreeCodeCamp/freecodecamp/wiki/How-to-get-help-when-you-get-stuck' target='_blank'>Leer-Buscar-Preguntar</a> si te sientes atascado.",
"Cuando hayas terminado, pulsa el botón de \"I've completed this challenge\" e incluye un link a tu CodePen. ", "Cuando hayas terminado, pulsa el botón \"I've completed this challenge\" e incluye un link a tu CodePen. ",
"Puedes obtener retroalimentación sobre tu proyecto por parte de otros campistas, compartiéndolo en nuestra <a href='//gitter.im/freecodecamp/codereview' target='_blank'>Sala de chat para revisión de código</a>. También puedes compartirlo en Twitter y en el campamento de tu ciudad (en Facebook)." "Puedes obtener retroalimentación sobre tu proyecto por parte de otros campistas, compartiéndolo en nuestra <a href='//gitter.im/freecodecamp/codereview' target='_blank'>Sala de chat para revisión de código</a>. También puedes compartirlo en Twitter y en el campamento de tu ciudad (en Facebook)."
], ],
"isRequired": true, "isRequired": true,

View File

@ -22,7 +22,7 @@
"descriptionEs": [ "descriptionEs": [
[ [
"http://i.imgur.com/vJyiXzU.gif", "http://i.imgur.com/vJyiXzU.gif",
"Un gif mostrando como puedes pulsar el enlace que está más adelante y llenar todos los campos necesarios para agregar los estudios de Free Code Camp a tu perfil de LinkedIn", "Un gif mostrando como puedes pulsar el enlace de abajo y llenar todos los campos necesarios para agregar los estudios de Free Code Camp a tu perfil de LinkedIn",
"LinkedIn reconoce a Free Code Camp como una universidad. Puedes obtener acceso a nuestra larga red de alumnos agregando Free Code Camp a la sección de educación de tu LinkedIn. Define tu fecha de graduación para el siguiente año. En el campo \"Grado\", escribe \"Certificación de Desarrollo Web Full Stack\". En \"Campo de estudio\", escribe \"Ingeniería de Software\". Después pulsa \"Guardar Cambios\".", "LinkedIn reconoce a Free Code Camp como una universidad. Puedes obtener acceso a nuestra larga red de alumnos agregando Free Code Camp a la sección de educación de tu LinkedIn. Define tu fecha de graduación para el siguiente año. En el campo \"Grado\", escribe \"Certificación de Desarrollo Web Full Stack\". En \"Campo de estudio\", escribe \"Ingeniería de Software\". Después pulsa \"Guardar Cambios\".",
"https://www.linkedin.com/profile/edit-education?school=Free+Code+Camp" "https://www.linkedin.com/profile/edit-education?school=Free+Code+Camp"
] ]
@ -44,13 +44,13 @@
"releasedOn": "February 10, 2016", "releasedOn": "February 10, 2016",
"type": "Waypoint", "type": "Waypoint",
"challengeType": 7, "challengeType": 7,
"nameEs": "Translate", "nameEs": "Unete a nuestro Subreddit",
"descriptionEs": [ "descriptionEs": [
[ [
"http://i.imgur.com/DYjJuCG.gif", "http://i.imgur.com/DYjJuCG.gif",
"", "Un gif mostrando como puedes crear una cuenta de Reddit y unirte a Free Code Camp subreddit.",
"", "Nuestra comunidad tiene su propio subreddit en Reddit. Esta es una manera conveniente de hacer preguntas y compartir enlaces con toda nuestra comunidad. Si aún no dispones de una cuenta de Reddit, puedes crear una en unos segundos - ni siquiera necesitas una dirección de correo electrónico. A continuación, puedes pulsar el botón \"subscribe\" para unirte a nuestro subreddit. También puedes suscribirte a otros subreddits que estan listados en la barra lateral.",
"" "https://reddit.com/r/freecodecamp"
] ]
] ]
}, },
@ -76,19 +76,19 @@
"type": "Waypoint", "type": "Waypoint",
"challengeType": 7, "challengeType": 7,
"releasedOn": "February 10, 2016", "releasedOn": "February 10, 2016",
"nameEs": "Translate", "nameEs": "Lee noticias de codificación en nuestros canal de publicaciones Medium",
"descriptionEs": [ "descriptionEs": [
[ [
"http://i.imgur.com/FxSOL4a.gif", "http://i.imgur.com/FxSOL4a.gif",
"", "Un gif mostrando cómo crear una cuenta en Medium.",
"", "Nuestra comunidad tiene un canal de publicaciones Medium, donde escribimos un montón de artículos sobre desarrollo de software. Si aún no dispones de una cuenta Medium, puedes seguir el enlace y registrarte usando una red social o ingresando un correo electrónico (enviarán un correo electrónico que debes abrirlo para crear tu cuenta.) Selecciona un tema de interés, puedes continuar a través de los pasos.",
"" "https://www.medium.com"
], ],
[ [
"http://i.imgur.com/zhhywSX.gif", "http://i.imgur.com/zhhywSX.gif",
"", "Un gif mostrando cómo puedes pulsar el botón \"follow\" para seguir las publicaciones de Free Code Camp.",
"", "Una vez que inicias sesión, puedes ir al canal de publicaciones de Free Code Camp Medium y pulsar \"follow\". Nuestros campistas publican varios artículos cada semana.",
"" "https://medium.freecodecamp.com"
] ]
] ]
}, },
@ -108,12 +108,12 @@
"type": "Waypoint", "type": "Waypoint",
"challengeType": 7, "challengeType": 7,
"releasedOn": "February 10, 2016", "releasedOn": "February 10, 2016",
"nameEs": "Translate", "nameEs": "Miranos programar en vivo por Twitch.tv",
"descriptionEs": [ "descriptionEs": [
[ [
"http://i.imgur.com/8rtyRY1.gif", "http://i.imgur.com/8rtyRY1.gif",
"", "Un gif mostrando cómo resgistrarse en Twitch.tv y seguir nuestro canal.",
"", "Nuestros campistas programan en vivo con frecuencia en Twitch.tv, un sitio web popular de streaming. Puedes crear una cuenta en menos de un minuto, luego, sigue al canal de Free Code Camp. Cuando sigas al canal, verás la opción de recibir una notificación por correo electrónico cada vez que uno de nuestros campistas esté en vivo. Puedes unirte a docenas de otros campistas y verlos programar, e interactuar en una sala de chat. Esta es una manera divertida e informal de aprender observando a las personas a construir proyectos.",
"https://twitch.tv/freecodecamp" "https://twitch.tv/freecodecamp"
] ]
] ]
@ -138,7 +138,7 @@
[ [
"http://i.imgur.com/Og1ifsn.gif", "http://i.imgur.com/Og1ifsn.gif",
"Un gif mostrando como te puedes comprometer con una meta para tus estudios de Free Code Camp y prometer una donación mensual a una organización sin fines de lucro para darte motivación externa de alcanzar esa meta.", "Un gif mostrando como te puedes comprometer con una meta para tus estudios de Free Code Camp y prometer una donación mensual a una organización sin fines de lucro para darte motivación externa de alcanzar esa meta.",
"Puedes poner una meta y prometer donar mensualmente a una organización sin fines de lucro hasta que alcances tu meta. Esto te dará motivación externa en tu aventura de aprender a programar, así como una oportunidad para ayudar inmediatamente a organizaciones sin fines de lucro. Elige tu meta, después elige tu donativo mensual. Cuando pulses en \"comprometerse\", la página de donación de la organización sin fines de lucro se abrirá en una nueva pestaña. Esto es completamente opcional, y puedes cambiar tu compromiso o detenerlo en cualquier momento.", "Puedes poner una meta y prometer donar mensualmente a una organización sin fines de lucro hasta que alcances tu meta. Esto te dará motivación externa en tu aventura de aprender a programar, así como una oportunidad para ayudar inmediatamente a organizaciones sin fines de lucro. Elige tu meta, después elige tu donativo mensual. Cuando pulses \"commit\", la página de donación de la organización sin fines de lucro se abrirá en una nueva pestaña. Esto es completamente opcional, y puedes cambiar tu compromiso o detenerlo en cualquier momento.",
"/comprometerse" "/comprometerse"
] ]
] ]

View File

@ -108,7 +108,7 @@
"</div>" "</div>"
], ],
"tests": [ "tests": [
"assert(code.match(/\\$\\s*?\\(\\s*?(?:'|\")\\.message(?:'|\")\\s*?\\)\\s*?\\.html\\s*?\\(\\s*?(?:'|\")Here\\sis\\sthe\\smessage(?:'|\")\\s*?\\);/gi), 'message: Clicking the \"Get Message\" button should give the element with the class <code>message</code> the text \"Here is the message\".');" "assert(code.match(/\\$\\s*?\\(\\s*?(?:'|\")\\.message(?:'|\")\\s*?\\)\\s*?(\\.html|\\.text)\\s*?\\(\\s*?(?:'|\")Here\\sis\\sthe\\smessage(?:'|\")\\s*?\\);/gi), 'message: Clicking the \"Get Message\" button should give the element with the class <code>message</code> the text \"Here is the message\".');"
], ],
"type": "waypoint", "type": "waypoint",
"challengeType": 0, "challengeType": 0,

View File

@ -1110,7 +1110,37 @@
] ]
], ],
"type": "hike", "type": "hike",
"challengeType": 6 "challengeType": 6,
"nameEs": "Seguridad en los computadores",
"descriptionEs": [
"Lo fundamental de la seguridad en los computadores y cómo proteger su información.",
"Echemos una mirada a la seguridad en los computadores.",
"De lo primero que vamos a hablar es de algo conocido como ataque diccionario que se dirige a sus claves.",
"Cuando tu creas una clave, algunos lugares de internet te exigen claves muy específicas y complejas, las cuales con frecuencia no son tan necesarias.",
"El tipo de ataque que ellos quieren evitar son los llamados ataques diccionario.",
"Los ataques diccionario están programados para probar todas las palabras de un diccionario o todas las claves mas usuales, para una gran cantidad de nombres de usuario, registradas en sus propias bases de datos. ",
"Su tu clave es Canguro, probablemente podrá ser descubierta ya que es una palabra simple.",
"Aunque algunas claves no son tan sencillas como la anterior, esto tampoco importa.",
"Ellos atacan tal cantidad de cuentas que solo necesitan unas pocas claves que sean sencillas.",
"Cundo se crea una clave, tú usas letras mayúsculas, minúsculas, números y símbolos, pero una de las formas más eficientes es seleccionar números y letras al azar (ejemplo: canguroSyCA67).",
"Por lo tanto mantente lejos de los números seguidos.",
"Suplantación es otra forma de ataque del cual debes estar pendiente. Son correos o páginas web que simulan sitios seguros y engañan a las personas para obtener su información personlal. Son sitios que no están conectados a donde dicen que estan.",
"En general hay dos formas de resguardarse de estos ataques: verificar la dirección web de la página y abrir la dirección de la página web, en otro navegador para poder comprobar si corresponde con el sitio del que dicen que procede.",
"Ahora hablemos sobre cifrado y HTTPS",
"Si te encuentras comprando en una página de internet de una tienda muy prestigiosa y necesitas dar los datos de tu tarjeta de crédito es muy razonable que sospeches que alguien puede estar viendo y copiando esta información.",
"Es imprescindible que te fijes que la dirección de esta página web, en tu navegador, comienza con letras verdes y con HTTPS en vez de comenzar con solo HTTP.",
"Esto significa que el sitio por el que estás enviando esta información, la envía en forma codificada",
"En general, asegúrate que la página sea HTTPS si necesita escribir información privada o importante.",
"Otras recomendaciones importantes: no uses la misma clave para diferentes cuentas importantes",
"Si el sitio que usas ha sido vulnerado, tu clave puede estar comprometida independientemente de su fortaleza.",
"Tampoco descargues archivos desconocidos.",
"Se precavido si no reconoces archivos de tipo .pdf, .txt, .jpg.",
"Algunos de estos archivos pueden ser muy potentes e incluso correr dentro de tu computador.",
"Por último, mantén tu software actualizado, en especial el software que interactúa con internet.",
"Una de las formas en que se puede comprometer tu información es cuando los 'los chicos malos' encuentran huecos en las versiones viejas de los programas",
"Estos defectos se corrigen en las versiones nuevas, por lo que evitarás problemas si mantienes el software actualizado.",
"Estas son algunas cosas muy básicas que debes saber acerca de cómo proteger la información de tu computador."
]
} }
] ]
} }

View File

@ -1,7 +1,7 @@
import { Observable } from 'rx'; import { Observable } from 'rx';
import debugFactory from 'debug'; import debugFactory from 'debug';
const debug = debugFactory('freecc:user:remote'); const debug = debugFactory('fcc:user:remote');
function destroyAllRelated(id, Model) { function destroyAllRelated(id, Model) {
return Observable.fromNodeCallback( return Observable.fromNodeCallback(
@ -21,7 +21,7 @@ module.exports = function(app) {
if (!id) { if (!id) {
return next(); return next();
} }
Observable.combineLatest( return Observable.combineLatest(
destroyAllRelated(id, UserIdentity), destroyAllRelated(id, UserIdentity),
destroyAllRelated(id, UserCredential), destroyAllRelated(id, UserCredential),
function(identData, credData) { function(identData, credData) {
@ -30,7 +30,8 @@ module.exports = function(app) {
credData: credData credData: credData
}; };
} }
).subscribe( )
.subscribe(
function(data) { function(data) {
debug('deleted', data); debug('deleted', data);
}, },
@ -82,15 +83,15 @@ module.exports = function(app) {
}; };
debug('sending welcome email'); debug('sending welcome email');
Email.send(mailOptions, function(err) { return Email.send(mailOptions, function(err) {
if (err) { return next(err); } if (err) { return next(err); }
req.logIn(user, function(err) { return req.logIn(user, function(err) {
if (err) { return next(err); } if (err) { return next(err); }
req.flash('success', { req.flash('success', {
msg: [ "Welcome to Free Code Camp! We've created your account." ] msg: [ "Welcome to Free Code Camp! We've created your account." ]
}); });
res.redirect(redirect); return res.redirect(redirect);
}); });
}); });
}); });

View File

@ -1,14 +1,13 @@
import React from 'react'; import React from 'react';
import { RoutingContext } from 'react-router'; import { RouterContext } from 'react-router';
import Fetchr from 'fetchr'; import debug from 'debug';
import { createLocation } from 'history';
import debugFactory from 'debug'; import renderToString from '../../common/app/utils/render-to-string';
import { dehydrate } from 'thundercats'; import provideStore from '../../common/app/provide-store';
import { renderToString$ } from 'thundercats-react';
import app$ from '../../common/app'; import app$ from '../../common/app';
const debug = debugFactory('freecc:react-server'); const log = debug('fcc:react-server');
// add routes here as they slowly get reactified // add routes here as they slowly get reactified
// remove their individual controllers // remove their individual controllers
@ -38,52 +37,43 @@ export default function reactSubRouter(app) {
app.use(router); app.use(router);
function serveReactApp(req, res, next) { function serveReactApp(req, res, next) {
const services = new Fetchr({ req }); const serviceOptions = { req };
const location = createLocation(req.path); app$({
location: req.path,
// returns a router wrapped app serviceOptions
app$({ location }) })
// if react-router does not find a route send down the chain // if react-router does not find a route send down the chain
.filter(function({ props }) { .filter(({ redirect, props }) => {
if (!props && redirect) {
res.redirect(redirect.pathname + redirect.search);
}
if (!props) { if (!props) {
debug('react tried to find %s but got 404', location.pathname); log(`react tried to find ${location.pathname} but got 404`);
return next(); return next();
} }
return !!props; return !!props;
}) })
.flatMap(function({ props, AppCat }) { .flatMap(({ props, store }) => {
const cat = AppCat(null, services); log('render react markup and pre-fetch data');
debug('render react markup and pre-fetch data');
const store = cat.getStore('appStore');
// primes store to observe action changes return renderToString(
// cleaned up by cat.dispose further down provideStore(React.createElement(RouterContext, props), store)
store.subscribe(() => {});
return renderToString$(
cat,
React.createElement(RoutingContext, props)
) )
.flatMap( .map(({ markup }) => ({ markup, store }));
dehydrate(cat),
({ markup }, data) => ({ markup, data, cat })
);
}) })
.flatMap(function({ data, markup, cat }) { .flatMap(function({ markup, store }) {
debug('react markup rendered, data fetched'); log('react markup rendered, data fetched');
cat.dispose(); const state = store.getState();
const { title } = data.AppStore; const { title } = state.app.title;
res.expose(data, 'data'); res.expose(state, 'data');
return res.render$( return res.render$(
'layout-react', 'layout-react',
{ markup, title } { markup, title }
); );
}) })
.doOnNext(markup => res.send(markup))
.subscribe( .subscribe(
function(markup) { () => log('html rendered and ready to send'),
debug('html rendered and ready to send');
res.send(markup);
},
next next
); );
} }

View File

@ -22,7 +22,7 @@ import {
import certTypes from '../utils/certTypes.json'; import certTypes from '../utils/certTypes.json';
const log = debug('freecc:certification'); const log = debug('fcc:certification');
const sendMessageToNonUser = ifNoUserSend( const sendMessageToNonUser = ifNoUserSend(
'must be logged in to complete.' 'must be logged in to complete.'
); );

View File

@ -26,7 +26,7 @@ import badIdMap from '../utils/bad-id-map';
const isDev = process.env.NODE_ENV !== 'production'; const isDev = process.env.NODE_ENV !== 'production';
const isBeta = !!process.env.BETA; const isBeta = !!process.env.BETA;
const log = debug('freecc:challenges'); const log = debug('fcc:challenges');
const challengesRegex = /^(bonfire|waypoint|zipline|basejump|checkpoint)/i; const challengesRegex = /^(bonfire|waypoint|zipline|basejump|checkpoint)/i;
const challengeView = { const challengeView = {
0: 'challenges/showHTML', 0: 'challenges/showHTML',
@ -120,6 +120,7 @@ function shouldShowNew(element, block) {
}, 0); }, 0);
return newCount / block.length * 100 === 100; return newCount / block.length * 100 === 100;
} }
return null;
} }
// meant to be used with a filter method // meant to be used with a filter method
@ -516,7 +517,7 @@ module.exports = function(app) {
if (data.id) { if (data.id) {
res.cookie('currentChallengeId', data.id); res.cookie('currentChallengeId', data.id);
} }
res.render(view, data); return res.render(view, data);
}, },
next, next,
function() {} function() {}
@ -525,14 +526,12 @@ module.exports = function(app) {
function completedChallenge(req, res, next) { function completedChallenge(req, res, next) {
req.checkBody('id', 'id must be a ObjectId').isMongoId(); req.checkBody('id', 'id must be a ObjectId').isMongoId();
req.checkBody('name', 'name must be at least 3 characters') req.checkBody('name', 'name must be at least 3 characters')
.isString() .isString()
.isLength({ min: 3 }); .isLength({ min: 3 });
req.checkBody('challengeType', 'challengeType must be an integer') req.checkBody('challengeType', 'challengeType must be an integer')
.isNumber() .isNumber();
.isInt();
const type = accepts(req).type('html', 'json', 'text'); const type = accepts(req).type('html', 'json', 'text');
const errors = req.validationErrors(true); const errors = req.validationErrors(true);
@ -585,7 +584,7 @@ module.exports = function(app) {
alreadyCompleted alreadyCompleted
}); });
} }
res.sendStatus(200); return res.sendStatus(200);
} }
); );
} }
@ -597,8 +596,7 @@ module.exports = function(app) {
.isString() .isString()
.isLength({ min: 3 }); .isLength({ min: 3 });
req.checkBody('challengeType', 'must be a number') req.checkBody('challengeType', 'must be a number')
.isNumber() .isNumber();
.isInt();
req.checkBody('solution', 'solution must be a url').isURL(); req.checkBody('solution', 'solution must be a url').isURL();
const errors = req.validationErrors(true); const errors = req.validationErrors(true);
@ -652,7 +650,7 @@ module.exports = function(app) {
user.progressTimestamps.length + 1 user.progressTimestamps.length + 1
}); });
} }
res.status(200).send(true); return res.status(200).send(true);
}) })
.subscribe(() => {}, next); .subscribe(() => {}, next);
} }

View File

@ -34,7 +34,7 @@ const sendNonUserToCommit = ifNoUserRedirectTo(
'info' 'info'
); );
const debug = debugFactory('freecc:commit'); const debug = debugFactory('fcc:commit');
function findNonprofit(name) { function findNonprofit(name) {
let nonprofit; let nonprofit;
@ -217,7 +217,7 @@ export default function commit(app) {
}) })
.subscribe( .subscribe(
pledge => { pledge => {
let msg = `You have successfully stopped your pledge.`; let msg = 'You have successfully stopped your pledge.';
if (!pledge) { if (!pledge) {
msg = `No pledge found for user ${user.username}.`; msg = `No pledge found for user ${user.username}.`;
} }

View File

@ -14,9 +14,9 @@ module.exports = function(app) {
return next(); return next();
} }
req.user.picture = defaultProfileImage; req.user.picture = defaultProfileImage;
req.user.save(function(err) { return req.user.save(function(err) {
if (err) { return next(err); } if (err) { return next(err); }
next(); return next();
}); });
} }
@ -24,6 +24,6 @@ module.exports = function(app) {
if (req.user) { if (req.user) {
return res.redirect('/challenges/current-challenge'); return res.redirect('/challenges/current-challenge');
} }
res.render('home', { title: message }); return res.render('home', { title: message });
} }
}; };

View File

@ -2,7 +2,7 @@ var Rx = require('rx'),
async = require('async'), async = require('async'),
moment = require('moment'), moment = require('moment'),
request = require('request'), request = require('request'),
debug = require('debug')('freecc:cntr:resources'), debug = require('debug')('fcc:cntr:resources'),
constantStrings = require('../utils/constantStrings.json'), constantStrings = require('../utils/constantStrings.json'),
labs = require('../resources/labs.json'), labs = require('../resources/labs.json'),
testimonials = require('../resources/testimonials.json'), testimonials = require('../resources/testimonials.json'),
@ -145,7 +145,7 @@ module.exports = function(app) {
if (err) { if (err) {
return next(err); return next(err);
} }
process.nextTick(function() { return process.nextTick(function() {
res.header('Content-Type', 'application/xml'); res.header('Content-Type', 'application/xml');
res.render('resources/sitemap', { res.render('resources/sitemap', {
appUrl: appUrl, appUrl: appUrl,
@ -227,14 +227,18 @@ module.exports = function(app) {
} }
function confirmStickers(req, res) { function confirmStickers(req, res) {
req.flash('success', { msg: 'Thank you for supporting our community! You should receive your stickers in the ' + req.flash('success', {
'mail soon!'}); msg: 'Thank you for supporting our community! You should receive ' +
'your stickers in the mail soon!'
});
res.redirect('/shop'); res.redirect('/shop');
} }
function cancelStickers(req, res) { function cancelStickers(req, res) {
req.flash('info', { msg: 'You\'ve cancelled your purchase of our stickers. You can ' req.flash('info', {
+ 'support our community any time by buying some.'}); msg: 'You\'ve cancelled your purchase of our stickers. You can ' +
'support our community any time by buying some.'
});
res.redirect('/shop'); res.redirect('/shop');
} }
function submitCatPhoto(req, res) { function submitCatPhoto(req, res) {
@ -280,18 +284,14 @@ module.exports = function(app) {
function unsubscribe(req, res, next) { function unsubscribe(req, res, next) {
User.findOne({ where: { email: req.params.email } }, function(err, user) { User.findOne({ where: { email: req.params.email } }, function(err, user) {
if (user) { if (user) {
if (err) { if (err) { return next(err); }
return next(err);
}
user.sendMonthlyEmail = false; user.sendMonthlyEmail = false;
user.save(function() { return user.save(function() {
if (err) { if (err) { return next(err); }
return next(err); return res.redirect('/unsubscribed');
}
res.redirect('/unsubscribed');
}); });
} else { } else {
res.redirect('/unsubscribed'); return res.redirect('/unsubscribed');
} }
}); });
} }
@ -330,7 +330,7 @@ module.exports = function(app) {
Object.keys(JSON.parse(pulls)).length : Object.keys(JSON.parse(pulls)).length :
'Can\'t connect to github'; 'Can\'t connect to github';
request( return request(
[ [
'https://api.github.com/repos/freecodecamp/', 'https://api.github.com/repos/freecodecamp/',
'freecodecamp/issues?client_id=', 'freecodecamp/issues?client_id=',
@ -344,7 +344,7 @@ module.exports = function(app) {
issues = ((pulls === parseInt(pulls, 10)) && issues) ? issues = ((pulls === parseInt(pulls, 10)) && issues) ?
Object.keys(JSON.parse(issues)).length - pulls : Object.keys(JSON.parse(issues)).length - pulls :
"Can't connect to GitHub"; "Can't connect to GitHub";
res.send({ return res.send({
issues: issues, issues: issues,
pulls: pulls pulls: pulls
}); });
@ -364,7 +364,7 @@ module.exports = function(app) {
(JSON.parse(trello)) : (JSON.parse(trello)) :
'Can\'t connect to to Trello'; 'Can\'t connect to to Trello';
res.end(JSON.stringify(trello)); return res.end(JSON.stringify(trello));
}); });
} }
@ -379,7 +379,7 @@ module.exports = function(app) {
blog = (status && status.statusCode === 200) ? blog = (status && status.statusCode === 200) ?
JSON.parse(blog) : JSON.parse(blog) :
'Can\'t connect to Blogger'; 'Can\'t connect to Blogger';
res.end(JSON.stringify(blog)); return res.end(JSON.stringify(blog));
} }
); );
} }

View File

@ -2,7 +2,7 @@ var Rx = require('rx'),
assign = require('object.assign'), assign = require('object.assign'),
sanitizeHtml = require('sanitize-html'), sanitizeHtml = require('sanitize-html'),
moment = require('moment'), moment = require('moment'),
debug = require('debug')('freecc:cntr:story'), debug = require('debug')('fcc:cntr:story'),
utils = require('../utils'), utils = require('../utils'),
observeMethod = require('../utils/rx').observeMethod, observeMethod = require('../utils/rx').observeMethod,
saveUser = require('../utils/rx').saveUser, saveUser = require('../utils/rx').saveUser,
@ -207,7 +207,7 @@ module.exports = function(app) {
return upvote.upVotedByUsername === username; return upvote.upVotedByUsername === username;
}); });
res.render('stories/index', { return res.render('stories/index', {
title: story.headline, title: story.headline,
link: story.link, link: story.link,
originalStoryLink: dashedName, originalStoryLink: dashedName,
@ -357,7 +357,7 @@ module.exports = function(app) {
url = 'http://' + url; url = 'http://' + url;
} }
findStory({ where: { link: url } }) return findStory({ where: { link: url } })
.map(function(stories) { .map(function(stories) {
if (stories.length) { if (stories.length) {
return { return {

Some files were not shown because too many files have changed in this diff Show More