Move Video challenges under challenges dir

Remove old hikes components
Remove unused jobs stuff
pull/7430/head
Berkeley Martinez 2016-06-13 12:26:30 -07:00
parent 5f5f9e526e
commit 4a043e151e
49 changed files with 391 additions and 2484 deletions

View File

@ -1,6 +1,5 @@
import errSaga from './err-saga';
import titleSaga from './title-saga';
import localStorageSaga from './local-storage-saga';
import hardGoToSaga from './hard-go-to-saga';
import windowSaga from './window-saga';
import executeChallengeSaga from './execute-challenge-saga';
@ -11,7 +10,6 @@ import gitterSaga from './gitter-saga';
export default [
errSaga,
titleSaga,
localStorageSaga,
hardGoToSaga,
windowSaga,
executeChallengeSaga,

View File

@ -1,43 +0,0 @@
import store from 'store';
import {
saveForm,
clearForm,
loadSavedForm
} from '../../common/app/routes/Jobs/redux/types';
import {
saveCompleted,
loadSavedFormCompleted
} from '../../common/app/routes/Jobs/redux/actions';
const formKey = 'newJob';
export default function localStorageSaga(action$) {
return action$
.filter(action => {
return action.type === saveForm ||
action.type === clearForm ||
action.type === loadSavedForm;
})
.map(action => {
if (action.type === saveForm) {
const form = action.payload;
try {
store.setItem(formKey, form);
return saveCompleted(form);
} catch (error) {
return {
type: 'app.handleError',
error
};
}
}
if (action.type === clearForm) {
store.removeItem(formKey);
return null;
}
return loadSavedFormCompleted(store.getItem(formKey));
});
}

View File

@ -3,27 +3,17 @@ import { reducer as formReducer } from 'redux-form';
import { reducer as app } from './redux';
import entitiesReducer from './redux/entities-reducer';
import { reducer as hikesApp } from './routes/Hikes/redux';
import {
reducer as challengesApp,
projectNormalizer
} from './routes/challenges/redux';
import {
reducer as jobsApp,
formNormalizer as jobsNormalizer
} from './routes/Jobs/redux';
export default function createReducer(sideReducers = {}) {
return combineReducers({
...sideReducers,
entities: entitiesReducer,
app,
hikesApp,
jobsApp,
challengesApp,
form: formReducer.normalize({
...jobsNormalizer,
...projectNormalizer
})
form: formReducer.normalize({ ...projectNormalizer })
});
}

View File

@ -1,71 +0,0 @@
import React, { PropTypes } from 'react';
import { compose } from 'redux';
import { contain } from 'redux-epic';
import { connect } from 'react-redux';
import PureComponent from 'react-pure-render/component';
import { createSelector } from 'reselect';
// import debug from 'debug';
import HikesMap from './Map.jsx';
import { fetchHikes } from '../redux/actions';
// const log = debug('fcc:hikes');
const mapStateToProps = createSelector(
state => state.entities.hike,
state => state.hikesApp.hikes,
(hikesMap, hikesByDashedName) => {
if (!hikesMap || !hikesByDashedName) {
return { hikes: [] };
}
return {
hikes: hikesByDashedName.map(dashedName => hikesMap[dashedName])
};
}
);
const fetchOptions = {
fetchAction: 'fetchHikes',
isPrimed: ({ hikes }) => hikes && !!hikes.length,
getActionArgs: ({ params: { dashedName } }) => [ dashedName ],
shouldContainerFetch(props, nextProps) {
return props.params.dashedName !== nextProps.params.dashedName;
}
};
export class Hikes extends PureComponent {
static displayName = 'Hikes';
static propTypes = {
children: PropTypes.element,
hikes: PropTypes.array,
params: PropTypes.object
};
renderMap(hikes) {
return (
<HikesMap hikes={ hikes }/>
);
}
render() {
const { hikes } = this.props;
return (
<div>
{
// render sub-route
this.props.children ||
// if no sub-route render hikes map
this.renderMap(hikes)
}
</div>
);
}
}
// export redux and fetch aware component
export default compose(
connect(mapStateToProps, { fetchHikes }),
contain(fetchOptions)
)(Hikes);

View File

@ -1,102 +0,0 @@
import React, { PropTypes } from 'react';
import { connect } from 'react-redux';
import { Button, Col, Row } from 'react-bootstrap';
import Youtube from 'react-youtube';
import { createSelector } from 'reselect';
import debug from 'debug';
import { hardGoTo } from '../../../redux/actions';
import { toggleQuestionView } from '../redux/actions';
import { getCurrentHike } from '../redux/selectors';
const log = debug('fcc:hikes');
const mapStateToProps = createSelector(
getCurrentHike,
(currentHike) => {
const {
dashedName,
description,
challengeSeed: [id] = [0]
} = currentHike;
return {
id,
dashedName,
description
};
}
);
export class Lecture extends React.Component {
static displayName = 'Lecture';
static propTypes = {
// actions
toggleQuestionView: PropTypes.func,
// ui
id: PropTypes.string,
description: PropTypes.array,
dashedName: PropTypes.string,
hardGoTo: PropTypes.func
};
componentWillMount() {
if (!this.props.id) {
// this.props.hardGoTo('/map');
}
}
shouldComponentUpdate(nextProps) {
const { props } = this;
return nextProps.id !== props.id;
}
handleError: log;
renderTranscript(transcript, dashedName) {
return transcript.map((line, index) => (
<p
className='lead text-left'
dangerouslySetInnerHTML={{__html: line}}
key={ dashedName + index } />
));
}
render() {
const {
id = '1',
description = [],
toggleQuestionView
} = this.props;
const dashedName = 'foo';
return (
<Col xs={ 12 }>
<Row>
<Youtube
id='player_1'
onError={ this.handleError }
videoId={ id } />
</Row>
<Row>
<article>
{ this.renderTranscript(description, dashedName) }
</article>
<Button
block={ true }
bsSize='large'
bsStyle='primary'
onClick={ toggleQuestionView }>
Take me to the Questions
</Button>
</Row>
</Col>
);
}
}
export default connect(
mapStateToProps,
{ hardGoTo, toggleQuestionView }
)(Lecture);

View File

@ -1,39 +0,0 @@
import React, { PropTypes } from 'react';
import { Link } from 'react-router';
import { ListGroup, ListGroupItem } from 'react-bootstrap';
export default React.createClass({
displayName: 'HikesMap',
propTypes: {
hikes: PropTypes.array
},
render() {
const {
hikes = [{}]
} = this.props;
const vidElements = hikes.map(({ title, dashedName }) => {
return (
<ListGroupItem key={ dashedName }>
<Link to={ `/videos/${dashedName}` }>
<h3>{ title }</h3>
</Link>
</ListGroupItem>
);
});
return (
<div>
<div className='text-center'>
<h2>Welcome To Hikes!</h2>
</div>
<hr />
<ListGroup>
{ vidElements }
</ListGroup>
</div>
);
}
});

View File

@ -1,24 +0,0 @@
export default {
path: 'videos',
getComponent(_, cb) {
require.ensure(
[ './components/Hikes.jsx' ],
require => {
cb(null, require('./components/Hikes.jsx').default);
},
'hikes'
);
},
getChildRoutes(_, cb) {
require.ensure(
[ './components/Hike.jsx' ],
require => {
cb(null, [{
path: ':dashedName',
component: require('./components/Hike.jsx').default
}]);
},
'hikes'
);
}
};

View File

@ -1,58 +0,0 @@
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,
(entities, hikes, currentHike) => ({ hikes, currentHike }),
entities => ({ entities })
);
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

