Merge branch 'feature/jobs' into staging

This commit is contained in:
Quincy Larson
2015-11-02 00:07:39 -08:00
41 changed files with 1429 additions and 265 deletions

View File

@ -22,6 +22,19 @@ const history = createHistory();
const appLocation = createLocation( const appLocation = createLocation(
location.pathname + location.search location.pathname + location.search
); );
function location$(history) {
return Rx.Observable.create(function(observer) {
const dispose = history.listen(function(location) {
observer.onNext(location.pathname);
});
return Rx.Disposable.create(() => {
dispose();
});
});
}
// returns an observable // returns an observable
app$({ history, location: appLocation }) app$({ history, location: appLocation })
.flatMap( .flatMap(
@ -36,6 +49,27 @@ app$({ history, location: appLocation })
// redirects in the future // redirects in the future
({ nextLocation, props }, appCat) => ({ nextLocation, props, appCat }) ({ nextLocation, props }, appCat) => ({ nextLocation, props, appCat })
) )
.doOnNext(({ appCat }) => {
const appActions = appCat.getActions('appActions');
location$(history)
.pluck('pathname')
.distinctUntilChanged()
.doOnNext(route => debug('route change', route))
.subscribe(route => appActions.updateRoute(route));
appActions.goBack.subscribe(function() {
history.goBack();
});
appActions
.updateRoute
.pluck('route')
.doOnNext(route => debug('update route', route))
.subscribe(function(route) {
history.pushState(null, route);
});
})
.flatMap(({ props, appCat }) => { .flatMap(({ props, appCat }) => {
props.history = history; props.history = history;
return Render( return Render(
@ -49,7 +83,7 @@ app$({ history, location: appLocation })
debug('react rendered'); debug('react rendered');
}, },
err => { err => {
debug('an error has occured', err.stack); throw err;
}, },
() => { () => {
debug('react closed subscription'); debug('react closed subscription');

16
client/less/jobs.less Normal file
View File

@ -0,0 +1,16 @@
.jobs-list-highlight {
background-color: #ffc
}
a.jobs-list-highlight:hover {
background-color: #ffc
}
.jobs-list {
cursor: pointer;
cursor: hand;
}
.jobs-checkbox-spacer input[type="checkbox"] {
margin-left: -23px
}

View File

@ -424,7 +424,6 @@
&:hover, &:hover,
&:focus { &:focus {
color: @navbar-default-link-active-color; color: @navbar-default-link-active-color;
background-color: @navbar-default-link-active-bg;
} }
} }
> .disabled > a { > .disabled > a {

View File

@ -9,6 +9,11 @@ html,body,div,span,a,li,td,th {
font-weight: 300; font-weight: 300;
} }
bold {
font-family: 'Lato-Bold', sans-serif;
font-weight: Bold;
}
li, .wrappable { li, .wrappable {
white-space: pre; /* CSS 2.0 */ white-space: pre; /* CSS 2.0 */
white-space: pre-wrap; /* CSS 2.1 */ white-space: pre-wrap; /* CSS 2.1 */
@ -973,11 +978,11 @@ code {
margin: 0!important; margin: 0!important;
} }
// gitter chat
.gitter-chat-embed { .gitter-chat-embed {
z-index: 20000 !important; z-index: 20000 !important;
} }
//uncomment this to see the dimensions of all elements outlined in red //uncomment this to see the dimensions of all elements outlined in red
//* { //* {
// border-color: red; // border-color: red;
@ -1087,3 +1092,4 @@ code {
} }
@import "chat.less"; @import "chat.less";
@import "jobs.less";

View File

@ -12,6 +12,6 @@ export default Cat()
cat.register(HikesActions, null, services); cat.register(HikesActions, null, services);
cat.register(HikesStore, null, cat); cat.register(HikesStore, null, cat);
cat.register(JobActions, null, services); cat.register(JobActions, null, cat, services);
cat.register(JobsStore, null, cat); cat.register(JobsStore, null, cat);
}); });

View File

@ -0,0 +1,27 @@
import React, { createClass, PropTypes } from 'react';
import { Alert } from 'react-bootstrap';
export default createClass({
displayName: 'FlashQueue',
propTypes: {
messages: PropTypes.array
},
renderMessages(messages) {
return messages.map(message => {
return (
<Alert>
);
});
},
render() {
const { messages = [] } = this.props;
return (
<div>
{ this.renderMessages(messages) }
</div>
);
}
});

View File

View File

@ -1,4 +1,5 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import { LinkContainer } from 'react-router-bootstrap';
import { import {
Col, Col,
CollapsibleNav, CollapsibleNav,
@ -11,16 +12,6 @@ import navLinks from './links.json';
import FCCNavItem from './NavItem.jsx'; import FCCNavItem from './NavItem.jsx';
const fCClogo = 'https://s3.amazonaws.com/freecodecamp/freecodecamp_logo.svg'; const fCClogo = 'https://s3.amazonaws.com/freecodecamp/freecodecamp_logo.svg';
const navElements = navLinks.map((navItem, index) => {
return (
<NavItem
eventKey={ index + 1 }
href={ navItem.link }
key={ index }>
{ navItem.content }
</NavItem>
);
});
const logoElement = ( const logoElement = (
<a href='/'> <a href='/'>
@ -39,18 +30,40 @@ const toggleButton = (
</button> </button>
); );
export default class extends React.Component { export default React.createClass({
constructor(props) { displayName: 'Nav',
super(props);
}
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() {
return navLinks.map(({ content, link, react }, index) => {
if (react) {
return (
<LinkContainer
eventKey={ index + 1 }
key={ content }
to={ link }>
<NavItem>
{ content }
</NavItem>
</LinkContainer>
);
} }
return (
<NavItem
eventKey={ index + 1 }
href={ link }
key={ content }>
{ content }
</NavItem>
);
});
},
renderPoints(username, points) { renderPoints(username, points) {
if (!username) { if (!username) {
@ -62,7 +75,7 @@ export default class extends React.Component {
[ { points } ] [ { points } ]
</NavItem> </NavItem>
); );
} },
renderSignin(username, picture) { renderSignin(username, picture) {
if (username) { if (username) {
@ -87,7 +100,7 @@ export default class extends React.Component {
</FCCNavItem> </FCCNavItem>
); );
} }
} },
render() { render() {
const { username, points, picture } = this.props; const { username, points, picture } = this.props;
@ -103,7 +116,7 @@ export default class extends React.Component {
className='hamburger-dropdown' className='hamburger-dropdown'
navbar={ true } navbar={ true }
right={ true }> right={ true }>
{ navElements } { this.renderLinks() }
{ this.renderPoints(username, points) } { this.renderPoints(username, points) }
{ this.renderSignin(username, picture) } { this.renderSignin(username, picture) }
</Nav> </Nav>
@ -111,4 +124,4 @@ export default class extends React.Component {
</Navbar> </Navbar>
); );
} }
} });

View File

@ -4,11 +4,14 @@ import BootstrapMixin from 'react-bootstrap/lib/BootstrapMixin';
export default React.createClass({ export default React.createClass({
displayName: 'FCCNavItem', displayName: 'FCCNavItem',
mixins: [BootstrapMixin], mixins: [BootstrapMixin],
propTypes: { propTypes: {
active: React.PropTypes.bool, active: React.PropTypes.bool,
'aria-controls': React.PropTypes.string, 'aria-controls': React.PropTypes.string,
children: React.PropTypes.node,
className: React.PropTypes.string,
disabled: React.PropTypes.bool, disabled: React.PropTypes.bool,
eventKey: React.PropTypes.any, eventKey: React.PropTypes.any,
href: React.PropTypes.string, href: React.PropTypes.string,
@ -30,7 +33,11 @@ export default React.createClass({
e.preventDefault(); e.preventDefault();
if (!this.props.disabled) { if (!this.props.disabled) {
this.props.onSelect(this.props.eventKey, this.props.href, this.props.target); this.props.onSelect(
this.props.eventKey,
this.props.href,
this.props.target
);
} }
} }
}, },
@ -50,10 +57,11 @@ export default React.createClass({
...props ...props
} = this.props; } = this.props;
let classes = { const linkClassName = classNames(className, {
active, // 'active': active, we don't actually use the active class
disabled // but it is used for a11y below
}; 'disabled': disabled
});
let linkProps = { let linkProps = {
role, role,
@ -75,9 +83,9 @@ export default React.createClass({
role='presentation'> role='presentation'>
<a <a
{ ...linkProps } { ...linkProps }
aria-selected={ active }
aria-controls={ ariaControls } aria-controls={ ariaControls }
className={ className }> aria-selected={ active }
className={ linkClassName }>
{ children } { children }
</a> </a>
</li> </li>

View File

@ -9,5 +9,6 @@
"link": "/news" "link": "/news"
},{ },{
"content": "Jobs", "content": "Jobs",
"link": "/jobs" "link": "/jobs",
"react": true
}] }]

View File

@ -16,7 +16,11 @@ export default Actions({
}; };
}, },
getUser: null getUser: null,
updateRoute(route) {
return { route };
},
goBack: null
}) })
.refs({ displayName: 'AppActions' }) .refs({ displayName: 'AppActions' })
.init(({ instance: appActions, args: [services] }) => { .init(({ instance: appActions, args: [services] }) => {

View File

@ -14,10 +14,10 @@ export default Store({
value: initValue value: initValue
}, },
init({ instance: appStore, args: [cat] }) { init({ instance: appStore, args: [cat] }) {
const { setUser, setTitle } = cat.getActions('appActions'); const { updateRoute, setUser, setTitle } = cat.getActions('appActions');
const register = createRegistrar(appStore); const register = createRegistrar(appStore);
register(setter(fromMany(setUser, setTitle))); register(setter(fromMany(setUser, setTitle, updateRoute)));
return appStore; return appStore;
} }

View File

@ -0,0 +1,270 @@
import React, { PropTypes } from 'react';
import { Button, Input, Col, Panel, 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: 'JobsStore',
actions: [
'jobActions',
'appActions'
],
map({
job: { id, isHighlighted } = {},
buttonId = isHighlighted ?
paypalIds.highlighted :
paypalIds.regular,
price = 200,
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
},
goToJobBoard() {
const { appActions } = this.props;
appActions.updateRoute('/jobs');
},
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>+ 50</h4>
</Col>
</Row>
);
},
renderPromo() {
const {
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({
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 }>
<Panel>
<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 ? 50 : 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' />
</Panel>
</Col>
</Row>
</div>
);
}
})
);

View File

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

View File

@ -1,36 +1,43 @@
import React, { cloneElement, PropTypes } from 'react'; import React, { cloneElement, PropTypes } from 'react';
import { contain } from 'thundercats-react'; import { contain } from 'thundercats-react';
import { History } from 'react-router'; import { Button, Panel, Row, Col } from 'react-bootstrap';
import { Button, Jumbotron, Row } from 'react-bootstrap';
import CreateJobModal from './CreateJobModal.jsx';
import ListJobs from './List.jsx'; import ListJobs from './List.jsx';
import TwitterBtn from './TwitterBtn.jsx';
export default contain( export default contain(
{ {
store: 'jobsStore', store: 'jobsStore',
fetchAction: 'jobActions.getJobs', fetchAction: 'jobActions.getJobs',
actions: 'jobActions' actions: [
'appActions',
'jobActions'
]
}, },
React.createClass({ React.createClass({
displayName: 'Jobs', displayName: 'Jobs',
mixins: [History],
propTypes: { propTypes: {
children: PropTypes.element, children: PropTypes.element,
numOfFollowers: PropTypes.number,
appActions: PropTypes.object,
jobActions: PropTypes.object, jobActions: PropTypes.object,
jobs: PropTypes.array, jobs: PropTypes.array,
showModal: PropTypes.bool showModal: PropTypes.bool
}, },
handleJobClick(id) { componentDidMount() {
const { jobActions } = this.props; const { jobActions } = this.props;
jobActions.getFollowers();
},
handleJobClick(id) {
const { appActions, jobActions } = this.props;
if (!id) { if (!id) {
return null; return null;
} }
jobActions.findJob(id); jobActions.findJob(id);
this.history.pushState(null, `/jobs/${id}`); appActions.updateRoute(`/jobs/${id}`);
}, },
renderList(handleJobClick, jobs) { renderList(handleJobClick, jobs) {
@ -54,37 +61,47 @@ export default contain(
render() { render() {
const { const {
children, children,
numOfFollowers,
jobs, jobs,
showModal, appActions
jobActions
} = this.props; } = this.props;
return ( return (
<div> <Panel>
<Row> <Row>
<Jumbotron> <Col
<h1>Free Code Camps' Job Board</h1> md={ 10 }
<p> mdOffset= { 1 }
Need to find the best junior developers? xs={ 12 }>
Want to find dedicated developers eager to join your company? <h1 className='text-center'>
Sign up now to post your job! Talented web developers with strong portfolios are eager
</p> to work for your company
</h1>
<Row className='text-center'>
<Col
sm={ 8 }
smOffset={ 2 }
xs={ 12 }>
<Button <Button
bsSize='large' bsSize='large'
className='signup-btn' className='signup-btn btn-block'
onClick={ jobActions.openModal }> onClick={ ()=> {
Try the first month 20% off! appActions.updateRoute('/jobs/new');
}}>
Post a job: $200 for 30 days + weekly tweets
</Button> </Button>
</Jumbotron> <div className='button-spacer' />
<TwitterBtn count={ numOfFollowers || 0 } />
<div className='spacer' />
</Col>
</Row> </Row>
<Row> <Row>
{ this.renderChild(children, jobs) || { this.renderChild(children, jobs) ||
this.renderList(this.handleJobClick, jobs) } this.renderList(this.handleJobClick, jobs) }
</Row> </Row>
<CreateJobModal </Col>
onHide={ jobActions.closeModal } </Row>
showModal={ showModal } /> </Panel>
</div>
); );
} }
}) })

View File

@ -1,5 +1,6 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import { PanelGroup, Thumbnail, Panel, Well } from 'react-bootstrap'; import classnames from 'classnames';
import { ListGroup, ListGroupItem } from 'react-bootstrap';
import moment from 'moment'; import moment from 'moment';
export default React.createClass({ export default React.createClass({
@ -10,62 +11,57 @@ export default React.createClass({
jobs: PropTypes.array jobs: PropTypes.array
}, },
renderJobs(handleClick, jobs =[]) { addLocation(locale) {
const thumbnailStyle = { if (!locale) {
backgroundColor: 'white', return null;
maxHeight: '100px', }
maxWidth: '100px' return (
}; <span className='hidden-xs hidden-sm'>
{ locale } - {' '}
</span>
);
},
return jobs.map(( renderJobs(handleClick, jobs = []) {
{ return jobs
.filter(({ isPaid, isApproved, isFilled }) => {
return isPaid && isApproved && !isFilled;
})
.map(({
id, id,
company, company,
position, position,
isHighlighted, isHighlighted,
description, postedOn,
logo, locale
city, }) => {
state,
email, const className = classnames({
phone, 'jobs-list': true,
postedOn 'jobs-list-highlight': isHighlighted
}, });
index
) => {
const header = (
<div>
<h4 style={{ display: 'inline-block' }}>{ company }</h4>
<h5
className='pull-right hidden-xs hidden-md'
style={{ display: 'inline-block' }}>
{ position }
</h5>
</div>
);
return ( return (
<Panel <ListGroupItem
bsStyle={ isHighlighted ? 'warning' : 'default' } className={ className }
collapsible={ true } key={ id }
eventKey={ index } onClick={ () => handleClick(id) }>
header={ header } <div>
key={ id }> <h4 style={{ display: 'inline-block' }}>
<Well> <bold>{ company }</bold>
<Thumbnail {' '}
alt={ company + 'company logo' } <span className='hidden-xs hidden-sm'>
src={ logo } - { position }
style={ thumbnailStyle } /> </span>
<Panel> </h4>
Position: { position } <h4
Location: { city }, { state } className='pull-right'
<br /> style={{ display: 'inline-block' }}>
Contact: { email || phone || 'N/A' } { this.addLocation(locale) }
<br /> { moment(new Date(postedOn)).format('MMM Do') }
Posted On: { moment(postedOn).format('MMMM Do, YYYY') } </h4>
</Panel> </div>
<p onClick={ () => handleClick(id) }>{ description }</p> </ListGroupItem>
</Well>
</Panel>
); );
}); });
}, },
@ -77,9 +73,9 @@ export default React.createClass({
} = this.props; } = this.props;
return ( return (
<PanelGroup> <ListGroup>
{ this.renderJobs(handleClick, jobs) } { this.renderJobs(handleClick, jobs) }
</PanelGroup> </ListGroup>
); );
} }
}); });

View File

@ -1,7 +1,11 @@
import { helpers } from 'rx';
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import { History } from 'react-router'; import { History } from 'react-router';
import { contain } from 'thundercats-react'; import { contain } from 'thundercats-react';
import debugFactory from 'debug'; import debugFactory from 'debug';
import dedent from 'dedent';
import normalizeUrl from 'normalize-url';
import { getDefaults } from '../utils'; import { getDefaults } from '../utils';
import { import {
@ -14,13 +18,13 @@ import {
Col, Col,
Input, Input,
Row, Row,
Panel,
Well Well
} from 'react-bootstrap'; } from 'react-bootstrap';
import { import {
isAscii, isAscii,
isEmail, isEmail,
isMobilePhone,
isURL isURL
} from 'validator'; } from 'validator';
@ -31,12 +35,43 @@ const checkValidity = [
'locale', 'locale',
'description', 'description',
'email', 'email',
'phone',
'url', 'url',
'logo', 'logo',
'name', 'company',
'highlight' 'isHighlighted',
'howToApply'
]; ];
const hightlightCopy = `
Highlight my post to make it stand out. (+$50)
`;
const foo = `
This will narrow the field substantially with higher quality applicants
`;
const isFullStackCopy = `
Applicants must have earned Free Code Camps Full Stack Certification to apply.*
`;
const isFrontEndCopy = `
Applicants must have earned Free Code Camps Front End Certification to apply.*
`;
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
`;
function formatValue(value, validator, type = 'string') { function formatValue(value, validator, type = 'string') {
const formated = getDefaults(type); const formated = getDefaults(type);
@ -50,12 +85,32 @@ function formatValue(value, validator, type = 'string') {
return formated; return formated;
} }
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 });
} }
function isValidPhone(data) { function makeRequired(validator) {
return isMobilePhone(data, 'en-US'); return (val) => !!val && validator(val);
} }
export default contain({ export default contain({
@ -67,22 +122,28 @@ export default contain({
locale, locale,
description, description,
email, email,
phone,
url, url,
logo, logo,
name, company,
highlight isHighlighted,
isFullStackCert,
isFrontEndCert,
isRemoteOk,
howToApply
} = form; } = form;
return { return {
position: formatValue(position, isAscii), position: formatValue(position, makeRequired(isAscii)),
locale: formatValue(locale, isAscii), locale: formatValue(locale, makeRequired(isAscii)),
description: formatValue(description, isAscii), description: formatValue(description, makeRequired(helpers.identity)),
email: formatValue(email, isEmail), email: formatValue(email, makeRequired(isEmail)),
phone: formatValue(phone, isValidPhone), url: formatValue(formatUrl(url), isValidURL),
url: formatValue(url, isValidURL), logo: formatValue(formatUrl(logo), isValidURL),
logo: formatValue(logo, isValidURL), company: formatValue(company, makeRequired(isAscii)),
name: formatValue(name, isAscii), isHighlighted: formatValue(isHighlighted, null, 'bool'),
highlight: formatValue(highlight, null, 'bool') isFullStackCert: formatValue(isFullStackCert, null, 'bool'),
isFrontEndCert: formatValue(isFrontEndCert, null, 'bool'),
isRemoteOk: formatValue(isRemoteOk, null, 'bool'),
howToApply: formatValue(howToApply, makeRequired(isAscii))
}; };
}, },
subscribeOnWillMount() { subscribeOnWillMount() {
@ -98,11 +159,14 @@ export default contain({
locale: PropTypes.object, locale: PropTypes.object,
description: PropTypes.object, description: PropTypes.object,
email: PropTypes.object, email: PropTypes.object,
phone: PropTypes.object,
url: PropTypes.object, url: PropTypes.object,
logo: PropTypes.object, logo: PropTypes.object,
name: PropTypes.object, company: PropTypes.object,
highlight: PropTypes.object isHighlighted: PropTypes.object,
isFullStackCert: PropTypes.object,
isFrontEndCert: PropTypes.object,
isRemoteOk: PropTypes.object,
howToApply: PropTypes.object
}, },
mixins: [History], mixins: [History],
@ -124,29 +188,37 @@ export default contain({
} }
const { const {
jobActions,
// form values
position, position,
locale, locale,
description, description,
email, email,
phone,
url, url,
logo, logo,
name, company,
highlight, isHighlighted,
jobActions isFullStackCert,
isFrontEndCert,
isRemoteOk,
howToApply
} = this.props; } = this.props;
// sanitize user output // sanitize user output
const jobValues = { const jobValues = {
position: inHTMLData(position.value), position: inHTMLData(position.value),
location: inHTMLData(locale.value), locale: inHTMLData(locale.value),
description: inHTMLData(description.value), description: inHTMLData(description.value),
email: inHTMLData(email.value), email: inHTMLData(email.value),
phone: inHTMLData(phone.value), url: formatUrl(uriInSingleQuotedAttr(url.value), false),
url: uriInSingleQuotedAttr(url.value), logo: formatUrl(uriInSingleQuotedAttr(logo.value), false),
logo: uriInSingleQuotedAttr(logo.value), company: inHTMLData(company.value),
name: inHTMLData(name.value), isHighlighted: !!isHighlighted.value,
highlight: !!highlight.value isFrontEndCert: !!isFrontEndCert.value,
isFullStackCert: !!isFullStackCert.value,
isRemoteOk: !!isRemoteOk.value,
howToApply: inHTMLData(howToApply.value)
}; };
const job = Object.keys(jobValues).reduce((accu, prop) => { const job = Object.keys(jobValues).reduce((accu, prop) => {
@ -179,11 +251,14 @@ export default contain({
locale, locale,
description, description,
email, email,
phone,
url, url,
logo, logo,
name, company,
highlight, isHighlighted,
isFrontEndCert,
isFullStackCert,
isRemoteOk,
howToApply,
jobActions: { handleForm } jobActions: { handleForm }
} = this.props; } = this.props;
const { handleChange } = this; const { handleChange } = this;
@ -193,22 +268,26 @@ export default contain({
return ( return (
<div> <div>
<Row> <Row>
<Col> <Col
<Well className='text-center'> md={ 10 }
<h1>Create Your Job Post</h1> mdOffset={ 1 }>
<Panel className='text-center'>
<form <form
className='form-horizontal' className='form-horizontal'
onSubmit={ this.handleSubmit }> onSubmit={ this.handleSubmit }>
<div className='spacer'> <div className='spacer'>
<h2>Job Information</h2> <h2>First, tell us about the position</h2>
</div> </div>
<Input <Input
bsStyle={ position.bsStyle } bsStyle={ position.bsStyle }
label='Position' label='Job Title'
labelClassName={ labelClass } labelClassName={ labelClass }
onChange={ (e) => handleChange('position', e) } onChange={ (e) => handleChange('position', e) }
placeholder='Position' placeholder={
'e.g. Full Stack Developer, Front End Developer, etc.'
}
required={ true }
type='text' type='text'
value={ position.value } value={ position.value }
wrapperClassName={ inputClass } /> wrapperClassName={ inputClass } />
@ -217,7 +296,8 @@ export default contain({
label='Location' label='Location'
labelClassName={ labelClass } labelClassName={ labelClass }
onChange={ (e) => handleChange('locale', e) } onChange={ (e) => handleChange('locale', e) }
placeholder='Location' placeholder='e.g. San Francisco, Remote, etc.'
required={ true }
type='text' type='text'
value={ locale.value } value={ locale.value }
wrapperClassName={ inputClass } /> wrapperClassName={ inputClass } />
@ -226,48 +306,90 @@ export default contain({
label='Description' label='Description'
labelClassName={ labelClass } labelClassName={ labelClass }
onChange={ (e) => handleChange('description', e) } onChange={ (e) => handleChange('description', e) }
placeholder='Description' required={ true }
rows='10' rows='10'
type='textarea' type='textarea'
value={ description.value } value={ description.value }
wrapperClassName={ inputClass } /> wrapperClassName={ inputClass } />
<Input
<div className='divider'> checked={ isFrontEndCert.value }
<h2>Company Information</h2> label={ isFrontEndCopy }
onChange={
({ target: { checked } }) => handleForm({
isFrontEndCert: !!checked
})
}
type='checkbox'
wrapperClassName={ checkboxClass } />
<Input
checked={ isFullStackCert.value }
label={ isFullStackCopy }
onChange={
({ target: { checked } }) => handleForm({
isFullStackCert: !!checked
})
}
type='checkbox'
wrapperClassName={ checkboxClass } />
<Input
checked={ isRemoteOk.value }
label={ isRemoteCopy }
onChange={
({ target: { checked } }) => handleForm({
isRemoteOk: !!checked
})
}
type='checkbox'
wrapperClassName={ checkboxClass } />
<Row>
<small>* { foo }</small>
</Row>
<div className='spacer' />
<Row>
<div>
<h2>How should they apply?</h2>
</div> </div>
<Input <Input
bsStyle={ name.bsStyle } bsStyle={ howToApply.bsStyle }
label=' '
labelClassName={ labelClass }
onChange={ (e) => handleChange('howToApply', e) }
placeholder={ howToApplyCopy }
required={ true }
rows='2'
type='textarea'
value={ howToApply.value }
wrapperClassName={ inputClass } />
</Row>
<div className='spacer' />
<div>
<h2>Tell us about your organization</h2>
</div>
<Input
bsStyle={ company.bsStyle }
label='Company Name' label='Company Name'
labelClassName={ labelClass } labelClassName={ labelClass }
onChange={ (e) => handleChange('name', e) } onChange={ (e) => handleChange('company', e) }
placeholder='Foo, INC'
type='text' type='text'
value={ name.value } value={ company.value }
wrapperClassName={ inputClass } /> wrapperClassName={ inputClass } />
<Input <Input
bsStyle={ email.bsStyle } bsStyle={ email.bsStyle }
label='Email' label='Email'
labelClassName={ labelClass } labelClassName={ labelClass }
onChange={ (e) => handleChange('email', e) } onChange={ (e) => handleChange('email', e) }
placeholder='Email' placeholder='This is how we will contact you'
required={ true }
type='email' type='email'
value={ email.value } value={ email.value }
wrapperClassName={ inputClass } /> wrapperClassName={ inputClass } />
<Input
bsStyle={ phone.bsStyle }
label='Phone'
labelClassName={ labelClass }
onChange={ (e) => handleChange('phone', e) }
placeholder='555-123-1234'
type='tel'
value={ phone.value }
wrapperClassName={ inputClass } />
<Input <Input
bsStyle={ url.bsStyle } bsStyle={ url.bsStyle }
label='URL' label='URL'
labelClassName={ labelClass } labelClassName={ labelClass }
onChange={ (e) => handleChange('url', e) } onChange={ (e) => handleChange('url', e) }
placeholder='http://freecatphotoapp.com' placeholder='http://yourcompany.com'
type='url' type='url'
value={ url.value } value={ url.value }
wrapperClassName={ inputClass } /> wrapperClassName={ inputClass } />
@ -276,27 +398,48 @@ export default contain({
label='Logo' label='Logo'
labelClassName={ labelClass } labelClassName={ labelClass }
onChange={ (e) => handleChange('logo', e) } onChange={ (e) => handleChange('logo', e) }
placeholder='http://freecatphotoapp.com/logo.png' placeholder='http://yourcompany.com/logo.png'
type='url' type='url'
value={ logo.value } value={ logo.value }
wrapperClassName={ inputClass } /> wrapperClassName={ inputClass } />
<div className='divider'> <div className='spacer' />
<Well>
<div>
<h2>Make it stand out</h2> <h2>Make it stand out</h2>
</div> </div>
<Input
checked={ highlight.value }
label='Highlight your ad'
labelClassName={ 'col-sm-offset-1 col-sm-6'}
onChange={
({ target: { checked } }) => handleForm({
highlight: !!checked
})
}
type='checkbox' />
<div className='spacer' /> <div className='spacer' />
<Row> <Row>
<Col <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'
checked={ isHighlighted.value }
label={ hightlightCopy }
onChange={
({ target: { checked } }) => handleForm({
isHighlighted: !!checked
})
}
type='checkbox'
wrapperClassName={
checkboxClass.replace('text-left', '')
} />
</Row>
</Well>
<Row>
<Col
className='text-left'
lg={ 6 } lg={ 6 }
lgOffset={ 3 }> lgOffset={ 3 }>
<Button <Button
@ -309,7 +452,7 @@ export default contain({
</Col> </Col>
</Row> </Row>
</form> </form>
</Well> </Panel>
</Col> </Col>
</Row> </Row>
</div> </div>

View File

@ -0,0 +1,39 @@
import React from 'react';
import { LinkContainer } from 'react-router-bootstrap';
import { Button, Panel, Col, Row } from 'react-bootstrap';
export default React.createClass({
displayName: 'NewJobCompleted',
render() {
return (
<div className='text-center'>
<Panel>
<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>
</Panel>
</div>
);
}
});

View File

@ -1,14 +1,85 @@
// import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import { Lifecycle } from 'react-router';
import { Panel, Button, Row, Col } from 'react-bootstrap';
import { contain } from 'thundercats-react'; import { contain } from 'thundercats-react';
import ShowJob from './ShowJob.jsx'; import ShowJob from './ShowJob.jsx';
import JobNotFound from './JobNotFound.jsx';
export default contain( export default contain(
{ {
store: 'JobsStore', store: 'JobsStore',
actions: 'JobActions', actions: [
'appActions',
'jobActions'
],
map({ form: job = {} }) { map({ form: job = {} }) {
return { job }; return { job };
} }
}, },
ShowJob React.createClass({
displayName: 'Preview',
propTypes: {
appActions: PropTypes.object,
job: PropTypes.object,
jobActions: PropTypes.object
},
mixins: [Lifecycle],
componentDidMount() {
const { appActions, job } = this.props;
// redirect user in client
if (!job || !job.position || !job.description) {
appActions.updateRoute('/jobs/new');
}
},
routerWillLeave() {
const { jobActions } = this.props;
jobActions.clearPromo();
},
render() {
const { appActions, job, jobActions } = this.props;
if (!job || !job.position || !job.description) {
return <JobNotFound />;
}
return (
<div>
<ShowJob job={ job } />
<Row>
<Col
md={ 10 }
mdOffset={ 1 }
xs={ 12 }>
<Panel>
<Button
block={ true }
className='signup-btn'
onClick={ () => {
jobActions.clearSavedForm();
jobActions.saveJobToDb({
goTo: '/jobs/new/check-out',
job
});
}}>
Looks great! Let's Check Out
</Button>
<Button
block={ true }
onClick={ () => appActions.goBack() } >
Head back and make edits
</Button>
</Panel>
</Col>
</Row>
</div>
);
}
})
); );

View File

@ -1,5 +1,10 @@
import React, { createClass } from 'react';
import { History } from 'react-router';
import { contain } from 'thundercats-react'; import { contain } from 'thundercats-react';
import ShowJob from './ShowJob.jsx'; import ShowJob from './ShowJob.jsx';
import JobNotFound from './JobNotFound.jsx';
import { isJobValid } from '../utils';
export default contain( export default contain(
{ {
@ -20,5 +25,26 @@ export default contain(
return job.id !== id; return job.id !== id;
} }
}, },
ShowJob createClass({
displayName: 'Show',
mixins: [History],
componentDidMount() {
const { job } = this.props;
// redirect user in client
if (!isJobValid(job)) {
this.history.pushState(null, '/jobs');
}
},
render() {
const { job } = this.props;
if (!isJobValid(job)) {
return <JobNotFound />;
}
return <ShowJob { ...this.props }/>;
}
})
); );

View File

@ -1,6 +1,10 @@
import React, { PropTypes } from 'react'; import React, { PropTypes } from 'react';
import { Row, Thumbnail, Panel, Well } from 'react-bootstrap'; import { Well, Row, Col, Thumbnail, Panel } from 'react-bootstrap';
import moment from 'moment'; import urlRegexFactory from 'url-regex';
const urlRegex = urlRegexFactory();
const defaultImage =
'https://s3.amazonaws.com/freecodecamp/camper-image-placeholder.png';
const thumbnailStyle = { const thumbnailStyle = {
backgroundColor: 'white', backgroundColor: 'white',
@ -8,6 +12,12 @@ const thumbnailStyle = {
maxWidth: '100px' maxWidth: '100px'
}; };
function addATags(text) {
return text.replace(urlRegex, function(match) {
return `<a href=${match}>${match}</a>`;
});
}
export default React.createClass({ export default React.createClass({
displayName: 'ShowJob', displayName: 'ShowJob',
propTypes: { propTypes: {
@ -36,30 +46,69 @@ export default React.createClass({
city, city,
company, company,
state, state,
email, locale,
phone, description,
postedOn, howToApply
description
} = job; } = job;
return ( return (
<div> <div>
<Row> <Row>
<Well> <Col
<Thumbnail md={ 10 }
alt={ company + 'company logo' } mdOffset={ 1 }
src={ logo } xs={ 12 }>
style={ thumbnailStyle } />
<Panel> <Panel>
Position: { position } <Row>
Location: { city }, { state } <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 /> <br />
Contact: { email || phone || 'N/A' } <bold>Location: </bold>
<br /> { locale ? locale : `${city}, ${state}` }
Posted On: { moment(postedOn).format('MMMM Do, YYYY') } </Col>
</Panel> </Row>
<div className='spacer' />
<Row>
<Col
md={ 6 }
mdOffset={ 3 }
style={{ whiteSpace: 'pre-line' }}
xs={ 12 }>
<p>{ description }</p> <p>{ description }</p>
</Col>
</Row>
<Well>
<Row>
<Col
md={ 6 }
mdOffset={ 3 }>
<bold>How do I apply?</bold>
<br />
<br />
<span dangerouslySetInnerHTML={{
__html: addATags(howToApply)
}} />
</Col>
</Row>
</Well> </Well>
</Panel>
</Col>
</Row> </Row>
</div> </div>
); );

View File

@ -0,0 +1,33 @@
import React, { createClass, PropTypes } from 'react';
import { Button } from 'react-bootstrap';
const followLink = 'https://twitter.com/intent/follow?' +
'ref_src=twsrc%5Etfw&amp;region=follow_link&amp;screen_name=CamperJobs&' +
'amp;tw_p=followbutton';
function commify(count) {
return Number(count).toLocaleString('en');
}
export default createClass({
displayName: 'FollowButton',
propTypes: {
count: PropTypes.number
},
render() {
const { count } = this.props;
return (
<Button
block={ true }
bsSize='large'
bsStyle='primary'
href={ followLink }
target='__blank'>
Join { commify(count) } followers who see our job postings on Twitter.
</Button>
);
}
});

View File

@ -1,6 +1,8 @@
import { Actions } from 'thundercats'; import { Actions } from 'thundercats';
import store from 'store'; import store from 'store';
import debugFactory from 'debug'; import debugFactory from 'debug';
import { jsonp$ } from '../../../../utils/jsonp$';
import { postJSON$ } from '../../../../utils/ajax-stream';
const debug = debugFactory('freecc:jobs:actions'); const debug = debugFactory('freecc:jobs:actions');
const assign = Object.assign; const assign = Object.assign;
@ -31,6 +33,7 @@ export default Actions({
}, },
setError: null, setError: null,
getJob: null, getJob: null,
saveJobToDb: null,
getJobs(params) { getJobs(params) {
return { params }; return { params };
}, },
@ -56,12 +59,47 @@ export default Actions({
}, },
saveForm: null, saveForm: null,
getSavedForm: null, getSavedForm: null,
clearSavedForm: null,
setForm(form) { setForm(form) {
return { form }; return { form };
},
getFollowers: null,
setFollowersCount(numOfFollowers) {
return { numOfFollowers };
},
setPromoCode({ target: { value = '' }} = {}) {
return { promoCode: value.replace(/[^\d\w\s]/, '') };
},
applyCode: null,
clearPromo(foo, undef) {
return {
price: undef,
buttonId: undef,
discountAmount: undef,
promoCode: undef,
promoApplied: false,
promoName: undef
};
},
applyPromo({
fullPrice: price,
buttonId,
discountAmount,
code: promoCode,
name: promoName
} = {}) {
return {
price,
buttonId,
discountAmount,
promoCode,
promoApplied: true,
promoName
};
} }
}) })
.refs({ displayName: 'JobActions' }) .refs({ displayName: 'JobActions' })
.init(({ instance: jobActions, args: [services] }) => { .init(({ instance: jobActions, args: [cat, services] }) => {
jobActions.getJobs.subscribe(() => { jobActions.getJobs.subscribe(() => {
services.read('jobs', null, null, (err, jobs) => { services.read('jobs', null, null, (err, jobs) => {
if (err) { if (err) {
@ -100,5 +138,55 @@ export default Actions({
jobActions.setForm(job); jobActions.setForm(job);
} }
}); });
jobActions.clearSavedForm.subscribe(() => {
store.remove('newJob');
});
jobActions.saveJobToDb.subscribe(({ goTo, job }) => {
const appActions = cat.getActions('appActions');
services.create('jobs', { job }, null, (err, job) => {
if (err) {
debug('job services experienced an issue', err);
return jobActions.setError(err);
}
jobActions.setJobs({ job });
appActions.updateRoute(goTo);
});
});
jobActions.getFollowers.subscribe(() => {
const url = 'https://cdn.syndication.twimg.com/widgets/followbutton/' +
'info.json?lang=en&screen_names=CamperJobs' +
'&callback=JSONPCallback';
jsonp$(url)
.map(({ response }) => {
return response[0]['followers_count'];
})
.subscribe(
count => jobActions.setFollowersCount(count),
err => jobActions.setError(err)
);
});
jobActions.applyCode.subscribe(({ code = '', type = null}) => {
const body = { code: code.replace(/[^\d\w\s]/, '') };
if (type) {
body.type = type;
}
postJSON$('/api/promos/getButton', body)
.pluck('response')
.subscribe(
({ promo }) => {
if (promo && promo.buttonId) {
jobActions.applyPromo(promo);
}
jobActions.setError(new Error('no promo found'));
},
jobActions.setError
);
});
return jobActions; return jobActions;
}); });

View File

@ -19,7 +19,11 @@ export default Store({
openModal, openModal,
closeModal, closeModal,
handleForm, handleForm,
setForm setForm,
setFollowersCount,
setPromoCode,
applyPromo,
clearPromo
} = cat.getActions('JobActions'); } = cat.getActions('JobActions');
const register = createRegistrar(jobsStore); const register = createRegistrar(jobsStore);
register(setter(setJobs)); register(setter(setJobs));
@ -27,6 +31,10 @@ export default Store({
register(setter(openModal)); register(setter(openModal));
register(setter(closeModal)); register(setter(closeModal));
register(setter(setForm)); register(setter(setForm));
register(setter(setPromoCode));
register(setter(applyPromo));
register(setter(clearPromo));
register(setter(setFollowersCount));
register(transformer(findJob)); register(transformer(findJob));
register(handleForm); register(handleForm);

View File

@ -2,6 +2,8 @@ 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 NewJobCompleted from './components/NewJobCompleted.jsx';
/* /*
* index: /jobs list jobs * index: /jobs list jobs
@ -19,6 +21,12 @@ export default {
}, { }, {
path: 'jobs/new/preview', path: 'jobs/new/preview',
component: Preview component: Preview
}, {
path: 'jobs/new/check-out',
component: GoToPayPal
}, {
path: 'jobs/new/completed',
component: NewJobCompleted
}, { }, {
path: 'jobs/:id', path: 'jobs/:id',
component: Show component: Show

View File

@ -20,3 +20,10 @@ export function getDefaults(type, value) {
} }
return Object.assign({}, defaults[type]); return Object.assign({}, defaults[type]);
} }
export function isJobValid(job) {
return job &&
!job.isFilled &&
job.isApproved &&
job.isPaid;
}

View File

@ -22,7 +22,8 @@
"type": "string" "type": "string"
}, },
"email": { "email": {
"type": "string" "type": "string",
"required": true
}, },
"phone": { "phone": {
"type": "string" "type": "string"
@ -36,24 +37,57 @@
"country": { "country": {
"type": "string" "type": "string"
}, },
"locale": {
"type": "string",
"required": true,
"description": "format: city, state"
},
"location": { "location": {
"type": "geopoint" "type": "geopoint",
"description": "location in lat, long"
}, },
"description": { "description": {
"type": "string" "type": "string"
}, },
"isApproved": { "isApproved": {
"type": "boolean" "type": "boolean",
"default": false
}, },
"isHighlighted": { "isHighlighted": {
"type": "boolean" "type": "boolean",
"default": false
}, },
"isPaid": { "isPaid": {
"type": "boolean" "type": "boolean",
"default": false
},
"isFilled": {
"type": "boolean",
"default": false
}, },
"postedOn": { "postedOn": {
"type": "date", "type": "date",
"defaultFn": "now" "defaultFn": "now"
},
"isFrontEndCert": {
"type": "boolean",
"defaut": false,
"description": "Camper must be front end certified to apply"
},
"isFullStackCert": {
"type": "boolean",
"default": false,
"description": "Camper must be full stack certified to apply"
},
"isRemoteOk": {
"type": "boolean",
"default": false,
"description": "Camper may work remotely"
},
"howToApply": {
"type": "string",
"required": true,
"description": "How do campers apply to job"
} }
}, },
"validations": [], "validations": [],

47
common/models/promo.js Normal file
View File

@ -0,0 +1,47 @@
import { isAlphanumeric } from 'validator';
export default function promo(Promo) {
Promo.getButton = function getButton(code, type = 'isNot') {
if (
!isAlphanumeric(code) &&
type &&
!isAlphanumeric(type)
) {
return Promise.reject(new Error(
'Code or Type should be an alphanumeric'
));
}
const query = {
where: {
and: [{ code }, { type }]
}
};
return Promo.findOne(query);
};
Promo.remoteMethod(
'getButton',
{
description: 'Get button id for promocode',
accepts: [
{
arg: 'code',
type: 'string',
required: true
},
{
arg: 'type',
type: 'string'
}
],
returns: [
{
arg: 'promo',
type: 'object'
}
]
}
);
}

59
common/models/promo.json Normal file
View File

@ -0,0 +1,59 @@
{
"name": "promo",
"base": "PersistedModel",
"strict": true,
"idInjection": true,
"trackChanges": false,
"properties": {
"code": {
"type": "string",
"required": true,
"description": "The code to unlock the promotional discount"
},
"name": {
"type": "string",
"required": true,
"description": "The name of the discount"
},
"buttonId": {
"type": "string",
"required": true,
"description": "The id of paypal button"
},
"type": {
"type": "string",
"description": "A selector of different types of buttons for the same discount"
},
"fullPrice": {
"type": "number",
"required": true,
"description": "The original amount"
},
"discountAmount": {
"type": "number",
"description": "The amount of the discount if applicable"
},
"discountPercent": {
"type": "number",
"description": "The amount of discount as a percentage if applicable"
}
},
"validations": [],
"relations": {},
"acls": [
{
"accessType": "*",
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "DENY"
},
{
"accessType": "EXECUTE",
"principalType": "ROLE",
"principalId": "$everyone",
"permission": "ALLOW",
"property": "getButton"
}
],
"methods": []
}

View File

@ -255,6 +255,7 @@ export function postJSON$(url, body) {
url, url,
body: JSON.stringify(body), body: JSON.stringify(body),
method: 'POST', method: 'POST',
responseType: 'json',
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
}); });
} }
@ -277,9 +278,6 @@ export function get$(url) {
* @returns {Observable} The observable sequence which contains the parsed JSON * @returns {Observable} The observable sequence which contains the parsed JSON
*/ */
export function getJSON$(url) { export function getJSON$(url) {
if (!root.JSON && typeof root.JSON.parse !== 'function') {
throw new TypeError('JSON is not supported in your runtime.');
}
return ajax$({ url: url, responseType: 'json' }).map(function(x) { return ajax$({ url: url, responseType: 'json' }).map(function(x) {
return x.response; return x.response;
}); });

77
common/utils/jsonp$.js Normal file
View File

@ -0,0 +1,77 @@
import { AnonymousObservable, Disposable } from 'rx';
const root = typeof window !== 'undefined' ? window : {};
const trash = 'document' in root && root.document.createElement('div');
function destroy(element) {
trash.appendChild(element);
trash.innerHTML = '';
}
export function jsonp$(options) {
let id = 0;
if (typeof options === 'string') {
options = { url: options };
}
return new AnonymousObservable(function(o) {
const settings = Object.assign(
{},
{
jsonp: 'JSONPCallback',
async: true,
jsonpCallback: 'rxjsjsonpCallbackscallback_' + (id++).toString(36)
},
options
);
let script = root.document.createElement('script');
script.type = 'text/javascript';
script.async = settings.async;
script.src = settings.url.replace(settings.jsonp, settings.jsonpCallback);
root[settings.jsonpCallback] = function(data) {
root[settings.jsonpCallback].called = true;
root[settings.jsonpCallback].data = data;
};
const handler = function(e) {
if (e.type === 'load' && !root[settings.jsonpCallback].called) {
e = { type: 'error' };
}
const status = e.type === 'error' ? 400 : 200;
const data = root[settings.jsonpCallback].data;
if (status === 200) {
o.onNext({
status: status,
responseType: 'jsonp',
response: data,
originalEvent: e
});
o.onCompleted();
} else {
o.onError({
type: 'error',
status: status,
originalEvent: e
});
}
};
script.onload = script.onreadystatechanged = script.onerror = handler;
const head = root.document.getElementsByTagName('head')[0] ||
root.document.documentElement;
head.insertBefore(script, head.firstChild);
return Disposable.create(() => {
script.onload = script.onreadystatechanged = script.onerror = null;
destroy(script);
script = null;
});
});
}

View File

@ -59,11 +59,11 @@
"gulp-eslint": "~0.9.0", "gulp-eslint": "~0.9.0",
"gulp-inject": "~1.0.2", "gulp-inject": "~1.0.2",
"gulp-jsonlint": "^1.1.0", "gulp-jsonlint": "^1.1.0",
"gulp-less": "^3.0.3",
"gulp-minify-css": "~0.5.1",
"gulp-nodemon": "^2.0.3", "gulp-nodemon": "^2.0.3",
"gulp-notify": "^2.2.0", "gulp-notify": "^2.2.0",
"gulp-plumber": "^1.0.1", "gulp-plumber": "^1.0.1",
"gulp-less": "^3.0.3",
"gulp-minify-css": "~0.5.1",
"gulp-reduce-file": "0.0.1", "gulp-reduce-file": "0.0.1",
"gulp-rev": "^6.0.1", "gulp-rev": "^6.0.1",
"gulp-rev-replace": "^0.4.2", "gulp-rev-replace": "^0.4.2",
@ -89,6 +89,7 @@
"node-slack": "0.0.7", "node-slack": "0.0.7",
"node-uuid": "^1.4.3", "node-uuid": "^1.4.3",
"nodemailer": "~1.3.0", "nodemailer": "~1.3.0",
"normalize-url": "^1.3.1",
"object.assign": "^3.0.0", "object.assign": "^3.0.0",
"passport-facebook": "^2.0.0", "passport-facebook": "^2.0.0",
"passport-github": "^0.1.5", "passport-github": "^0.1.5",
@ -103,6 +104,7 @@
"react-bootstrap": "~0.23.7", "react-bootstrap": "~0.23.7",
"react-motion": "~0.1.0", "react-motion": "~0.1.0",
"react-router": "https://github.com/BerkeleyTrue/react-router.git#freecodecamp", "react-router": "https://github.com/BerkeleyTrue/react-router.git#freecodecamp",
"react-router-bootstrap": "^0.19.2",
"react-vimeo": "^0.0.3", "react-vimeo": "^0.0.3",
"request": "~2.53.0", "request": "~2.53.0",
"rev-del": "^1.0.5", "rev-del": "^1.0.5",
@ -115,6 +117,7 @@
"thundercats-react": "^0.3.0", "thundercats-react": "^0.3.0",
"twit": "~1.1.20", "twit": "~1.1.20",
"uglify-js": "~2.4.15", "uglify-js": "~2.4.15",
"url-regex": "^3.0.0",
"validator": "^3.22.1", "validator": "^3.22.1",
"webpack": "^1.9.12", "webpack": "^1.9.12",
"xss-filters": "^1.2.6", "xss-filters": "^1.2.6",

View File

@ -7,3 +7,8 @@
font-family: "Lato Light"; font-family: "Lato Light";
src: url(/fonts/Lato-Light.ttf) format("truetype"); src: url(/fonts/Lato-Light.ttf) format("truetype");
} }
@font-face {
font-family: "Lato Bold";
src: url(/fonts/Lato-Bold.ttf) format("truetype");
}

BIN
public/fonts/Lato-Bold.ttf Executable file

Binary file not shown.

View File

@ -11,17 +11,25 @@ const debug = debugFactory('freecc: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
const routes = [ const routes = [
'/hikes',
'/hikes/*',
'/jobs', '/jobs',
'/jobs/*' '/jobs/*'
]; ];
const devRoutes = [
'/hikes',
'/hikes/*'
];
export default function reactSubRouter(app) { export default function reactSubRouter(app) {
var router = app.loopback.Router(); var router = app.loopback.Router();
// These routes are in production
routes.forEach((route) => {
router.get(route, serveReactApp);
});
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
routes.forEach(function(route) { devRoutes.forEach(function(route) {
router.get(route, serveReactApp); router.get(route, serveReactApp);
}); });
} }

View File

@ -51,6 +51,10 @@
"dataSource": "db", "dataSource": "db",
"public": true "public": true
}, },
"promo": {
"dataSource": "db",
"public": true
},
"user": { "user": {
"dataSource": "db", "dataSource": "db",
"public": true "public": true

View File

@ -1,14 +1,36 @@
const whereFilt = {
where: {
isFilled: false,
isPaid: true,
isApproved: true
}
};
export default function getJobServices(app) { export default function getJobServices(app) {
const { Job } = app.models; const { Job } = app.models;
return { return {
name: 'jobs', name: 'jobs',
read: (req, resource, params, config, cb) => { create(req, resource, { job } = {}, body, config, cb) {
if (!job) {
return cb(new Error('job creation should get a job object'));
}
Object.assign(job, {
isPaid: false,
isApproved: false
});
Job.create(job, (err, savedJob) => {
cb(err, savedJob);
});
},
read(req, resource, params, config, cb) {
const id = params ? params.id : null; const id = params ? params.id : null;
if (id) { if (id) {
return Job.findById(id, cb); return Job.findById(id, cb);
} }
Job.find({}, (err, jobs) => { Job.find(whereFilt, (err, jobs) => {
cb(err, jobs); cb(err, jobs);
}); });
} }

View File

@ -9,7 +9,7 @@ const protectedUserFields = {
profiles: censor profiles: censor
}; };
export default function userServices(/* app */) { export default function userServices() {
return { return {
name: 'user', name: 'user',
read: (req, resource, params, config, cb) => { read: (req, resource, params, config, cb) => {

View File

@ -1,14 +1,13 @@
doctype html doctype html
html(ng-app='profileValidation', lang='en') html(lang='en')
head head
if title if title
title= title title= title
else else
title redirecting to | Free Code Camp title Free Code Camp
include partials/small-head include partials/react-stylesheets
body.top-and-bottom-margins(style='overflow: hidden') body.top-and-bottom-margins(style='overflow: hidden')
.container .container
include partials/flash
#fcc!= markup #fcc!= markup
script!= state script!= state
script(src=rev('/js', 'bundle.js')) script(src=rev('/js', 'bundle.js'))

View File

@ -1,9 +1,7 @@
script(src="//cdnjs.cloudflare.com/ajax/libs/jquery/2.1.3/jquery.min.js") link(rel='stylesheet', type='text/css' href='/css/lato.css')
script(src="//cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.4/js/bootstrap.min.js")
link(rel='stylesheet', href='/bower_components/font-awesome/css/font-awesome.min.css') link(rel='stylesheet', href='/bower_components/font-awesome/css/font-awesome.min.css')
link(rel='stylesheet', href='/css/main.css') link(rel='stylesheet', href=rev('/css', 'main.css'))
link(rel='stylesheet', href='/css/Vimeo.css') link(rel='stylesheet', href='/css/Vimeo.css')
// End **REQUIRED** includes
include meta include meta
meta(charset='utf-8') meta(charset='utf-8')

View File

@ -1,14 +1,30 @@
doctype html doctype html
html(lang='en') html(lang='en')
head script(src="//cdnjs.cloudflare.com/ajax/libs/jquery/2.1.3/jquery.min.js")
include partials/small-head script(src="//cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.4/js/bootstrap.min.js")
link(rel='stylesheet', href='/bower_components/font-awesome/css/font-awesome.min.css')
link(rel='stylesheet', href='/css/main.css')
link(rel='stylesheet', href='/css/Vimeo.css')
include partials/meta
meta(charset='utf-8')
meta(http-equiv='X-UA-Compatible', content='IE=edge')
meta(name='viewport', content='width=device-width, initial-scale=1.0')
meta(name='csrf-token', content=_csrf)
script.
(function(i,s,o,g,r,a,m){ i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','//www.google-analytics.com/analytics.js','ga');
ga('create', 'UA-55446531-1', 'auto');
ga('require', 'displayfeatures');
ga('send', 'pageview');
body.top-and-bottom-margins body.top-and-bottom-margins
include partials/navbar include partials/navbar
.container .container
.row .row
.panel.panel-info .panel.panel-info
p redirecting you... please wait... p redirecting you... please wait...
include partials/footer
script. script.
setTimeout(function() { setTimeout(function() {
window.location = 'http://freecodecamp.com' window.location = 'http://freecodecamp.com'