diff --git a/client/sagas/local-storage-saga.js b/client/sagas/local-storage-saga.js
new file mode 100644
index 0000000000..7913dcf42f
--- /dev/null
+++ b/client/sagas/local-storage-saga.js
@@ -0,0 +1,67 @@
+import {
+ saveForm,
+ clearForm,
+ loadSavedForm
+} from '../common/app/routes/Jobs/redux/types';
+
+import {
+ 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));
+ return null;
+ } catch (e) {
+ return dispatch({
+ type: 'app.handleError',
+ error: new Error('could not parse form data')
+ });
+ }
+ }
+
+ 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 (err) {
+ return dispatch({
+ type: 'app.handleError',
+ error: new Error('could not parse form data')
+ });
+ }
+ }
+
+ return next(action);
+ };
+};
diff --git a/common/app/routes/Hikes/redux/types.js b/common/app/routes/Hikes/redux/types.js
index 2e51441926..da3a622adc 100644
--- a/common/app/routes/Hikes/redux/types.js
+++ b/common/app/routes/Hikes/redux/types.js
@@ -21,5 +21,7 @@ const types = [
'goToNextHike'
];
-export default types
- .reduce((types, type) => ({ ...types, [type]: `videos.${type}` }), {});
+export default types.reduce((types, type) => {
+ types[type] = `videos.${type}`;
+ return types;
+}, {});
diff --git a/common/app/routes/Jobs/components/Jobs.jsx b/common/app/routes/Jobs/components/Jobs.jsx
index a315227d70..bea7d205b0 100644
--- a/common/app/routes/Jobs/components/Jobs.jsx
+++ b/common/app/routes/Jobs/components/Jobs.jsx
@@ -1,5 +1,6 @@
import React, { cloneElement, PropTypes } from 'react';
-import { connect, compose } from 'redux';
+import { compose } from 'redux';
+import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { push } from 'react-router-redux';
diff --git a/common/app/routes/Jobs/components/NewJob.jsx b/common/app/routes/Jobs/components/NewJob.jsx
index 43f2dfb6ef..1111057def 100644
--- a/common/app/routes/Jobs/components/NewJob.jsx
+++ b/common/app/routes/Jobs/components/NewJob.jsx
@@ -1,8 +1,8 @@
import { helpers } from 'rx';
import React, { PropTypes } from 'react';
-import { History } from 'react-router';
-import { contain } from 'thundercats-react';
-import debugFactory from 'debug';
+import { reduxForm } from 'redux-form';
+import { connector } from 'react-redux';
+import debug from 'debug';
import dedent from 'dedent';
import normalizeUrl from 'normalize-url';
@@ -26,7 +26,7 @@ import {
isURL
} from 'validator';
-const debug = debugFactory('fcc:jobs:newForm');
+const log = debug('fcc:jobs:newForm');
const checkValidity = [
'position',
@@ -100,396 +100,369 @@ function makeRequired(validator) {
return (val) => !!val && validator(val);
}
-export default contain({
- store: 'appStore',
- actions: 'jobActions',
- map({ jobsApp: { form = {} } }) {
- const {
- position,
- locale,
- description,
- email,
- url,
- logo,
- company,
- isFrontEndCert = true,
- isBackEndCert,
- isHighlighted,
- isRemoteOk,
- howToApply
- } = form;
- return {
- position: formatValue(position, makeRequired(isAscii)),
- locale: formatValue(locale, makeRequired(isAscii)),
- description: formatValue(description, makeRequired(helpers.identity)),
- email: formatValue(email, makeRequired(isEmail)),
- url: formatValue(formatUrl(url), isValidURL),
- logo: formatValue(formatUrl(logo), isValidURL),
- company: formatValue(company, makeRequired(isAscii)),
- isHighlighted: formatValue(isHighlighted, null, 'bool'),
- isRemoteOk: formatValue(isRemoteOk, null, 'bool'),
- howToApply: formatValue(howToApply, makeRequired(isAscii)),
- isFrontEndCert,
- isBackEndCert
- };
- },
- subscribeOnWillMount() {
- return typeof window !== 'undefined';
- }
- },
- React.createClass({
- displayName: 'NewJob',
+const formOptions = {
+ fields: [
+ 'position',
+ 'locale',
+ 'description',
+ 'email',
+ 'url',
+ 'logo',
+ 'company',
+ 'isHighlighted',
+ 'isRemoteOk',
+ 'isFrontEndCert',
+ 'isBackEndCert',
+ 'howToApply'
+ ]
+}
- 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
- },
+export class NewJob extends React.Component {
+ static displayName = 'NewJob';
- mixins: [History],
+ static propTypes = {
+ jobActions: PropTypes.object,
+ fields: PropTypes.object,
+ onSubmit: PropTypes.func
+ };
- 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;
+ 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;
}
+ });
- const {
- jobActions,
+ if (
+ !valid ||
+ !pros.isFrontEndCert &&
+ !pros.isBackEndCert
+ ) {
+ debug('form not valid');
+ return;
+ }
- // form values
- position,
- locale,
- description,
- email,
- url,
- logo,
- company,
- isFrontEndCert,
- isBackEndCert,
- isHighlighted,
- isRemoteOk,
- howToApply
- } = this.props;
+ const {
+ jobActions,
- // 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
- };
+ // form values
+ position,
+ locale,
+ description,
+ email,
+ url,
+ logo,
+ company,
+ isFrontEndCert,
+ isBackEndCert,
+ isHighlighted,
+ isRemoteOk,
+ howToApply
+ } = this.props;
- const job = Object.keys(jobValues).reduce((accu, prop) => {
- if (jobValues[prop]) {
- accu[prop] = jobValues[prop];
- }
- return accu;
- }, {});
+ // 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
+ };
- job.postedOn = new Date();
- debug('job sanitized', job);
- jobActions.saveForm(job);
+ const job = Object.keys(jobValues).reduce((accu, prop) => {
+ if (jobValues[prop]) {
+ accu[prop] = jobValues[prop];
+ }
+ return accu;
+ }, {});
- this.history.pushState(null, '/jobs/new/preview');
- },
+ job.postedOn = new Date();
+ debug('job sanitized', job);
+ jobActions.saveForm(job);
- componentDidMount() {
- const { jobActions } = this.props;
- jobActions.getSavedForm();
- },
+ this.history.pushState(null, '/jobs/new/preview');
+ },
- handleChange(name, { target: { value } }) {
- const { jobActions: { handleForm } } = this.props;
- handleForm({ [name]: value });
- },
+ componentDidMount() {
+ const { jobActions } = this.props;
+ jobActions.getSavedForm();
+ },
- handleCertClick(name) {
- const { jobActions: { handleForm } } = this.props;
- const otherButton = name === 'isFrontEndCert' ?
- 'isBackEndCert' :
- 'isFrontEndCert';
+ handleChange(name, { target: { value } }) {
+ const { jobActions: { handleForm } } = this.props;
+ handleForm({ [name]: value });
+ },
- handleForm({
- [name]: true,
- [otherButton]: false
- });
- },
+ handleCertClick(name) {
+ const { jobActions: { handleForm } } = this.props;
+ const otherButton = name === 'isFrontEndCert' ?
+ 'isBackEndCert' :
+ 'isFrontEndCert';
- render() {
- const {
- position,
- locale,
- description,
- email,
- url,
- logo,
- company,
- isHighlighted,
- isRemoteOk,
- howToApply,
- isFrontEndCert,
- isBackEndCert,
- jobActions: { handleForm }
- } = this.props;
+ handleForm({
+ [name]: true,
+ [otherButton]: false
+ });
+ },
- const { handleChange } = this;
- const labelClass = 'col-sm-offset-1 col-sm-2';
- const inputClass = 'col-sm-6';
+ render() {
+ const {
+ position,
+ locale,
+ description,
+ email,
+ url,
+ logo,
+ company,
+ isHighlighted,
+ isRemoteOk,
+ howToApply,
+ isFrontEndCert,
+ isBackEndCert,
+ jobActions: { handleForm }
+ } = this.props;
- return (
-
-
-
-
+ );
+ }
+}
+
+export default reduxForm(
+ formOptions,
+ mapStateToProps,
+ bindableActions
+)(NewJob);
diff --git a/common/app/routes/Jobs/components/Preview.jsx b/common/app/routes/Jobs/components/Preview.jsx
index a50eca2794..cfdec61ddb 100644
--- a/common/app/routes/Jobs/components/Preview.jsx
+++ b/common/app/routes/Jobs/components/Preview.jsx
@@ -1,6 +1,6 @@
import React, { PropTypes } from 'react';
import { Button, Row, Col } from 'react-bootstrap';
-import { connect } from 'redux';
+import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import PureComponent from 'react-pure-render/component';
import { goBack, push } from 'react-router-redux';
@@ -11,8 +11,9 @@ import JobNotFound from './JobNotFound.jsx';
import { clearSavedForm, saveJobToDb } from '../redux/actions';
const mapStateToProps = createSelector(
- state => state.jobsApp.form,
- (job = {}) => ({ job })
+ state => state.jobsApp.previewJob,
+ state => state.jobsApp.jobs.entities
+ (job, jobsMap) => ({ job: jobsMap[job] || {} })
);
const bindableActions = {
diff --git a/common/app/routes/Jobs/components/Show.jsx b/common/app/routes/Jobs/components/Show.jsx
index 84ca630b92..05e9320a45 100644
--- a/common/app/routes/Jobs/components/Show.jsx
+++ b/common/app/routes/Jobs/components/Show.jsx
@@ -1,5 +1,6 @@
import React, { PropTypes } from 'react';
-import { connect, compose } from 'redux';
+import { compose } from 'redux';
+import { connect } from 'react-redux';
import { push } from 'react-router-redux';
import PureComponent from 'react-pure-render/component';
import { createSelector } from 'reselect';
diff --git a/common/app/routes/Jobs/index.js b/common/app/routes/Jobs/index.js
index 9d8ee8c2dc..b388bdc577 100644
--- a/common/app/routes/Jobs/index.js
+++ b/common/app/routes/Jobs/index.js
@@ -2,7 +2,7 @@ import Jobs from './components/Jobs.jsx';
import NewJob from './components/NewJob.jsx';
import Show from './components/Show.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';
/*
@@ -23,7 +23,7 @@ export default {
component: Preview
}, {
path: 'jobs/new/check-out',
- component: GoToPayPal
+ component: JobTotal
}, {
path: 'jobs/new/completed',
component: NewJobCompleted
diff --git a/common/app/routes/Jobs/redux/actions.js b/common/app/routes/Jobs/redux/actions.js
index e69de29bb2..0212a2a375 100644
--- a/common/app/routes/Jobs/redux/actions.js
+++ b/common/app/routes/Jobs/redux/actions.js
@@ -0,0 +1,20 @@
+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);
+
+export const saveJob = createAction(types.saveJob);
+export const saveJobCompleted = createAction(types.saveJobCompleted);
+
+export const saveForm = createAction(types.saveForm);
+export const clearForm = createAction(types.clearSavedForm);
+export const loadSavedFormCompleted = createAction(
+ types.loadSavedFormCompleted
+);
diff --git a/common/app/routes/Jobs/redux/apply-promo-saga.js b/common/app/routes/Jobs/redux/apply-promo-saga.js
new file mode 100644
index 0000000000..e5b182baf5
--- /dev/null
+++ b/common/app/routes/Jobs/redux/apply-promo-saga.js
@@ -0,0 +1,39 @@
+import { Observable } from 'rx';
+
+import { testPromo } from './types';
+import { applyPromo } from './actions';
+import { postJSON$ } from '../../../../utils/ajax-stream';
+
+export default () => ({ dispatch }) => next => {
+ return function applyPromoSaga(action) {
+ if (action.type !== testPromo) {
+ 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 applyPromo(promo);
+ })
+ .catch(error => Observable.just({
+ type: 'app.handleError',
+ error
+ }))
+ .doOnNext(dispatch);
+ };
+};
diff --git a/common/app/routes/Jobs/redux/fetch-jobs-saga.js b/common/app/routes/Jobs/redux/fetch-jobs-saga.js
index 2712d2e47d..e7fec1a429 100644
--- a/common/app/routes/Jobs/redux/fetch-jobs-saga.js
+++ b/common/app/routes/Jobs/redux/fetch-jobs-saga.js
@@ -1,7 +1,50 @@
-import { fetchJobs, fetchJobsCompleted } from './types';
+import { Observable } from 'rx';
+import { normalize, Schema, arrayOf } from 'normalizr';
-export default ({ services }) => ({ dispatch, getState }) => next => {
+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) {
- return next(action);
+ if (action.type !== fetchJobs) {
+ return next(action);
+ }
+
+ const { payload: id } = action;
+ const data = { service: 'jobs' };
+ if (id) {
+ data.id = 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.jobs,
+ results: result.jobs
+ }
+ );
+ })
+ .catch(error => {
+ return Observable.just({
+ type: handleError,
+ error
+ });
+ })
+ .doOnNext(dispatch);
};
};
diff --git a/common/app/routes/Jobs/redux/index.js b/common/app/routes/Jobs/redux/index.js
index 0936f320ae..b0b05159f1 100644
--- a/common/app/routes/Jobs/redux/index.js
+++ b/common/app/routes/Jobs/redux/index.js
@@ -1 +1,8 @@
-export default from './Actions';
+export actions from './actions';
+export reducer from './reducer';
+export types from './types';
+
+import fetchJobsSaga from './fetch-jobs-saga';
+import saveJobSaga from './save-job-saga';
+
+export const sagas = [ fetchJobsSaga, saveJobSaga ];
diff --git a/common/app/routes/Jobs/redux/reducer.js b/common/app/routes/Jobs/redux/reducer.js
index e69de29bb2..2fa43101ee 100644
--- a/common/app/routes/Jobs/redux/reducer.js
+++ b/common/app/routes/Jobs/redux/reducer.js
@@ -0,0 +1,79 @@
+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 = {
+ 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.saveJobCompleted]: (state, { payload: newJob }) => {
+ return {
+ ...state,
+ newJob
+ };
+ },
+ [types.fetchJobCompleted]: (state, { payload: { jobs, currentJob } }) => ({
+ ...state,
+ currentJob,
+ jobs
+ }),
+ [types.updatePromoCode]: (state, { payload }) => ({
+ ...state,
+ promoCode: replace(payload)
+ }),
+ [types.applyPromo]: (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
+);
diff --git a/common/app/routes/Jobs/redux/save-job-saga.js b/common/app/routes/Jobs/redux/save-job-saga.js
new file mode 100644
index 0000000000..0faf17d823
--- /dev/null
+++ b/common/app/routes/Jobs/redux/save-job-saga.js
@@ -0,0 +1,26 @@
+import { Observable } from 'rx';
+
+import { saveJobCompleted } from './actions';
+import { saveJob } from './types';
+
+import { handleError } from '../../../redux/types';
+
+export default ({ services }) => ({ dispatch }) => next => {
+ return function saveJobSaga(action) {
+ if (action.type !== saveJob) {
+ return next(action);
+ }
+ const { payload: job } = action;
+
+ return services.createService$({
+ service: 'jobs',
+ params: { job }
+ })
+ .map(job => saveJobCompleted(job))
+ .catch(error => Observable.just({
+ type: handleError,
+ error
+ }))
+ .doOnNext(dispatch);
+ };
+};
diff --git a/common/app/routes/Jobs/redux/types.js b/common/app/routes/Jobs/redux/types.js
index 3e3ccc2b6c..7417e734b6 100644
--- a/common/app/routes/Jobs/redux/types.js
+++ b/common/app/routes/Jobs/redux/types.js
@@ -3,16 +3,15 @@ const types = [
'fetchJobsCompleted',
'findJob',
-
'saveJob',
- 'getJob',
- 'getJobs',
- 'openModal',
- 'closeModal',
- 'handleFormUpdate',
+
'saveForm',
- 'clear'
+ 'clearForm',
+ 'loadSavedForm',
+ 'loadSavedFormCompleted'
];
-export default types
- .reduce((types, type) => ({ ...types, [type]: `jobs.${type}` }), {});
+export default types.reduce((types, type) => {
+ types[type] = `jobs.${type}`;
+ return types;
+}, {});
diff --git a/common/app/sagas.js b/common/app/sagas.js
index fc40bc384c..cfd486242b 100644
--- a/common/app/sagas.js
+++ b/common/app/sagas.js
@@ -1,6 +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
+ ...hikesSagas,
+ ...jobsSagas
];