@ -1,138 +0,0 @@
import { Observable } from 'rx';
import { push } from 'react-router-redux';
import types from './types';
import { getMouse } from './utils';
import {
createErrorObservable,
makeToast,
updatePoints
} from '../../../redux/actions';
import { hikeCompleted, goToNextHike } from './actions';
import { postJSON$ } from '../../../../utils/ajax-stream';
import { getCurrentHike } from './selectors';
function handleAnswer(action, getState) {
const {
e,
answer,
userAnswer,
info,
threshold
} = action.payload;
const state = getState();
const { id, name, challengeType, tests } = getCurrentHike(state);
const {
app: { isSignedIn, csrfToken },
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 Observable.just(null);
}
if (positionX >= threshold) {
finalAnswer = true;
}
if (positionX <= -threshold) {
finalAnswer = false;
}
} else {
finalAnswer = userAnswer;
}
// incorrect question
if (answer !== finalAnswer) {
let infoAction;
if (info) {
infoAction = makeToast({
title: 'Hint',
message: info,
type: 'info'
});
}
return Observable
.just({ type: types.endShake })
.delay(500)
.startWith(infoAction, { type: types.startShake });
}
if (tests[currentQuestion]) {
return Observable
.just({ type: types.goToNextQuestion })
.delay(300)
.startWith({ type: types.primeNextQuestion });
}
let updateUser$;
if (isSignedIn) {
const body = { id, name, challengeType: +challengeType, _csrf: csrfToken };
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(createErrorObservable);
} 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(createErrorObservable)
// end with action so we know it is ok to transition
.concat(Observable.just({ type: types.transitionHike }));
}
export default function answerSaga(action$, getState) {
return action$
.filter(action => {
return action.type === types.answerQuestion ||
action.type === types.transitionHike;
})
.flatMap(action => {
if (action.type === types.answerQuestion) {
return handleAnswer(action, getState);
}
const { hikesApp: { currentHike } } = getState();
// if no next hike currentHike will equal '' which is falsy
if (currentHike) {
return Observable.just(push(`/videos/${currentHike}`));
}
return Observable.just(push('/map'));
});
}

View File

@ -1,28 +0,0 @@
import { normalize, Schema, arrayOf } from 'normalizr';
import types from './types';
import { fetchHikesCompleted } from './actions';
import { createErrorObserable } from '../../../redux/actions';
import { findCurrentHike } from './utils';
// const log = debug('fcc:fetch-hikes-saga');
const hike = new Schema('hike', { idAttribute: 'dashedName' });
export default function fetchHikesSaga(action$, getState, { services }) {
return action$
.filter(action => action.type === types.fetchHikes)
.flatMap(action => {
const dashedName = action.payload;
return services.readService$({ service: 'hikes' })
.map(hikes => {
const { entities, result } = normalize(
{ hikes },
{ hikes: arrayOf(hike) }
);
const currentHike = findCurrentHike(result.hikes, dashedName);
return fetchHikesCompleted(entities, result.hikes, currentHike);
})
.catch(createErrorObserable);
});
}

View File

@ -1,8 +0,0 @@
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

@ -1,99 +0,0 @@
import { handleActions } from 'redux-actions';
import types from './types';
import { findNextHikeName } from './utils';
const initialState = {
hikes: [],
// 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),
mouse: [ 0, 0 ]
}),
[types.transitionHike]: state => ({
...state,
showQuestions: false,
currentQuestion: 1
}),
[types.fetchHikesCompleted]: (state, { payload }) => {
const { hikes, currentHike } = payload;
return {
...state,
hikes,
currentHike
};
}
},
initialState
);

View File

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

View File

@ -1,24 +0,0 @@
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

@ -1,76 +0,0 @@
import debug from 'debug';
import _ from 'lodash';
const log = debug('fcc:hikes:utils');
function getFirstHike(hikes) {
return hikes[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
.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: String[],
// dashedName: String
// ) => String
export function findNextHikeName(hikes, dashedName) {
if (!dashedName) {
log('find next hike no dashedName provided');
return hikes[0];
}
const currentIndex = _.findIndex(
hikes,
_dashedName => _dashedName === dashedName
);
if (currentIndex >= hikes.length) {
return '';
}
return hikes[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 +0,0 @@
This folder contains everything relative to Jobs board

View File

@ -1,35 +0,0 @@
import React from 'react';
import { LinkContainer } from 'react-router-bootstrap';
import { Button, Row, Col } from 'react-bootstrap';
export default class extends React.Component {
static displayName = 'NoJobFound';
shouldComponentUpdate() {
return false;
}
render() {
return (
<div>
<Row>
<Col
md={ 6 }
mdOffset={ 3 }>
<div>
No job found...
<LinkContainer to='/jobs'>
<Button
block={ true }
bsSize='large'
bsStyle='primary'>
Go to the job board
</Button>
</LinkContainer>
</div>
</Col>
</Row>
</div>
);
}
}

View File

@ -1,306 +0,0 @@
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='//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,149 +0,0 @@
import React, { cloneElement, PropTypes } from 'react';
import { compose } from 'redux';
import { contain } from 'redux-epic';
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 ListJobs from './List.jsx';
import {
findJob,
fetchJobs
} from '../redux/actions';
const mapStateToProps = createSelector(
state => state.entities.job,
state => state.jobsApp.jobs,
(jobsMap, jobsById) => ({
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,
jobs: PropTypes.array,
showModal: PropTypes.bool
};
createJobClickHandler() {
const { findJob } = this.props;
return (id) => {
findJob(id);
};
}
renderList(handleJobClick, jobs) {
return (
<ListJobs
handleClick={ handleJobClick }
jobs={ jobs }/>
);
}
renderChild(child, jobs) {
if (!child) {
return null;
}
return cloneElement(
child,
{ jobs }
);
}
render() {
const {
children,
jobs
} = this.props;
return (
<Row>
<Col
md={ 10 }
mdOffset= { 1 }
xs={ 12 }>
<h1 className='text-center'>
Hire a JavaScript engineer who's experienced in HTML5,
Node.js, MongoDB, and Agile Development.
</h1>
<div className='spacer' />
<Row className='text-center'>
<Col
sm={ 8 }
smOffset={ 2 }
xs={ 12 }>
<LinkContainer to='/jobs/new' >
<Button className='signup-btn btn-block btn-cta'>
Post a job: $1,000
</Button>
</LinkContainer>
<div className='spacer' />
</Col>
</Row>
<div className='spacer' />
<Row>
<Col
md={ 2 }
xs={ 4 }>
<img
alt={`
a photo of Michael Gai, who recently hired a software
engineer through Free Code Camp
`}
className='img-responsive testimonial-image-jobs img-center'
src='//i.imgur.com/tGcAA8H.jpg' />
</Col>
<Col
md={ 10 }
xs={ 8 }>
<blockquote>
<p>
We hired our last developer out of Free Code Camp
and couldn't be happier. Free Code Camp is now
our go-to way to bring on pre-screened candidates
who are enthusiastic about learning quickly and
becoming immediately productive in their new career.
</p>
<footer>
Michael Gai, <cite>CEO at CoNarrative</cite>
</footer>
</blockquote>
</Col>
</Row>
<Row>
{ this.renderChild(children, jobs) ||
this.renderList(this.createJobClickHandler(), jobs) }
</Row>
</Col>
</Row>
);
}
}
export default compose(
connect(mapStateToProps, bindableActions),
contain(fetchOptions)
)(Jobs);

View File

@ -1,86 +0,0 @@
import React, { PropTypes } from 'react';
import classnames from 'classnames';
import { LinkContainer } from 'react-router-bootstrap';
import { ListGroup, ListGroupItem } from 'react-bootstrap';
import PureComponent from 'react-pure-render/component';
export default class ListJobs extends PureComponent {
static displayName = 'ListJobs';
static propTypes = {
handleClick: PropTypes.func,
jobs: PropTypes.array
};
addLocation(locale) {
if (!locale) {
return null;
}
return (
<span className='hidden-xs hidden-sm'>
{ locale }
</span>
);
}
renderJobs(handleClick, jobs = []) {
return jobs
.filter(({ isPaid, isApproved, isFilled }) => {
return isPaid && isApproved && !isFilled;
})
.map(({
id,
company,
position,
isHighlighted,
locale
}) => {
const className = classnames({
'jobs-list': true,
'col-xs-12': true,
'jobs-list-highlight': isHighlighted
});
const to = `/jobs/${id}`;
return (
<LinkContainer
key={ id }
to={ to }>
<ListGroupItem
className={ className }
onClick={ () => handleClick(id) }>
<div>
<h4 className='pull-left' style={{ display: 'inline-block' }}>
<bold>{ company }</bold>
{' '}
<span className='hidden-xs hidden-sm'>
- { position }
</span>
</h4>
<h4
className='pull-right'
style={{ display: 'inline-block' }}>
{ this.addLocation(locale) }
</h4>
</div>
</ListGroupItem>
</LinkContainer>
);
});
}
render() {
const {
handleClick,
jobs
} = this.props;
return (
<ListGroup>
{ this.renderJobs(handleClick, jobs) }
</ListGroup>
);
}
}

View File

@ -1,361 +0,0 @@
import { helpers } from 'rx';
import React, { PropTypes } from 'react';
import PureComponent from 'react-pure-render/component';
import { push } from 'react-router-redux';
import { reduxForm } from 'redux-form';
// import debug from 'debug';
import dedent from 'dedent';
import { isAscii, isEmail } from 'validator';
import {
Button,
Col,
Input,
Row
} from 'react-bootstrap';
import {
isValidURL,
makeOptional,
makeRequired,
createFormValidator,
getValidationState
} from '../../../utils/form';
import { saveForm, loadSavedForm } from '../redux/actions';
// const log = debug('fcc:jobs:newForm');
const hightlightCopy = `
Highlight my post to make it stand out. (+$250)
`;
const isRemoteCopy = `
This job can be performed remotely.
`;
const howToApplyCopy = dedent`
Examples: click here to apply yourcompany.com/jobs/33
Or email jobs@yourcompany.com
`;
const checkboxClass = dedent`
text-left
jobs-checkbox-spacer
col-sm-offset-2
col-sm-6 col-md-offset-3
`;
const certTypes = {
isFrontEndCert: 'isFrontEndCert',
isBackEndCert: 'isBackEndCert'
};
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)
};
export class NewJob extends PureComponent {
static displayName = 'NewJob';
static propTypes = {
fields: PropTypes.object,
handleSubmit: PropTypes.func,
loadSavedForm: PropTypes.func,
push: PropTypes.func,
saveForm: PropTypes.func
};
componentDidMount() {
this.props.loadSavedForm();
}
handleSubmit(job) {
this.props.saveForm(job);
this.props.push('/jobs/new/preview');
}
handleCertClick(name) {
const { fields } = this.props;
Object.keys(certTypes).forEach(certType => {
if (certType === name) {
return fields[certType].onChange(true);
}
return fields[certType].onChange(false);
});
}
render() {
const {
fields: {
position,
locale,
description,
email,
url,
logo,
company,
isHighlighted,
isRemoteOk,
howToApply,
isFrontEndCert,
isBackEndCert
},
handleSubmit
} = this.props;
const { handleChange } = this;
const labelClass = 'col-sm-offset-1 col-sm-2';
const inputClass = 'col-sm-6';
return (
<div>
<Row>
<Col
md={ 10 }
mdOffset={ 1 }>
<div className='text-center'>
<form
className='form-horizontal'
onSubmit={ handleSubmit(data => this.handleSubmit(data)) }>
<div className='spacer'>
<h2>First, select your ideal applicant: </h2>
</div>
<Row>
<Col
xs={ 6 }
xsOffset={ 3 }>
<Row>
<Button
bsStyle='primary'
className={ isFrontEndCert.value ? 'active' : '' }
onClick={ () => {
if (!isFrontEndCert.value) {
this.handleCertClick(certTypes.isFrontEndCert);
}
}}>
<h4>Front End Development Certified</h4>
You can expect each applicant
to have a code portfolio using the
following technologies:
HTML5, CSS, jQuery, API integrations
<br />
<br />
</Button>
</Row>
<div className='button-spacer' />
<Row>
<Button
bsStyle='primary'
className={ isBackEndCert.value ? 'active' : ''}
onClick={ () => {
if (!isBackEndCert.value) {
this.handleCertClick(certTypes.isBackEndCert);
}
}}>
<h4>Back End Development Certified</h4>
You can expect each applicant to have a code
portfolio using the following technologies:
HTML5, CSS, jQuery, API integrations, MVC Framework,
JavaScript, Node.js, MongoDB, Express.js
<br />
<br />
</Button>
</Row>
</Col>
</Row>
<div className='spacer'>
<h2>Tell us about the position</h2>
</div>
<hr />
<Input
bsStyle={ getValidationState(position) }
label='Job Title'
labelClassName={ labelClass }
placeholder={
'e.g. Full Stack Developer, Front End Developer, etc.'
}
required={ true }
type='text'
wrapperClassName={ inputClass }
{ ...position }
/>
<Input
bsStyle={ getValidationState(locale) }
label='Location'
labelClassName={ labelClass }
placeholder='e.g. San Francisco, Remote, etc.'
required={ true }
type='text'
wrapperClassName={ inputClass }
{ ...locale }
/>
<Input
bsStyle={ getValidationState(description) }
label='Description'
labelClassName={ labelClass }
required={ true }
rows='10'
type='textarea'
wrapperClassName={ inputClass }
{ ...description }
/>
<Input
label={ isRemoteCopy }
type='checkbox'
wrapperClassName={ checkboxClass }
{ ...isRemoteOk }
/>
<div className='spacer' />
<hr />
<Row>
<div>
<h2>How should they apply?</h2>
</div>
<Input
bsStyle={ getValidationState(howToApply) }
label=' '
labelClassName={ labelClass }
placeholder={ howToApplyCopy }
required={ true }
rows='2'
type='textarea'
wrapperClassName={ inputClass }
{ ...howToApply }
/>
</Row>
<div className='spacer' />
<hr />
<div>
<h2>Tell us about your organization</h2>
</div>
<Input
bsStyle={ getValidationState(company) }
label='Company Name'
labelClassName={ labelClass }
onChange={ (e) => handleChange('company', e) }
type='text'
wrapperClassName={ inputClass }
{ ...company }
/>
<Input
bsStyle={ getValidationState(email) }
label='Email'
labelClassName={ labelClass }
placeholder='This is how we will contact you'
required={ true }
type='email'
wrapperClassName={ inputClass }
{ ...email }
/>
<Input
bsStyle={ getValidationState(url) }
label='URL'
labelClassName={ labelClass }
placeholder='http://yourcompany.com'
type='url'
wrapperClassName={ inputClass }
{ ...url }
/>
<Input
bsStyle={ getValidationState(logo) }
label='Logo'
labelClassName={ labelClass }
placeholder='http://yourcompany.com/logo.png'
type='url'
wrapperClassName={ inputClass }
{ ...logo }
/>
<div className='spacer' />
<hr />
<div>
<div>
<h2>Make it stand out</h2>
</div>
<div className='spacer' />
<Row>
<Col
md={ 6 }
mdOffset={ 3 }>
Highlight this ad to give it extra attention.
<br />
Featured listings receive more clicks and more applications.
</Col>
</Row>
<div className='spacer' />
<Row>
<Input
bsSize='large'
bsStyle='success'
label={ hightlightCopy }
type='checkbox'
wrapperClassName={
checkboxClass.replace('text-left', '')
}
{ ...isHighlighted }
/>
</Row>
</div>
<Row>
<Col
className='text-left'
lg={ 6 }
lgOffset={ 3 }>
<Button
block={ true }
bsSize='large'
bsStyle='primary'
type='submit'>
Preview My Ad
</Button>
</Col>
</Row>
</form>
</div>
</Col>
</Row>
</div>
);
}
}
export default reduxForm(
{
form: 'NewJob',
fields,
validate: createFormValidator(fieldValidators)
},
state => ({ initialValues: state.jobsApp.initialValues }),
{
loadSavedForm,
push,
saveForm
}
)(NewJob);

View File

@ -1,43 +0,0 @@
import React from 'react';
import { LinkContainer } from 'react-router-bootstrap';
import { Button, Col, Row } from 'react-bootstrap';
export default class extends React.createClass {
static displayName = 'NewJobCompleted';
shouldComponentUpdate() {
return false;
}
render() {
return (
<div className='text-center'>
<div>
<Row>
<h1>
Your Position has Been Submitted
</h1>
</Row>
<Row>
<Col
md={ 6 }
mdOffset={ 3 }>
Well review your listing and email you when its live.
<br />
Thank you for listing this job with Free Code Camp.
</Col>
</Row>
<div className='spacer' />
<LinkContainer to={ '/jobs' }>
<Button
block={ true }
bsSize='large'
bsStyle='primary'>
Go to the job board
</Button>
</LinkContainer>
</div>
</div>
);
}
}

View File

@ -1,94 +0,0 @@
import { CompositeDisposable } from 'rx';
import React, { PropTypes } from 'react';
import { Button, Row, Col } from 'react-bootstrap';
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 JobNotFound from './JobNotFound.jsx';
import { clearForm, saveJob } from '../redux/actions';
const mapStateToProps = state => ({ job: state.jobsApp.newJob });
const bindableActions = {
goBack,
push,
clearForm,
saveJob
};
export class JobPreview extends PureComponent {
constructor(...args) {
super(...args);
this._subscriptions = new CompositeDisposable();
}
static displayName = 'Preview';
static propTypes = {
job: PropTypes.object,
saveJob: PropTypes.func,
clearForm: PropTypes.func,
push: PropTypes.func
};
componentWillMount() {
const { push, job } = this.props;
// redirect user in client
if (!job || !job.position || !job.description) {
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() {
const { job, goBack } = this.props;
if (!job || !job.position || !job.description) {
return <JobNotFound />;
}
return (
<div>
<ShowJob job={ job } />
<div className='spacer'></div>
<hr />
<Row>
<Col
md={ 10 }
mdOffset={ 1 }
xs={ 12 }>
<div>
<Button
block={ true }
className='signup-btn'
onClick={ () => this.handleJobSubmit() }>
Looks great! Let's Check Out
</Button>
<Button
block={ true }
onClick={ goBack } >
Head back and make edits
</Button>
</div>
</Col>
</Row>
</div>
);
}
}
export default connect(mapStateToProps, bindableActions)(JobPreview);

View File

@ -1,146 +0,0 @@
import React, { PropTypes } from 'react';
import { compose } from 'redux';
import { contain } from 'redux-epic';
import { connect } from 'react-redux';
import { push } from 'react-router-redux';
import PureComponent from 'react-pure-render/component';
import { createSelector } from 'reselect';
import { fetchJobs } from '../redux/actions';
import ShowJob from './ShowJob.jsx';
import JobNotFound from './JobNotFound.jsx';
import { isJobValid } from '../utils';
function shouldShowApply(
{
isFrontEndCert: isFrontEndCertReq = false,
isBackEndCert: isBackEndCertReq = false
}, {
isFrontEndCert = false,
isBackEndCert = false
}
) {
return (!isFrontEndCertReq && !isBackEndCertReq) ||
(isBackEndCertReq && isBackEndCert) ||
(isFrontEndCertReq && isFrontEndCert);
}
function generateMessage(
{
isFrontEndCert: isFrontEndCertReq = false,
isBackEndCert: isBackEndCertReq = false
},
{
isFrontEndCert = false,
isBackEndCert = false,
isSignedIn = false
}
) {
if (!isSignedIn) {
return 'Must be signed in to apply';
}
if (isFrontEndCertReq && !isFrontEndCert) {
return 'This employer requires Free Code Camps Front ' +
'End Development Certification in order to apply';
}
if (isBackEndCertReq && !isBackEndCert) {
return 'This employer requires Free Code Camps Back ' +
'End Development Certification in order to apply';
}
if (isFrontEndCertReq && isFrontEndCertReq) {
return 'This employer requires the Front End Development Certification. ' +
"You've earned it, so feel free to apply.";
}
return 'This employer requires the Back End Development Certification. ' +
"You've earned it, so feel free to apply.";
}
const mapStateToProps = createSelector(
state => state.app,
state => state.jobsApp.currentJob,
state => state.entities.job,
({ username, isFrontEndCert, isBackEndCert }, currentJob, jobMap) => ({
username,
isFrontEndCert,
isBackEndCert,
job: jobMap[currentJob] || {}
})
);
const bindableActions = {
push,
fetchJobs
};
const fetchOptions = {
fetchAction: 'fetchJobs',
getActionArgs({ params: { id } }) {
return [ id ];
},
isPrimed({ params: { id } = {}, job = {} }) {
return job.id === id;
},
// using es6 destructuring
shouldRefetch({ job }, { params: { id } }) {
return job.id !== id;
}
};
export class Show extends PureComponent {
static displayName = 'Show';
static propTypes = {
job: PropTypes.object,
isBackEndCert: PropTypes.bool,
isFrontEndCert: PropTypes.bool,
username: PropTypes.string
};
componentDidMount() {
const { job, push } = this.props;
// redirect user in client
if (!isJobValid(job)) {
push('/jobs');
}
}
render() {
const {
isBackEndCert,
isFrontEndCert,
job,
username
} = this.props;
if (!isJobValid(job)) {
return <JobNotFound />;
}
const isSignedIn = !!username;
const showApply = shouldShowApply(
job,
{ isFrontEndCert, isBackEndCert }
);
const message = generateMessage(
job,
{ isFrontEndCert, isBackEndCert, isSignedIn }
);
return (
<ShowJob
message={ message }
preview={ false }
showApply={ showApply }
{ ...this.props }/>
);
}
}
export default compose(
connect(mapStateToProps, bindableActions),
contain(fetchOptions)
)(Show);

View File

@ -1,147 +0,0 @@
import React, { PropTypes } from 'react';
import { Row, Col, Thumbnail } from 'react-bootstrap';
import PureComponent from 'react-pure-render/component';
import urlRegexFactory from 'url-regex';
const urlRegex = urlRegexFactory();
const defaultImage =
'https://s3.amazonaws.com/freecodecamp/camper-image-placeholder.png';
const thumbnailStyle = {
backgroundColor: 'white',
maxHeight: '100px',
maxWidth: '100px'
};
function addATags(text) {
return text.replace(urlRegex, function(match) {
return `<a href=${match} target='_blank'>${match}</a>`;
});
}
export default class extends PureComponent {
static displayName = 'ShowJob';
static propTypes = {
job: PropTypes.object,
params: PropTypes.object,
showApply: PropTypes.bool,
preview: PropTypes.bool,
message: PropTypes.string
};
renderHeader({ company, position }) {
return (
<div>
<h4 style={{ display: 'inline-block' }}>{ company }</h4>
<h5
className='pull-right hidden-xs hidden-md'
style={{ display: 'inline-block' }}>
{ position }
</h5>
</div>
);
}
renderHowToApply(showApply, preview, message, howToApply) {
if (!showApply) {
return (
<Row>
<Col
md={ 6 }
mdOffset={ 3 }>
<h4 className='bg-info text-center'>{ message }</h4>
</Col>
</Row>
);
}
return (
<Row>
<hr />
<Col
md={ 6 }
mdOffset={ 3 }>
<div>
<bold>{ preview ? 'How do I apply?' : message }</bold>
<br />
<br />
<span dangerouslySetInnerHTML={{
__html: addATags(howToApply)
}} />
</div>
</Col>
</Row>
);
}
render() {
const {
showApply = true,
message,
preview = true,
job = {}
} = this.props;
const {
logo,
position,
city,
company,
state,
locale,
description,
howToApply
} = job;
return (
<div>
<Row>
<Col
md={ 10 }
mdOffset={ 1 }
xs={ 12 }>
<div>
<Row>
<h2 className='text-center'>
{ company }
</h2>
</Row>
<div className='spacer' />
<Row>
<Col
md={ 2 }
mdOffset={ 3 }>
<Thumbnail
alt={ logo ? company + 'company logo' : 'stock image' }
src={ logo || defaultImage }
style={ thumbnailStyle } />
</Col>
<Col
md={ 4 }>
<bold>Position: </bold> { position || 'N/A' }
<br />
<bold>Location: </bold>
{ locale ? locale : `${city}, ${state}` }
</Col>
</Row>
<hr />
<div className='spacer' />
<Row>
<Col
md={ 6 }
mdOffset={ 3 }
style={{ whiteSpace: 'pre-line' }}
xs={ 12 }>
<p>{ description }</p>
</Col>
</Row>
{ this.renderHowToApply(showApply, preview, message, howToApply) }
</div>
</Col>
</Row>
</div>
);
}
}

View File

@ -1,36 +0,0 @@
export default {
getChildRoutes: (_, cb) => {
require.ensure(
[
'./components/Jobs.jsx',
'./components/NewJob.jsx',
'./components/Preview.jsx',
'./components/JobTotal.jsx',
'./components/NewJobCompleted.jsx',
'./components/Show.jsx'
],
require => {
cb(null, [{
path: '/jobs',
component: require('./components/Jobs.jsx').default
}, {
path: 'jobs/new',
component: require('./components/NewJob.jsx').default
}, {
path: 'jobs/new/preview',
component: require('./components/Preview.jsx').default
}, {
path: 'jobs/new/check-out',
component: require('./components/JobTotal.jsx').default
}, {
path: 'jobs/new/completed',
component: require('./components/NewJobCompleted.jsx').default
}, {
path: 'jobs/:id',
component: require('./components/Show.jsx').default
}]);
},
'jobs'
);
}
};

View File

@ -1,35 +0,0 @@
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 }),
entities => ({ entities })
);
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

@ -1,33 +0,0 @@
import { Observable } from 'rx';
import { applyPromo } from './types';
import { applyPromoCompleted } from './actions';
import { postJSON$ } from '../../../../utils/ajax-stream';
export default function applyPromoSaga(action$) {
return action$
.filter(action => action.type === applyPromo)
.flatMap(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
}));
});
}

View File

@ -1,36 +0,0 @@
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 function fetchJobsSaga(action$, getState, { services }) {
return action$
.filter(action => action.type === fetchJobs)
.flatMap(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(
entities,
result.jobs[0],
result.jobs
);
})
.catch(error => Observable.just({ type: handleError, error }));
});
}

View File

@ -1,11 +0,0 @@
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

@ -1,19 +0,0 @@
import {
inHTMLData,
uriInSingleQuotedAttr
} from 'xss-filters';
import { callIfDefined, formatUrl } from '../../../utils/form';
export default {
NewJob: {
position: callIfDefined(inHTMLData),
locale: callIfDefined(inHTMLData),
description: callIfDefined(inHTMLData),
email: callIfDefined(inHTMLData),
url: callIfDefined(value => formatUrl(uriInSingleQuotedAttr(value))),
logo: callIfDefined(value => formatUrl(uriInSingleQuotedAttr(value))),
company: callIfDefined(inHTMLData),
howToApply: callIfDefined(inHTMLData)
}
};

View File

@ -1,79 +0,0 @@
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: []
};
export default handleActions(
{
[types.findJob]: (state, { payload: id }) => {
return {
...state,
currentJob: id
};
},
[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

@ -1,25 +0,0 @@
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 function saveJobSaga(action$, getState, { services }) {
return action$
.filter(action => action.type === saveJob)
.flatMap(action => {
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
}));
});
}

View File

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

View File

@ -1,29 +0,0 @@
const defaults = {
string: {
value: '',
valid: false,
pristine: true,
type: 'string'
},
bool: {
value: false,
type: 'boolean'
}
};
export function getDefaults(type, value) {
if (!type) {
return defaults['string'];
}
if (value) {
return Object.assign({}, defaults[type], { value });
}
return Object.assign({}, defaults[type]);
}
export function isJobValid(job) {
return job &&
!job.isFilled &&
job.isApproved &&
job.isPaid;
}

View File

@ -8,6 +8,7 @@ import PureComponent from 'react-pure-render/component';
import Classic from './classic/Classic.jsx';
import Step from './step/Step.jsx';
import Project from './project/Project.jsx';
import Video from './video/Video.jsx';
import { fetchChallenge, fetchChallenges } from '../redux/actions';
import { challengeSelector } from '../redux/selectors';
@ -16,7 +17,8 @@ const views = {
step: Step,
classic: Classic,
project: Project,
simple: Project
simple: Project,
video: Video
};
const bindableActions = {

View File

@ -0,0 +1,110 @@
import React, { PropTypes } from 'react';
import { connect } from 'react-redux';
import { Button, Col, Row } from 'react-bootstrap';
import Youtube from 'react-youtube';
import { createSelector } from 'reselect';
import debug from 'debug';
import { toggleQuestionView } from '../../redux/actions';
import { challengeSelector } from '../../redux/selectors';
const log = debug('fcc:videos');
const mapStateToProps = createSelector(
challengeSelector,
({
challenge: {
id = 'foo',
dashedName,
description,
challengeSeed: [ videoId ] = [ '1' ]
}
}) => ({
id,
videoId,
dashedName,
description
})
);
export class Lecture extends React.Component {
static displayName = 'Lecture';
static propTypes = {
// actions
toggleQuestionView: PropTypes.func,
// ui
id: PropTypes.string,
videoId: PropTypes.string,
description: PropTypes.array,
dashedName: PropTypes.string
};
shouldComponentUpdate(nextProps) {
const { props } = this;
return nextProps.id !== props.id;
}
handleError: log;
renderTranscript(transcript, dashedName) {
return transcript.map((line, index) => (
<p
className='lead text-left'
dangerouslySetInnerHTML={{__html: line}}
key={ dashedName + index }
/>
));
}
render() {
const {
id,
videoId,
description = [],
toggleQuestionView
} = this.props;
const dashedName = 'foo';
return (
<Col xs={ 12 }>
<Row>
<div className='embed-responsive embed-responsive-16by9'>
<Youtube
className='embed-responsive-item'
id={ id }
onError={ this.handleError }
videoId={ videoId }
/>
</div>
</Row>
<Row>
<Col
md={ 10 }
mdOffset={ 1 }
xs={ 12 }
>
<article>
{ this.renderTranscript(description, dashedName) }
</article>
<Button
block={ true }
bsSize='large'
bsStyle='primary'
onClick={ toggleQuestionView }
>
Take me to the Questions
</Button>
<div className='spacer' />
</Col>
</Row>
</Col>
);
}
}
export default connect(
mapStateToProps,
{ toggleQuestionView }
)(Lecture);

View File

@ -9,8 +9,8 @@ import {
moveQuestion,
releaseQuestion,
grabQuestion
} from '../redux/actions';
import { getCurrentHike } from '../redux/selectors';
} from '../../redux/actions';
import { challengeSelector } from '../../redux/selectors';
const answerThreshold = 100;
const springProperties = { stiffness: 120, damping: 10 };
@ -22,34 +22,30 @@ const actionsToBind = {
};
const mapStateToProps = createSelector(
getCurrentHike,
state => state.hikesApp,
challengeSelector,
state => state.challengesApp,
state => state.app.isSignedIn,
(currentHike, ui, isSignedIn) => {
const {
(
{ challenge: { tests = [ ] }},
{
currentQuestion = 1,
mouse = [ 0, 0 ],
delta = [ 0, 0 ],
isCorrect = false,
isPressed = false,
shouldShakeQuestion = false
} = ui;
const {
tests = []
} = currentHike;
return {
tests,
currentQuestion,
isCorrect,
mouse,
delta,
isPressed,
shouldShakeQuestion,
isSignedIn
};
}
},
isSignedIn
) => ({
tests,
currentQuestion,
isCorrect,
mouse,
delta,
isPressed,
shouldShakeQuestion,
isSignedIn
})
);
class Question extends React.Component {
@ -133,7 +129,8 @@ class Question extends React.Component {
onTouchEnd={ mouseUp }
onTouchMove={ this.handleMouseMove(isPressed, this.props) }
onTouchStart={ grabQuestion }
style={ style }>
style={ style }
>
<h4>Question { number }</h4>
<p>{ question }</p>
</article>
@ -162,7 +159,8 @@ class Question extends React.Component {
<Col
onMouseUp={ e => this.handleMouseUp(e, answer, info) }
xs={ 8 }
xsOffset={ 2 }>
xsOffset={ 2 }
>
<Row>
<Motion style={{ x: spring(xPosition, springProperties) }}>
{ questionElement }
@ -174,14 +172,16 @@ class Question extends React.Component {
bsSize='large'
bsStyle='primary'
className='pull-left'
onClick={ this.onAnswer(answer, false, info) }>
onClick={ this.onAnswer(answer, false, info) }
>
false
</Button>
<Button
bsSize='large'
bsStyle='primary'
className='pull-right'
onClick={ this.onAnswer(answer, true, info) }>
onClick={ this.onAnswer(answer, true, info) }
>
true
</Button>
</div>

View File

@ -5,26 +5,27 @@ import { createSelector } from 'reselect';
import Lecture from './Lecture.jsx';
import Questions from './Questions.jsx';
import { resetHike } from '../redux/actions';
import { updateTitle } from '../../../redux/actions';
import { getCurrentHike } from '../redux/selectors';
import { resetUi } from '../../redux/actions';
import { updateTitle } from '../../../../redux/actions';
import { challengeSelector } from '../../redux/selectors';
const bindableActions = { resetUi, updateTitle };
const mapStateToProps = createSelector(
getCurrentHike,
state => state.hikesApp.shouldShowQuestions,
(currentHike, shouldShowQuestions) => ({
title: currentHike ? currentHike.title : '',
challengeSelector,
state => state.challengesApp.shouldShowQuestions,
({ challenge: { title } }, shouldShowQuestions) => ({
title,
shouldShowQuestions
})
);
// export plain component for testing
export class Hike extends React.Component {
static displayName = 'Hike';
export class Video extends React.Component {
static displayName = 'Video';
static propTypes = {
// actions
resetHike: PropTypes.func,
resetUi: PropTypes.func,
// ui
title: PropTypes.string,
params: PropTypes.object,
@ -38,12 +39,12 @@ export class Hike extends React.Component {
}
componentWillUnmount() {
this.props.resetHike();
this.props.resetUi();
}
componentWillReceiveProps({ params: { dashedName } }) {
if (this.props.params.dashedName !== dashedName) {
this.props.resetHike();
componentWillReceiveProps({ title }) {
if (this.props.title !== title) {
this.props.resetUi();
}
}
@ -69,7 +70,8 @@ export class Hike extends React.Component {
<div className='spacer' />
<section
className={ 'text-center' }
title={ title }>
title={ title }
>
{ this.renderBody(shouldShowQuestions) }
</section>
</Col>
@ -78,4 +80,7 @@ export class Hike extends React.Component {
}
// export redux aware component
export default connect(mapStateToProps, { resetHike, updateTitle })(Hike);
export default connect(
mapStateToProps,
bindableActions
)(Video);

View File

@ -1,6 +1,6 @@
import { createAction } from 'redux-actions';
import { updateContents } from '../../../../utils/polyvinyl';
import { loggerToStr } from '../utils';
import { getMouse, loggerToStr } from '../utils';
import types from './types';
@ -79,3 +79,40 @@ export const moveToNextChallenge = createAction(types.moveToNextChallenge);
export const saveCode = createAction(types.saveCode);
export const loadCode = createAction(types.loadCode);
export const savedCodeFound = createAction(types.savedCodeFound);
// video challenges
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 videoCompleted = createAction(types.videoCompleted);

View File

@ -0,0 +1,86 @@
import { Observable } from 'rx';
import types from './types';
import { getMouse } from '../utils';
import { submitChallenge, videoCompleted } from './actions';
import { createErrorObservable, makeToast } from '../../../redux/actions';
import { challengeSelector } from './selectors';
export default function answerSaga(action$, getState) {
return action$
.filter(action => action.type === types.answerQuestion)
.flatMap(({
payload: {
e,
answer,
userAnswer,
info,
threshold
}
}) => {
const state = getState();
const {
challenge: { tests }
} = challengeSelector(state);
const {
challengesApp: {
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 Observable.just(null);
}
if (positionX >= threshold) {
finalAnswer = true;
}
if (positionX <= -threshold) {
finalAnswer = false;
}
} else {
finalAnswer = userAnswer;
}
// incorrect question
if (answer !== finalAnswer) {
let infoAction;
if (info) {
infoAction = makeToast({
title: 'Have a hint',
message: info,
type: 'info'
});
}
return Observable
.just({ type: types.endShake })
.delay(500)
.startWith(infoAction, { type: types.startShake });
}
if (tests[currentQuestion]) {
return Observable
.just({ type: types.goToNextQuestion })
.delay(300)
.startWith({ type: types.primeNextQuestion });
}
return Observable.just(submitChallenge())
.delay(300)
// moves question to the appropriate side of the screen
.startWith(videoCompleted(finalAnswer))
// end with action so we know it is ok to transition
.concat(Observable.just({ type: types.transitionHike }))
.catch(createErrorObservable);
});
}

View File

@ -12,6 +12,9 @@ import { backEndProject } from '../../../utils/challengeTypes';
import { randomCompliment } from '../../../utils/get-words';
import { postJSON$ } from '../../../../utils/ajax-stream';
// NOTE(@BerkeleyTrue): this file could benefit from some refactoring.
// lots of repeat code
function completedChallenge(state) {
let body;
let isSignedIn = false;
@ -163,6 +166,7 @@ function submitSimpleChallenge(type, state) {
const submitTypes = {
tests: submitModern,
step: submitSimpleChallenge,
video: submitSimpleChallenge,
'project.frontEnd': submitProject,
'project.backEnd': submitProject,
'project.simple': submitSimpleChallenge

View File

@ -5,11 +5,13 @@ export types from './types';
import fetchChallengesSaga from './fetch-challenges-saga';
import completionSaga from './completion-saga';
import nextChallengeSaga from './next-challenge-saga';
import answerSaga from './answer-saga';
export projectNormalizer from './project-normalizer';
export const sagas = [
fetchChallengesSaga,
completionSaga,
nextChallengeSaga
nextChallengeSaga,
answerSaga
];

View File

@ -8,7 +8,7 @@ import {
getFirstChallengeOfNextBlock,
getFirstChallengeOfNextSuperBlock
} from '../utils';
import { getRandomVerb } from '../../../utils/get-words';
import { randomVerb } from '../../../utils/get-words';
export default function nextChallengeSaga(actions$, getState) {
return actions$
@ -48,7 +48,7 @@ export default function nextChallengeSaga(actions$, getState) {
}
message += ' Your next challenge has arrived.';
const toast = {
// title: isNewSuperBlock || isNewBlock ? getRandomVerb() : null,
// title: isNewSuperBlock || isNewBlock ? randomVerb() : null,
message
};
*/
@ -56,7 +56,7 @@ export default function nextChallengeSaga(actions$, getState) {
updateCurrentChallenge(nextChallenge),
resetUi(),
makeToast({
title: getRandomVerb(),
title: randomVerb(),
message: 'Your next challenge has arrived.'
}),
push(`/challenges/${nextChallenge.block}/${nextChallenge.dashedName}`)

View File

@ -12,15 +12,30 @@ import {
} from '../utils';
const initialUiState = {
// step index tracing
currentIndex: 0,
previousIndex: -1,
// step action
isActionCompleted: false,
isSubmitting: true,
// project is ready to submit
isSubmitting: false,
output: `/**
* Any console.log()
* statements will appear in
* here console.
*/`
*/`,
// video
// 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
};
const initialState = {
id: '',
@ -107,6 +122,49 @@ const mainReducer = handleActions(
[types.updateOutput]: (state, { payload: output }) => ({
...state,
output: (state.output || '') + output
}),
// video
[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.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.videoCompleted]: (state, { payload: userAnswer } ) => ({
...state,
isCorrect: true,
isPressed: false,
delta: [ 0, 0 ],
mouse: [ userAnswer ? 1000 : -1000, 0]
})
},
initialState

View File

@ -39,5 +39,21 @@ export default createTypes([
// code storage
'saveCode',
'loadCode',
'savedCodeFound'
'savedCodeFound',
// video challenges
'toggleQuestionView',
'grabQuestion',
'releaseQuestion',
'moveQuestion',
'answerQuestion',
'startShake',
'endShake',
'primeNextQuestion',
'goToNextQuestion',
'transitionVideo',
'videoCompleted'
], 'challenges');

View File

@ -168,3 +168,21 @@ export function getCurrentSuperBlockName(current, entities) {
const block = blockMap[challenge.block];
return block.superBlock;
}
// gets new mouse position
// getMouse(
// e: MouseEvent|TouchEvent,
// [ dx: Number, dy: Number ]
// ) => [ Number, Number ]
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,13 +1,9 @@
import Jobs from './Jobs';
import Hikes from './Hikes';
import { modernChallenges, map, challenges } from './challenges';
import NotFound from '../components/NotFound/index.jsx';
export default {
path: '/',
childRoutes: [
Jobs,
Hikes,
challenges,
modernChallenges,
map,

View File

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