This commit is contained in:
2017-02-10 00:33:25 +00:00
commit a3e6ce3fb5
99 changed files with 11317 additions and 0 deletions

189
src/client.js Normal file
View File

@@ -0,0 +1,189 @@
import 'babel-polyfill';
import React from 'react';
import ReactDOM from 'react-dom';
import FastClick from 'fastclick';
import UniversalRouter from 'universal-router';
import queryString from 'query-string';
import { createPath } from 'history/PathUtils';
import history from './core/history';
import App from './components/App';
import { ErrorReporter, deepForceUpdate } from './core/devUtils';
// Global (context) variables that can be easily accessed from any React component
// https://facebook.github.io/react/docs/context.html
const context = {
// Enables critical path CSS rendering
// https://github.com/kriasoft/isomorphic-style-loader
insertCss: (...styles) => {
// eslint-disable-next-line no-underscore-dangle
const removeCss = styles.map(x => x._insertCss());
return () => { removeCss.forEach(f => f()); };
},
};
function updateTag(tagName, keyName, keyValue, attrName, attrValue) {
const node = document.head.querySelector(`${tagName}[${keyName}="${keyValue}"]`);
if (node && node.getAttribute(attrName) === attrValue) return;
// Remove and create a new tag in order to make it work with bookmarks in Safari
if (node) {
node.parentNode.removeChild(node);
}
if (typeof attrValue === 'string') {
const nextNode = document.createElement(tagName);
nextNode.setAttribute(keyName, keyValue);
nextNode.setAttribute(attrName, attrValue);
document.head.appendChild(nextNode);
}
}
function updateMeta(name, content) {
updateTag('meta', 'name', name, 'content', content);
}
function updateCustomMeta(property, content) { // eslint-disable-line no-unused-vars
updateTag('meta', 'property', property, 'content', content);
}
function updateLink(rel, href) { // eslint-disable-line no-unused-vars
updateTag('link', 'rel', rel, 'href', href);
}
// Switch off the native scroll restoration behavior and handle it manually
// https://developers.google.com/web/updates/2015/09/history-api-scroll-restoration
const scrollPositionsHistory = {};
if (window.history && 'scrollRestoration' in window.history) {
window.history.scrollRestoration = 'manual';
}
let onRenderComplete = function initialRenderComplete() {
const elem = document.getElementById('css');
if (elem) elem.parentNode.removeChild(elem);
onRenderComplete = function renderComplete(route, location) {
document.title = route.title;
updateMeta('description', route.description);
// Update necessary tags in <head> at runtime here, ie:
// updateMeta('keywords', route.keywords);
// updateCustomMeta('og:url', route.canonicalUrl);
// updateCustomMeta('og:image', route.imageUrl);
// updateLink('canonical', route.canonicalUrl);
// etc.
let scrollX = 0;
let scrollY = 0;
const pos = scrollPositionsHistory[location.key];
if (pos) {
scrollX = pos.scrollX;
scrollY = pos.scrollY;
} else {
const targetHash = location.hash.substr(1);
if (targetHash) {
const target = document.getElementById(targetHash);
if (target) {
scrollY = window.pageYOffset + target.getBoundingClientRect().top;
}
}
}
// Restore the scroll position if it was saved into the state
// or scroll to the given #hash anchor
// or scroll to top of the page
window.scrollTo(scrollX, scrollY);
// Google Analytics tracking. Don't send 'pageview' event after
// the initial rendering, as it was already sent
if (window.ga) {
window.ga('send', 'pageview', createPath(location));
}
};
};
// Make taps on links and buttons work fast on mobiles
FastClick.attach(document.body);
const container = document.getElementById('app');
let appInstance;
let currentLocation = history.location;
let routes = require('./routes').default;
// Re-render the app when window.location changes
async function onLocationChange(location) {
// Remember the latest scroll position for the previous location
scrollPositionsHistory[currentLocation.key] = {
scrollX: window.pageXOffset,
scrollY: window.pageYOffset,
};
// Delete stored scroll position for next page if any
if (history.action === 'PUSH') {
delete scrollPositionsHistory[location.key];
}
currentLocation = location;
try {
// Traverses the list of routes in the order they are defined until
// it finds the first route that matches provided URL path string
// and whose action method returns anything other than `undefined`.
const route = await UniversalRouter.resolve(routes, {
path: location.pathname,
query: queryString.parse(location.search),
});
// Prevent multiple page renders during the routing process
if (currentLocation.key !== location.key) {
return;
}
if (route.redirect) {
history.replace(route.redirect);
return;
}
appInstance = ReactDOM.render(
<App context={context}>{route.component}</App>,
container,
() => onRenderComplete(route, location),
);
} catch (error) {
console.error(error); // eslint-disable-line no-console
// Current url has been changed during navigation process, do nothing
if (currentLocation.key !== location.key) {
return;
}
// Display the error in full-screen for development mode
if (process.env.NODE_ENV !== 'production') {
appInstance = null;
document.title = `Error: ${error.message}`;
ReactDOM.render(<ErrorReporter error={error} />, container);
return;
}
// Avoid broken navigation in production mode by a full page reload on error
window.location.reload();
}
}
// Handle client-side navigation by using HTML5 History API
// For more information visit https://github.com/mjackson/history#readme
history.listen(onLocationChange);
onLocationChange(currentLocation);
// Enable Hot Module Replacement (HMR)
if (module.hot) {
module.hot.accept('./routes', () => {
routes = require('./routes').default; // eslint-disable-line global-require
if (appInstance) {
try {
// Force-update the whole tree, including components that refuse to update
deepForceUpdate(appInstance);
} catch (error) {
appInstance = null;
document.title = `Hot Update Error: ${error.message}`;
ReactDOM.render(<ErrorReporter error={error} />, container);
return;
}
}
onLocationChange(currentLocation);
});
}

52
src/components/App.js Normal file
View File

@@ -0,0 +1,52 @@
import React, { PropTypes } from 'react';
const ContextType = {
// Enables critical path CSS rendering
// https://github.com/kriasoft/isomorphic-style-loader
insertCss: PropTypes.func.isRequired,
};
/**
* The top-level React component setting context (global) variables
* that can be accessed from all the child components.
*
* https://facebook.github.io/react/docs/context.html
*
* Usage example:
*
* const context = {
* history: createBrowserHistory(),
* store: createStore(),
* };
*
* ReactDOM.render(
* <App context={context}>
* <Layout>
* <LandingPage />
* </Layout>
* </App>,
* container,
* );
*/
class App extends React.PureComponent {
static propTypes = {
context: PropTypes.shape(ContextType).isRequired,
children: PropTypes.element.isRequired,
};
static childContextTypes = ContextType;
getChildContext() {
return this.props.context;
}
render() {
// NOTE: If you need to add or modify header, footer etc. of the app,
// please do that inside the Layout component.
return React.Children.only(this.props.children);
}
}
export default App;

45
src/components/Html.js Normal file
View File

@@ -0,0 +1,45 @@
import React, { PropTypes } from 'react';
import { analytics } from '../config';
class Html extends React.Component {
static propTypes = {
title: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
style: PropTypes.string,
scripts: PropTypes.arrayOf(PropTypes.string.isRequired),
children: PropTypes.string,
};
render() {
const { title, description, style, scripts, children } = this.props;
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="x-ua-compatible" content="ie=edge" />
<title>{title}</title>
<meta name="description" content={description} />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
{style && <style id="css" dangerouslySetInnerHTML={{ __html: style }} />}
</head>
<body>
<div id="app" dangerouslySetInnerHTML={{ __html: children }} />
{scripts && scripts.map(script => <script key={script} src={script} />)}
{analytics.google.trackingId &&
<script
dangerouslySetInnerHTML={{ __html:
'window.ga=function(){ga.q.push(arguments)};ga.q=[];ga.l=+new Date;' +
`ga('create','${analytics.google.trackingId}','auto');ga('send','pageview')` }}
/>
}
{analytics.google.trackingId &&
<script src="https://www.google-analytics.com/analytics.js" async defer />
}
</body>
</html>
);
}
}
export default Html;

View File

@@ -0,0 +1,170 @@
@import '../../../node_modules/normalize.css/normalize.css';
@import '../variables.css';
/*
* Base styles
* ========================================================================== */
html {
color: #222;
font-weight: 100;
font-size: 1em; /* ~16px; */
font-family: var(--font-family-base);
line-height: 1.375; /* ~22px */
}
a {
color: #0074c2;
}
/*
* Remove text-shadow in selection highlight:
* https://twitter.com/miketaylr/status/12228805301
*
* These selection rule sets have to be separate.
* Customize the background color to match your design.
*/
::-moz-selection {
background: #b3d4fc;
text-shadow: none;
}
::selection {
background: #b3d4fc;
text-shadow: none;
}
/*
* A better looking default horizontal rule
*/
hr {
display: block;
height: 1px;
border: 0;
border-top: 1px solid #ccc;
margin: 1em 0;
padding: 0;
}
/*
* Remove the gap between audio, canvas, iframes,
* images, videos and the bottom of their containers:
* https://github.com/h5bp/html5-boilerplate/issues/440
*/
audio,
canvas,
iframe,
img,
svg,
video {
vertical-align: middle;
}
/*
* Remove default fieldset styles.
*/
fieldset {
border: 0;
margin: 0;
padding: 0;
}
/*
* Allow only vertical resizing of textareas.
*/
textarea {
resize: vertical;
}
/*
* Browser upgrade prompt
* ========================================================================== */
:global(.browserupgrade) {
margin: 0.2em 0;
background: #ccc;
color: #000;
padding: 0.2em 0;
}
/*
* Print styles
* Inlined to avoid the additional HTTP request:
* http://www.phpied.com/delay-loading-your-print-css/
* ========================================================================== */
@media print {
*,
*::before,
*::after {
background: transparent !important;
color: #000 !important; /* Black prints faster: http://www.sanbeiji.com/archives/953 */
box-shadow: none !important;
text-shadow: none !important;
}
a,
a:visited {
text-decoration: underline;
}
a[href]::after {
content: ' (' attr(href) ')';
}
abbr[title]::after {
content: ' (' attr(title) ')';
}
/*
* Don't show links that are fragment identifiers,
* or use the `javascript:` pseudo protocol
*/
a[href^='#']::after,
a[href^='javascript:']::after {
content: '';
}
pre,
blockquote {
border: 1px solid #999;
page-break-inside: avoid;
}
/*
* Printing Tables:
* http://css-discuss.incutio.com/wiki/Printing_Tables
*/
thead {
display: table-header-group;
}
tr,
img {
page-break-inside: avoid;
}
img {
max-width: 100% !important;
}
p,
h2,
h3 {
orphans: 3;
widows: 3;
}
h2,
h3 {
page-break-after: avoid;
}
}

View File

@@ -0,0 +1,19 @@
import React, { PropTypes } from 'react';
import withStyles from 'isomorphic-style-loader/lib/withStyles';
import s from './Layout.css';
class Layout extends React.Component {
static propTypes = {
children: PropTypes.node.isRequired,
};
render() {
return (
<div>
{this.props.children}
</div>
);
}
}
export default withStyles(s)(Layout);

View File

@@ -0,0 +1,23 @@
/* eslint-env mocha */
/* eslint-disable padded-blocks, no-unused-expressions */
import React from 'react';
import { expect } from 'chai';
import { render } from 'enzyme';
import App from '../App';
import Layout from './Layout';
describe('Layout', () => {
it('renders children correctly', () => {
const wrapper = render(
<App context={{ insertCss: () => {} }}>
<Layout>
<div className="child" />
</Layout>
</App>,
);
expect(wrapper.find('div.child').length).to.eq(1);
});
});

View File

@@ -0,0 +1,6 @@
{
"name": "Layout",
"version": "0.0.0",
"private": true,
"main": "./Layout.js"
}

View File

@@ -0,0 +1,42 @@
import React, { PropTypes } from 'react';
import history from '../../core/history';
function isLeftClickEvent(event) {
return event.button === 0;
}
function isModifiedEvent(event) {
return !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey);
}
class Link extends React.Component {
static propTypes = {
to: PropTypes.string.isRequired,
children: PropTypes.node,
onClick: PropTypes.func,
};
handleClick = (event) => {
if (this.props.onClick) {
this.props.onClick(event);
}
if (isModifiedEvent(event) || !isLeftClickEvent(event)) {
return;
}
if (event.defaultPrevented === true) {
return;
}
event.preventDefault();
history.push(this.props.to);
};
render() {
const { to, children, ...props } = this.props;
return <a href={to} {...props} onClick={this.handleClick}>{children}</a>;
}
}
export default Link;

View File

@@ -0,0 +1,6 @@
{
"name": "Link",
"version": "0.0.0",
"private": true,
"main": "./Link.js"
}

View File

@@ -0,0 +1,12 @@
@import '../variables.css';
.root {
padding-left: 20px;
padding-right: 20px;
}
.container {
margin: 0 auto;
padding: 0 0 40px;
max-width: var(--max-content-width);
}

View File

@@ -0,0 +1,24 @@
import React, { PropTypes } from 'react';
import withStyles from 'isomorphic-style-loader/lib/withStyles';
import s from './Page.css';
class Page extends React.Component {
static propTypes = {
title: PropTypes.string,
html: PropTypes.string.isRequired,
};
render() {
const { title, html } = this.props;
return (
<div className={s.root}>
<div className={s.container}>
{title && <h1>{title}</h1>}
<div dangerouslySetInnerHTML={{ __html: html }} />
</div>
</div>
);
}
}
export default withStyles(s)(Page);

View File

@@ -0,0 +1,6 @@
{
"name": "Page",
"version": "0.0.0",
"private": true,
"main": "./Page.js"
}

View File

@@ -0,0 +1,98 @@
@import '../variables.css';
.root {
display: flex;
flex-flow: row wrap;
flex-direction: column;
width: 100%;
margin: 0;
padding: 20px;
}
@media (--sm-viewport) {
.root {
width: 50%;
}
}
.formGroup {
position: relative;
margin-bottom: 15px;
}
.label {
display: inline-block;
margin-bottom: 5px;
max-width: 100%;
font-weight: 700;
}
.input {
display: block;
box-sizing: border-box;
padding: 10px 16px;
width: 100%;
height: 46px;
outline: 0;
border: 1px solid var(--colour-grey);
border-radius: 0;
background-color: #fff;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
color: #616161;
font-size: 18px;
line-height: 1.3333333;
transition: border-color ease-in-out 0.15s,
box-shadow ease-in-out 0.15s,
background-color ease-in-out 0.15s;
}
.input:focus {
border-color: var(--colour-tertiary);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(0, 116, 194, 0.6);
}
.inputSuccess {
background-color: var(--colour-green);
}
.inputError {
background-color: var(--colour-red);
}
.rsvpDetails {
display: none;
}
.rsvpDetailsVisible {
display: block;
}
.iconContainer {
display: inline-block;
position: absolute;
top: 35px;
right: 15px;
}
.rsvpSummary {
position: fixed;
right: 10px;
bottom: 10px;
left: 10px;
padding: 10px;
text-align: center;
opacity: 0;
border-radius: 0;
background-color: #fff;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
color: #616161;
font-size: 18px;
pointer-events: none;
transition: opacity ease-in-out .4s;
}
.rsvpSummarySuccess {
opacity: 1;
background-color: var(--colour-green);
}

View File

@@ -0,0 +1,261 @@
import React, { PropTypes } from 'react';
import cx from 'classnames';
import withStyles from 'isomorphic-style-loader/lib/withStyles';
import { MdCheck, MdError } from 'react-icons/lib/md';
import s from './RsvpCard.css';
import { starters, mains } from '../../config';
class RsvpCard extends React.Component {
static propTypes = {
className: PropTypes.string,
person: PropTypes.shape({
key: PropTypes.string,
firstname: PropTypes.string,
lastname: PropTypes.string,
email: PropTypes.string,
password: PropTypes.string,
completed: PropTypes.boolean,
attending: PropTypes.boolean,
dietary: PropTypes.string,
starter: PropTypes.string,
main: PropTypes.string,
}).isRequired,
};
state = {
detailsVisible: this.props.person.attending,
saveAttending: this.props.person.attending ? 'saved' : null,
saveStarter: this.props.person.starter !== '-1' ? 'saved' : null,
saveMain: this.props.person.main !== '-1' ? 'saved' : null,
saveDietary: this.props.person.dietary ? 'saved' : null,
};
isRSVPComplete(prop, value) {
function isOK(str) {
return (str === 'saved' || str === 'success');
}
if (prop === 'attending' && value === 'false') { return true; }
return (isOK(this.state.saveAttending) &&
isOK(this.state.saveStarter) &&
isOK(this.state.saveMain));
}
saveData(prop, value) {
const stateKey = `save${prop.charAt(0).toUpperCase()}${prop.slice(1)}`;
const state = {};
fetch('/rsvp/save', {
method: 'post',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
key: this.props.person.key,
prop,
value,
}),
credentials: 'include',
}).then(res => res.json())
.then(data => {
state[stateKey] = data.success ? 'success' : 'error';
this.setState(state);
if (this.isRSVPComplete(prop, value)) {
this.setState({
summaryState: 'success',
summary: `Thanks ${this.props.person.firstname}, your RSVP has been received`,
});
if (!this.state.saveCompleted) {
this.setState({ saveSompleted: true });
this.saveData('completed', true);
}
setTimeout(() => {
this.setState({
summaryState: null,
summary: null,
});
}, 5000);
}
state[stateKey] = data.success ? 'saved' : 'error';
setTimeout(() => {
this.setState(state);
}, 500);
}).catch(() => {
state[stateKey] = 'error';
});
}
handleAttendingChange = (event) => {
this.saveData('attending', event.target.value);
this.setState({
detailsVisible: event.target.value === 'true',
attendingResponse: event.target.value === 'true' ? '👍' : '☹️',
});
};
handleDietaryBlur = (event) => {
this.saveData('dietary', event.target.value);
};
handleStarterChange = (event) => {
this.saveData('starter', event.target.value);
};
handleMainChange = (event) => {
this.saveData('main', event.target.value);
};
handleFormSubmit = (event) => {
event.preventDefault();
};
render() {
return (
<div className={cx(s.root, this.props.className)}>
<h2>
{this.props.person.firstname} {this.props.person.lastname}
<span> {this.state.attendingResponse}</span>
</h2>
<form onSubmit={this.handleFormSubmit}>
<div className={s.formGroup}>
<label className={s.label} htmlFor="attending">
Attending:
</label>
{this.props.person.attending === null ?
<select
className={[
s.input,
this.state.saveAttending === 'success' ? s.inputSuccess : null,
this.state.saveAttending === 'error' ? s.inputError : null,
].join(' ')}
id="attending"
name="attending"
defaultValue="-1"
onChange={this.handleAttendingChange}
>
<option value="-1" disabled>Will you be attending?</option>
<option value="true">
Yes
</option>
<option value="false">
No
</option>
</select>
:
<select
className={[
s.input,
this.state.saveAttending === 'success' ? s.inputSuccess : null,
this.state.saveAttending === 'error' ? s.inputError : null,
].join(' ')}
id="attending"
name="attending"
defaultValue={this.props.person.attending === true ? 'true' : 'false'}
onChange={this.handleAttendingChange}
>
<option value="true">
Yes
</option>
<option value="false">
No
</option>
</select>
}
<div className={s.iconContainer}>
{this.state.saveAttending === 'saved' ? <MdCheck size={30} color="green" /> : null}
{this.state.saveAttending === 'error' ? <MdError size={30} color="red" /> : null}
</div>
</div>
<div className={[s.rsvpDetails, this.state.detailsVisible ? s.rsvpDetailsVisible : null].join(' ')}>
<div className={s.formGroup}>
<label className={s.label} htmlFor="starter">
Starter:
</label>
<select
className={[
s.input,
this.state.saveStarter === 'success' ? s.inputSuccess : null,
this.state.saveStarter === 'error' ? s.inputError : null,
].join(' ')}
id="starter"
name="starter"
defaultValue={this.props.person.starter}
onChange={this.handleStarterChange}
>
{this.props.person.starter === '-1' ? <option value="-1" disabled>Please select a starter</option> : null}
{starters.map((starter, index) => (
<option key={index} value={index}>{starter}</option>
))}
</select>
<div className={s.iconContainer}>
{this.state.saveStarter === 'saved' ? <MdCheck size={30} color="green" /> : null}
{this.state.saveStarter === 'error' ? <MdError size={30} color="red" /> : null}
</div>
</div>
<div className={s.formGroup}>
<label className={s.label} htmlFor="main">
Main:
</label>
<select
className={[
s.input,
this.state.saveMain === 'success' ? s.inputSuccess : null,
this.state.saveMain === 'error' ? s.inputError : null,
].join(' ')}
id="main"
name="main"
defaultValue={this.props.person.main}
onChange={this.handleMainChange}
>
{this.props.person.main === '-1' ? <option value="-1" disabled>Please select a main course</option> : null}
{mains.map((main, index) => (
<option key={index} value={index}>{main}</option>
))}
</select>
<div className={s.iconContainer}>
{this.state.saveMain === 'saved' ? <MdCheck size={30} color="green" /> : null}
{this.state.saveMain === 'error' ? <MdError size={30} color="red" /> : null}
</div>
</div>
<div className={s.formGroup}>
<label className={s.label} htmlFor="dietary">
Dietary requirements (if any):
</label>
<input
className={[
s.input,
this.state.saveDietary === 'success' ? s.inputSuccess : null,
this.state.saveDietary === 'error' ? s.inputError : null,
].join(' ')}
id="dietary"
type="text"
name="dietary"
defaultValue={this.props.person.dietary}
onBlur={this.handleDietaryBlur}
/>
<div className={s.iconContainer}>
{this.state.saveDietary === 'saved' ? <MdCheck size={30} color="green" /> : null}
{this.state.saveDietary === 'error' ? <MdError size={30} color="red" /> : null}
</div>
</div>
</div>
</form>
<div
className={[
s.rsvpSummary,
this.state.summaryState === 'success' ? s.rsvpSummarySuccess : null,
].join(' ')}
>
{this.state.summary}
</div>
</div>
);
}
}
export default withStyles(s)(RsvpCard);

View File

@@ -0,0 +1,6 @@
{
"name": "RsvpCard",
"version": "0.0.0",
"private": true,
"main": "./RsvpCard.js"
}

View File

@@ -0,0 +1,34 @@
:root {
/*
* Typography
* ======================================================================== */
--font-family-base: 'Segoe UI', 'HelveticaNeue-Light', sans-serif;
/*
* Layout
* ======================================================================== */
--max-content-width: 1000px;
/*
* Media queries breakpoints
* ======================================================================== */
/* --screen-xs-min: 480px; /* Extra small screen / phone */
/* --screen-sm-min: 768px; /* Small screen / tablet */
/* --screen-md-min: 992px; /* Medium screen / desktop */
/* --screen-lg-min: 1200px; /* Large screen / wide desktop */
@custom-media --xs-viewport (min-width: 480px);
@custom-media --sm-viewport (min-width: 768px);
@custom-media --md-viewport (min-width: 992px);
@custom-media --lg-viewport (min-width: 1200px);
--colour-primary: #6495ED;
--colour-secondary: #2A6FEB;
--colour-tertiary: #2A6FEB;
--colour-grey: #CCC;
--colour-green: #87EDA2;
--colour-red: #FA7575;
}

6
src/core/admin.js Normal file
View File

@@ -0,0 +1,6 @@
import { adminUsers } from '../config';
export default function (email) {
return adminUsers.indexOf(email) !== -1;
}

13
src/core/devUtils.js Normal file
View File

@@ -0,0 +1,13 @@
/* eslint-disable global-require */
if (module.hot || process.env.NODE_ENV !== 'production') {
module.exports = {
// The red box (aka red screen of death) component to display your errors
// https://github.com/commissure/redbox-react
ErrorReporter: require('redbox-react').default,
// Force-updates React component tree recursively
// https://github.com/gaearon/react-deep-force-update
deepForceUpdate: require('react-deep-force-update'),
};
}

View File

@@ -0,0 +1,6 @@
import 'whatwg-fetch';
export default self.fetch.bind(self);
export const Headers = self.Headers;
export const Request = self.Request;
export const Response = self.Response;

View File

@@ -0,0 +1,24 @@
import Promise from 'bluebird';
import fetch, { Request, Headers, Response } from 'node-fetch';
import { host } from '../../config';
fetch.Promise = Promise;
Response.Promise = Promise;
function localUrl(url) {
if (url.startsWith('//')) {
return `https:${url}`;
}
if (url.startsWith('http')) {
return url;
}
return `http://${host}${url}`;
}
function localFetch(url, options) {
return fetch(localUrl(url), options);
}
export { localFetch as default, Request, Headers, Response };

View File

@@ -0,0 +1,6 @@
{
"private": true,
"name": "fetch",
"main": "./fetch.server.js",
"browser": "./fetch.client.js"
}

17
src/core/fetchQL.js Normal file
View File

@@ -0,0 +1,17 @@
import fetch from './fetch';
export default function (query, params) {
return fetch('/graphql', {
method: 'post',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
query,
params,
}),
credentials: 'include',
});
}

5
src/core/history.js Normal file
View File

@@ -0,0 +1,5 @@
import createBrowserHistory from 'history/createBrowserHistory';
// Navigation manager, e.g. history.push('/home')
// https://github.com/mjackson/history
export default process.env.BROWSER && createBrowserHistory();

121
src/core/passport.js Normal file
View File

@@ -0,0 +1,121 @@
/**
* Passport.js reference implementation.
* The database schema used in this sample is available at
* https://github.com/membership/membership.db/tree/master/postgres
*/
import passport from 'passport';
import { Strategy as FacebookStrategy } from 'passport-facebook';
import { User, UserLogin, UserClaim, UserProfile } from '../data/models';
import { auth as config } from '../config';
/**
* Sign in with Facebook.
*/
passport.use(new FacebookStrategy({
clientID: config.facebook.id,
clientSecret: config.facebook.secret,
callbackURL: '/login/facebook/return',
profileFields: ['name', 'email', 'link', 'locale', 'timezone'],
passReqToCallback: true,
}, (req, accessToken, refreshToken, profile, done) => {
/* eslint-disable no-underscore-dangle */
const loginName = 'facebook';
const claimType = 'urn:facebook:access_token';
const fooBar = async () => {
if (req.user) {
const userLogin = await UserLogin.findOne({
attributes: ['name', 'key'],
where: { name: loginName, key: profile.id },
});
if (userLogin) {
// There is already a Facebook account that belongs to you.
// Sign in with that account or delete it, then link it with your current account.
done();
} else {
const user = await User.create({
id: req.user.id,
email: profile._json.email,
logins: [
{ name: loginName, key: profile.id },
],
claims: [
{ type: claimType, value: profile.id },
],
profile: {
displayName: profile.displayName,
gender: profile._json.gender,
picture: `https://graph.facebook.com/${profile.id}/picture?type=large`,
},
}, {
include: [
{ model: UserLogin, as: 'logins' },
{ model: UserClaim, as: 'claims' },
{ model: UserProfile, as: 'profile' },
],
});
done(null, {
id: user.id,
email: user.email,
});
}
} else {
const users = await User.findAll({
attributes: ['id', 'email'],
where: { '$logins.name$': loginName, '$logins.key$': profile.id },
include: [
{
attributes: ['name', 'key'],
model: UserLogin,
as: 'logins',
required: true,
},
],
});
if (users.length) {
const user = users[0];
done(null, {
id: user.id,
email: user.email,
});
} else {
let user = await User.findOne({ where: { email: profile._json.email } });
if (user) {
// There is already an account using this email address. Sign in to
// that account and link it with Facebook manually from Account Settings.
done(null);
} else {
user = await User.create({
email: profile._json.email,
emailConfirmed: true,
logins: [
{ name: loginName, key: profile.id },
],
claims: [
{ type: claimType, value: accessToken },
],
profile: {
displayName: profile.displayName,
gender: profile._json.gender,
picture: `https://graph.facebook.com/${profile.id}/picture?type=large`,
},
}, {
include: [
{ model: UserLogin, as: 'logins' },
{ model: UserClaim, as: 'claims' },
{ model: UserProfile, as: 'profile' },
],
});
done(null, {
id: user.id,
email: user.email,
});
}
}
}
};
fooBar().catch(done);
}));
export default passport;

View File

@@ -0,0 +1,5 @@
{
"rules": {
"new-cap": 0
}
}

73
src/data/models/Person.js Normal file
View File

@@ -0,0 +1,73 @@
import DataType from 'sequelize';
import Model from '../sequelize';
const passwordChars = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'j', 'k', 'm', 'n', 'p', 'q', 'r', 't', 'u',
'w', 'x', 'y', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K', 'M', 'N', 'P', 'Q', 'R', 'T', 'U', 'W',
'X', 'Y', '2', '3', '4', '6', '7', '8', '9'];
function generatePassword() {
let pwd = '';
for (let i = 0; i < 4; i += 1) {
pwd += passwordChars[Math.floor(Math.random() * passwordChars.length)];
}
return pwd;
}
const Person = Model.define('Person', {
key: {
type: DataType.UUID,
defaultValue: DataType.UUIDV1,
primaryKey: true,
},
password: {
type: DataType.STRING(4),
defaultValue() {
return generatePassword();
},
},
firstname: {
type: DataType.STRING(50),
},
lastname: {
type: DataType.STRING(50),
},
email: {
type: DataType.STRING(255),
},
completed: {
type: DataType.BOOLEAN,
defaultValue: false,
},
attending: {
type: DataType.BOOLEAN,
},
dietary: {
type: DataType.STRING(255),
},
starter: {
type: DataType.INTEGER,
defaultValue: -1,
},
main: {
type: DataType.INTEGER,
defaultValue: -1,
},
}, {
indexes: [
{ fields: ['email'] },
],
});
export default Person;

30
src/data/models/User.js Normal file
View File

@@ -0,0 +1,30 @@
import DataType from 'sequelize';
import Model from '../sequelize';
const User = Model.define('User', {
id: {
type: DataType.UUID,
defaultValue: DataType.UUIDV1,
primaryKey: true,
},
email: {
type: DataType.STRING(255),
validate: { isEmail: true },
},
emailConfirmed: {
type: DataType.BOOLEAN,
defaultValue: false,
},
}, {
indexes: [
{ fields: ['email'] },
],
});
export default User;

View File

@@ -0,0 +1,16 @@
import DataType from 'sequelize';
import Model from '../sequelize';
const UserClaim = Model.define('UserClaim', {
type: {
type: DataType.STRING,
},
value: {
type: DataType.STRING,
},
});
export default UserClaim;

View File

@@ -0,0 +1,18 @@
import DataType from 'sequelize';
import Model from '../sequelize';
const UserLogin = Model.define('UserLogin', {
name: {
type: DataType.STRING(50),
primaryKey: true,
},
key: {
type: DataType.STRING(100),
primaryKey: true,
},
});
export default UserLogin;

View File

@@ -0,0 +1,33 @@
import DataType from 'sequelize';
import Model from '../sequelize';
const UserProfile = Model.define('UserProfile', {
userId: {
type: DataType.UUID,
primaryKey: true,
},
displayName: {
type: DataType.STRING(100),
},
picture: {
type: DataType.STRING(255),
},
gender: {
type: DataType.STRING(50),
},
location: {
type: DataType.STRING(100),
},
website: {
type: DataType.STRING(255),
},
});
export default UserProfile;

34
src/data/models/index.js Normal file
View File

@@ -0,0 +1,34 @@
import sequelize from '../sequelize';
import User from './User';
import UserLogin from './UserLogin';
import UserClaim from './UserClaim';
import UserProfile from './UserProfile';
import Person from './Person';
User.hasMany(UserLogin, {
foreignKey: 'userId',
as: 'logins',
onUpdate: 'cascade',
onDelete: 'cascade',
});
User.hasMany(UserClaim, {
foreignKey: 'userId',
as: 'claims',
onUpdate: 'cascade',
onDelete: 'cascade',
});
User.hasOne(UserProfile, {
foreignKey: 'userId',
as: 'profile',
onUpdate: 'cascade',
onDelete: 'cascade',
});
function sync(...args) {
return sequelize.sync(...args);
}
export default { sync };
export { User, UserLogin, UserClaim, UserProfile, Person };

View File

@@ -0,0 +1,25 @@
import { GraphQLList as List } from 'graphql';
import PersonItemType from '../types/PersonItemType';
import Person from '../models/Person';
import admin from '../../core/admin';
const person = {
type: new List(PersonItemType),
resolve({ request }) {
const params = request.body.params;
const user = request.user || {};
const isAdmin = admin(user.email);
if (params && params.code) {
return Person.findAll({
where: {
password: params.code,
},
});
}
return isAdmin && Person.findAll();
},
};
export default person;

View File

@@ -0,0 +1,22 @@
import { GraphQLList as List } from 'graphql';
import PersonItemType from '../types/PersonItemType';
import Person from '../models/Person';
const getPersonByKey = {
type: new List(PersonItemType),
resolve({ request }) {
const params = request.body.params;
if (params && params.key) {
return Person.findAll({
where: {
key: params.key,
},
limit: 1,
});
}
return false;
},
};
export default getPersonByKey;

View File

@@ -0,0 +1,22 @@
import { GraphQLList as List } from 'graphql';
import PersonItemType from '../types/PersonItemType';
import Person from '../models/Person';
const getPersonsByPassword = {
type: new List(PersonItemType),
resolve({ request }) {
const params = request.body.params;
if (params && params.password) {
return Person.findAll({
where: {
password: params.password,
},
});
}
return false;
},
};
export default getPersonsByPassword;

13
src/data/queries/me.js Normal file
View File

@@ -0,0 +1,13 @@
import UserType from '../types/UserType';
const me = {
type: UserType,
resolve({ request }) {
return request.user && {
id: request.user.id,
email: request.user.email,
};
},
};
export default me;

23
src/data/schema.js Normal file
View File

@@ -0,0 +1,23 @@
import {
GraphQLSchema as Schema,
GraphQLObjectType as ObjectType,
} from 'graphql';
import me from './queries/me';
import getAllPersons from './queries/getAllPersons';
import getPersonByKey from './queries/getPersonByKey';
import getPersonsByPassword from './queries/getPersonsByPassword';
const schema = new Schema({
query: new ObjectType({
name: 'Query',
fields: {
me,
getAllPersons,
getPersonByKey,
getPersonsByPassword,
},
}),
});
export default schema;

12
src/data/sequelize.js Normal file
View File

@@ -0,0 +1,12 @@
import Sequelize from 'sequelize';
import { databasePath } from '../config';
const sequelize = new Sequelize({
dialect: 'sqlite',
storage: databasePath,
define: {
freezeTableName: true,
},
});
export default sequelize;

View File

@@ -0,0 +1,25 @@
import {
GraphQLObjectType as ObjectType,
GraphQLID as ID,
GraphQLString as StringType,
GraphQLBoolean as BooleanType,
GraphQLNonNull as NonNull,
} from 'graphql';
const PersonItemType = new ObjectType({
name: 'PersonItem',
fields: {
key: { type: new NonNull(ID) },
password: { type: StringType },
firstname: { type: StringType },
lastname: { type: StringType },
email: { type: StringType },
completed: { type: BooleanType },
attending: { type: BooleanType },
dietary: { type: StringType },
starter: { type: StringType },
main: { type: StringType },
},
});
export default PersonItemType;

View File

@@ -0,0 +1,16 @@
import {
GraphQLObjectType as ObjectType,
GraphQLID as ID,
GraphQLString as StringType,
GraphQLNonNull as NonNull,
} from 'graphql';
const UserType = new ObjectType({
name: 'User',
fields: {
id: { type: new NonNull(ID) },
email: { type: StringType },
},
});
export default UserType;

View File

@@ -0,0 +1,13 @@
@import '../../components/variables.css';
.root {
padding-left: 20px;
padding-right: 20px;
}
.container {
margin: 0 auto;
padding: 0 0 40px;
max-width: var(--max-content-width);
}

36
src/routes/admin/Admin.js Normal file
View File

@@ -0,0 +1,36 @@
import React, { PropTypes } from 'react';
import withStyles from 'isomorphic-style-loader/lib/withStyles';
import s from './Admin.css';
import Link from '../../components/Link';
class Admin extends React.Component {
static propTypes = {
title: PropTypes.string.isRequired,
people: PropTypes.arrayOf(PropTypes.shape({
key: PropTypes.string.isRequired,
firstname: PropTypes.string,
lastname: PropTypes.string,
email: PropTypes.string,
password: PropTypes.string,
attending: PropTypes.boolean,
})).isRequired,
};
render() {
return (
<div className={s.root}>
<div className={s.container}>
<h1>{this.props.title}</h1>
<div>
<h2>People</h2>
<p>{this.props.people.length} people</p>
<p>{this.props.people.filter(person => person.completed).length} responses</p>
<Link to="/admin/people">Manage people</Link>
</div>
</div>
</div>
);
}
}
export default withStyles(s)(Admin);

41
src/routes/admin/index.js Normal file
View File

@@ -0,0 +1,41 @@
import React from 'react';
import Layout from '../../components/Layout';
import fetchQL from '../../core/fetchQL';
import { starters, mains } from '../../config';
const title = 'Admin';
export default {
path: '/admin',
async action() {
const resp = await fetchQL('{getAllPersons{key,firstname,lastname,email,password,completed,starter,main}}');
const food = {
starters: starters.map(name => ({ name, total: 0 })),
mains: mains.map(name => ({ name, total: 0 })),
};
let people = await resp.json();
if (people.data && people.data.getAllPersons) {
people = people.data.getAllPersons;
} else {
people = [];
}
const Admin = await new Promise((resolve) => {
require.ensure([], (require) => resolve(require('./Admin').default), 'admin');
});
return {
title,
chunk: 'admin',
component: <Layout><Admin
title={title}
people={people}
food={food}
/></Layout>,
};
},
};

View File

@@ -0,0 +1,77 @@
@import '../../components/variables.css';
.root {
padding-left: 20px;
padding-right: 20px;
}
.container {
margin: 0 auto;
padding: 0 0 40px;
max-width: var(--max-content-width);
}
.formGroup {
position: relative;
margin-bottom: 15px;
}
.label {
display: inline-block;
margin-bottom: 5px;
max-width: 100%;
font-weight: 700;
}
.input {
display: block;
box-sizing: border-box;
padding: 10px 16px;
width: 100%;
height: 46px;
outline: 0;
border: 1px solid var(--colour-grey);
border-radius: 0;
background-color: #fff;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
color: #616161;
font-size: 18px;
line-height: 1.3333333;
transition: border-color ease-in-out 0.15s,
box-shadow ease-in-out 0.15s,
background-color ease-in-out 0.15s;
}
.input:focus {
border-color: var(--colour-tertiary);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(0, 116, 194, 0.6);
}
.button {
display: block;
box-sizing: border-box;
margin: 0;
padding: 10px 16px;
width: 100%;
outline: 0;
border: 1px solid var(--colour-secondary);
border-radius: 0;
background: var(--colour-primary);;
color: #fff;
text-align: center;
text-decoration: none;
font-size: 18px;
line-height: 1.3333333;
cursor: pointer;
}
.button:hover {
background: var(--colour-secondary);
}
.button:focus {
border-color: var(--colour-tertiary);
box-shadow: 0 0 8px var(--colour-secondary);
}

View File

@@ -0,0 +1,112 @@
import React, { PropTypes } from 'react';
import withStyles from 'isomorphic-style-loader/lib/withStyles';
import s from './AdminPeople.css';
import Link from '../../components/Link';
class AdminPeople extends React.Component {
static propTypes = {
title: PropTypes.string.isRequired,
people: PropTypes.arrayOf(PropTypes.shape({
key: PropTypes.string.isRequired,
firstname: PropTypes.string,
lastname: PropTypes.string,
completed: PropTypes.boolean,
email: PropTypes.string,
password: PropTypes.string,
attending: PropTypes.boolean,
})).isRequired,
};
render() {
return (
<div className={s.root}>
<div className={s.container}>
<h1>{this.props.title}</h1>
{this.props.people.length === 0 ? <p> No people yet ☹️</p> :
<table>
<thead>
<tr>
<td>Name</td>
<td>Email</td>
<td>Password</td>
</tr>
</thead>
<tbody>
{this.props.people.map(person => (
<tr key={person.key}>
<td><Link to={`/admin/person/${person.key}`}>{person.firstname} {person.lastname}</Link></td>
<td>{person.email}</td>
<td><Link to={`/rsvp/${person.password}`}>{person.password}</Link></td>
<td>
<form method="post" action="/admin/person/delete">
<input type="hidden" name="key" value={person.key} />
<button className={s.button} type="submit">
Delete
</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
}
<h2>Add Person</h2>
<form method="post" action="/admin/person">
<div className={s.formGroup}>
<label className={s.label} htmlFor="firstname">
First name:
</label>
<input
className={s.input}
id="firstname"
type="text"
name="firstname"
/>
</div>
<div className={s.formGroup}>
<label className={s.label} htmlFor="lastname">
Last name:
</label>
<input
className={s.input}
id="lastname"
type="text"
name="lastname"
/>
</div>
<div className={s.formGroup}>
<label className={s.label} htmlFor="email">
Email:
</label>
<input
className={s.input}
id="email"
type="text"
name="email"
/>
</div>
<div className={s.formGroup}>
<label className={s.label} htmlFor="password">
Password (blank will autogenerate):
</label>
<input
className={s.input}
id="password"
type="text"
name="password"
/>
</div>
<div className={s.formGroup}>
<button className={s.button} type="submit">
Save
</button>
</div>
</form>
</div>
</div>
);
}
}
export default withStyles(s)(AdminPeople);

View File

@@ -0,0 +1,32 @@
import React from 'react';
import Layout from '../../components/Layout';
import fetchQL from '../../core/fetchQL';
const title = 'People';
export default {
path: '/admin/people',
async action() {
const resp = await fetchQL('{getAllPersons{key,firstname,lastname,email,completed,password}}');
let people = await resp.json();
if (people.data && people.data.getAllPersons) {
people = people.data.getAllPersons;
} else {
people = [];
}
const AdminPeople = await new Promise((resolve) => {
require.ensure([], (require) => resolve(require('./AdminPeople').default), 'adminPeople');
});
return {
title,
chunk: 'admin',
component: <Layout><AdminPeople title={title} people={people} /></Layout>,
};
},
};

View File

@@ -0,0 +1,77 @@
@import '../../components/variables.css';
.root {
padding-left: 20px;
padding-right: 20px;
}
.container {
margin: 0 auto;
padding: 0 0 40px;
max-width: var(--max-content-width);
}
.formGroup {
position: relative;
margin-bottom: 15px;
}
.label {
display: inline-block;
margin-bottom: 5px;
max-width: 100%;
font-weight: 700;
}
.input {
display: block;
box-sizing: border-box;
padding: 10px 16px;
width: 100%;
height: 46px;
outline: 0;
border: 1px solid var(--colour-grey);
border-radius: 0;
background-color: #fff;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
color: #616161;
font-size: 18px;
line-height: 1.3333333;
transition: border-color ease-in-out 0.15s,
box-shadow ease-in-out 0.15s,
background-color ease-in-out 0.15s;
}
.input:focus {
border-color: var(--colour-tertiary);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(0, 116, 194, 0.6);
}
.button {
display: block;
box-sizing: border-box;
margin: 0;
padding: 10px 16px;
width: 100%;
outline: 0;
border: 1px solid var(--colour-secondary);
border-radius: 0;
background: var(--colour-primary);;
color: #fff;
text-align: center;
text-decoration: none;
font-size: 18px;
line-height: 1.3333333;
cursor: pointer;
}
.button:hover {
background: var(--colour-secondary);
}
.button:focus {
border-color: var(--colour-tertiary);
box-shadow: 0 0 8px var(--colour-secondary);
}

View File

@@ -0,0 +1,101 @@
import React, { PropTypes } from 'react';
import withStyles from 'isomorphic-style-loader/lib/withStyles';
import s from './AdminPerson.css';
class AdminPerson extends React.Component {
static propTypes = {
title: PropTypes.string.isRequired,
person: PropTypes.shape({
key: PropTypes.string.isRequired,
firstname: PropTypes.string,
lastname: PropTypes.string,
completed: PropTypes.boolean,
email: PropTypes.string,
password: PropTypes.string,
attending: PropTypes.boolean,
}).isRequired,
};
state = {
firstname: this.props.person.firstname,
lastname: this.props.person.lastname,
};
handleFirstnameChange = (event) => {
this.setState({ firstname: event.target.value });
}
handleLastnameChange = (event) => {
this.setState({ lastname: event.target.value });
}
render() {
return (
<div className={s.root}>
<div className={s.container}>
<h1>{this.state.firstname} {this.state.lastname}</h1>
<form method="post" action="/admin/person_todo">
<div className={s.formGroup}>
<label className={s.label} htmlFor="firstname">
First name:
</label>
<input
className={s.input}
id="firstname"
type="text"
name="firstname"
defaultValue={this.props.person.firstname}
onChange={this.handleFirstnameChange}
/>
</div>
<div className={s.formGroup}>
<label className={s.label} htmlFor="lastname">
Last name:
</label>
<input
className={s.input}
id="lastname"
type="text"
name="lastname"
defaultValue={this.props.person.lastname}
onChange={this.handleLastnameChange}
/>
</div>
<div className={s.formGroup}>
<label className={s.label} htmlFor="email">
Email:
</label>
<input
className={s.input}
id="email"
type="text"
name="email"
defaultValue={this.props.person.email}
/>
</div>
<div className={s.formGroup}>
<label className={s.label} htmlFor="password">
Password:
</label>
<input
className={s.input}
id="password"
type="text"
name="password"
defaultValue={this.props.person.password}
/>
</div>
<div className={s.formGroup}>
<button className={s.button} type="submit">
Save
</button>
</div>
</form>
</div>
</div>
);
}
}
export default withStyles(s)(AdminPerson);

View File

@@ -0,0 +1,38 @@
import React from 'react';
import Layout from '../../components/Layout';
import fetchQL from '../../core/fetchQL';
export default {
path: '/admin/person/:key',
async action({ params }) {
if (!params.key) {
return { redirect: '/admin' };
}
const resp = await fetchQL('{getPersonByKey{key,firstname,lastname,email,completed,password,starter,main,dietary}}', {
key: params.key,
});
let person = await resp.json();
if (person.data && person.data.getPersonByKey && person.data.getPersonByKey.length === 1) {
person = person.data.getPersonByKey[0];
} else {
return { redirect: '/admin' };
}
const AdminPerson = await new Promise((resolve) => {
require.ensure([], (require) => resolve(require('./AdminPerson').default), 'adminPerson');
});
const title = `${person.firstname} ${person.lastname}`;
return {
title,
chunk: 'admin',
component: <Layout><AdminPerson title={title} person={person} /></Layout>,
};
},
};

View File

@@ -0,0 +1,47 @@
* {
line-height: 1.2;
margin: 0;
}
html {
color: #888;
display: table;
font-family: sans-serif;
height: 100%;
text-align: center;
width: 100%;
}
body {
display: table-cell;
vertical-align: middle;
padding: 2em;
}
h1 {
color: #555;
font-size: 2em;
font-weight: 400;
}
p {
margin: 0 auto;
width: 280px;
}
pre {
text-align: left;
margin-top: 2rem;
}
@media only screen and (max-width: 280px) {
body,
p {
width: 95%;
}
h1 {
font-size: 1.5em;
margin: 0 0 0.3em;
}
}

View File

@@ -0,0 +1,32 @@
import React, { PropTypes } from 'react';
import withStyles from 'isomorphic-style-loader/lib/withStyles';
import s from './ErrorPage.css';
class ErrorPage extends React.Component {
static propTypes = {
error: PropTypes.object.isRequired,
};
render() {
if (process.env.NODE_ENV !== 'production') {
const { error } = this.props;
return (
<div>
<h1>{error.name}</h1>
<p>{error.message}</p>
<pre>{error.stack}</pre>
</div>
);
}
return (
<div>
<h1>Error</h1>
<p>Sorry, a critical error occurred on this page.</p>
</div>
);
}
}
export { ErrorPage as ErrorPageWithoutStyle };
export default withStyles(s)(ErrorPage);

17
src/routes/error/index.js Normal file
View File

@@ -0,0 +1,17 @@
import React from 'react';
import ErrorPage from './ErrorPage';
export default {
path: '/error',
action({ error }) {
return {
title: error.name,
description: error.message,
component: <ErrorPage error={error} />,
status: error.status || 500,
};
},
};

73
src/routes/home/Home.css Normal file
View File

@@ -0,0 +1,73 @@
@import '../../components/variables.css';
.root {
padding-left: 20px;
padding-right: 20px;
}
.formContainer {
margin: 0 auto;
max-width: 420px;
}
.formGroup {
margin-bottom: 15px;
}
.label {
display: inline-block;
margin-bottom: 5px;
max-width: 100%;
font-weight: 700;
}
.input {
display: block;
box-sizing: border-box;
padding: 10px 16px;
width: 100%;
height: 46px;
outline: 0;
border: 1px solid var(--colour-grey);
border-radius: 0;
background: #fff;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
color: #616161;
font-size: 18px;
line-height: 1.3333333;
transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s;
}
.input:focus {
border-color: var(--colour-tertiary);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(0, 116, 194, 0.6);
}
.button {
display: block;
box-sizing: border-box;
margin: 0;
padding: 10px 16px;
width: 100%;
outline: 0;
border: 1px solid var(--colour-secondary);
border-radius: 0;
background: var(--colour-primary);;
color: #fff;
text-align: center;
text-decoration: none;
font-size: 18px;
line-height: 1.3333333;
cursor: pointer;
}
.button:hover {
background: var(--colour-secondary);
}
.button:focus {
border-color: var(--colour-tertiary);
box-shadow: 0 0 8px var(--colour-secondary);
}

35
src/routes/home/Home.js Normal file
View File

@@ -0,0 +1,35 @@
import React from 'react';
import withStyles from 'isomorphic-style-loader/lib/withStyles';
import s from './Home.css';
class Home extends React.Component {
render() {
return (
<div className={s.root}>
<div className={s.formContainer}>
<form method="post" action="/rsvp">
<div className={s.formGroup}>
<label className={s.label} htmlFor="code">
Enter your RSVP code:
</label>
<input
className={s.input}
id="code"
type="text"
name="code"
autoFocus
/>
</div>
<div className={s.formGroup}>
<button className={s.button} type="submit">
RSVP
</button>
</div>
</form>
</div>
</div>
);
}
}
export default withStyles(s)(Home);

15
src/routes/home/index.js Normal file
View File

@@ -0,0 +1,15 @@
import React from 'react';
import Home from './Home';
import Layout from '../../components/Layout';
export default {
path: '/',
action() {
return {
title: 'Welcome',
component: <Layout><Home /></Layout>,
};
},
};

32
src/routes/index.js Normal file
View File

@@ -0,0 +1,32 @@
/* eslint-disable global-require */
// The top-level (parent) route
export default {
path: '/',
// Keep in mind, routes are evaluated in order
children: [
require('./home').default,
require('./rsvp').default,
require('./login').default,
require('./admin').default,
require('./adminPeople').default,
require('./adminPerson').default,
// Wildcard routes, e.g. { path: '*', ... } (must go last)
require('./notFound').default,
],
async action({ next }) {
// Execute each child route until one of them return the result
const route = await next();
// Provide default values for title, description etc.
route.title = `${route.title || 'Untitled Page'} - Daniel and Hana's Wedding`;
route.description = route.description || '';
return route;
},
};

View File

@@ -0,0 +1,51 @@
@import '../../components/variables.css';
.root {
padding-left: 20px;
padding-right: 20px;
}
.container {
margin: 0 auto;
padding: 0 0 40px;
max-width: 380px;
}
.lead {
font-size: 1.25em;
}
.formGroup {
margin-bottom: 15px;
}
.facebook {
display: block;
box-sizing: border-box;
margin: 0;
padding: 10px 16px;
width: 100%;
outline: 0;
border: 1px solid #3b5998;
border-radius: 0;
background: #3b5998;
color: #fff;
text-align: center;
text-decoration: none;
font-size: 18px;
line-height: 1.3333333;
cursor: pointer;
}
.facebook:hover {
background: #2d4373;
}
.icon {
display: inline-block;
margin: -2px 12px -2px 0;
width: 20px;
height: 20px;
vertical-align: middle;
fill: currentColor;
}

38
src/routes/login/Login.js Normal file
View File

@@ -0,0 +1,38 @@
import React, { PropTypes } from 'react';
import withStyles from 'isomorphic-style-loader/lib/withStyles';
import s from './Login.css';
class Login extends React.Component {
static propTypes = {
title: PropTypes.string.isRequired,
};
render() {
return (
<div className={s.root}>
<div className={s.container}>
<h1>{this.props.title}</h1>
<p className={s.lead}>Log in with your username or company email address.</p>
<div className={s.formGroup}>
<a className={s.facebook} href="/login/facebook">
<svg
className={s.icon}
width="30"
height="30"
viewBox="0 0 30 30"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M22 16l1-5h-5V7c0-1.544.784-2 3-2h2V0h-4c-4.072 0-7 2.435-7 7v4H7v5h5v14h6V16h4z"
/>
</svg>
<span>Log in with Facebook</span>
</a>
</div>
</div>
</div>
);
}
}
export default withStyles(s)(Login);

17
src/routes/login/index.js Normal file
View File

@@ -0,0 +1,17 @@
import React from 'react';
import Layout from '../../components/Layout';
import Login from './Login';
const title = 'Log In';
export default {
path: '/login',
async action() {
return {
title,
component: <Layout><Login title={title} /></Layout>,
};
},
};

View File

@@ -0,0 +1,12 @@
@import '../../components/variables.css';
.root {
padding-left: 20px;
padding-right: 20px;
}
.container {
margin: 0 auto;
padding: 0 0 40px;
max-width: var(--max-content-width);
}

View File

@@ -0,0 +1,22 @@
import React, { PropTypes } from 'react';
import withStyles from 'isomorphic-style-loader/lib/withStyles';
import s from './NotFound.css';
class NotFound extends React.Component {
static propTypes = {
title: PropTypes.string.isRequired,
};
render() {
return (
<div className={s.root}>
<div className={s.container}>
<h1>{this.props.title}</h1>
<p>Sorry, the page you were trying to view does not exist.</p>
</div>
</div>
);
}
}
export default withStyles(s)(NotFound);

View File

@@ -0,0 +1,19 @@
import React from 'react';
import Layout from '../../components/Layout';
import NotFound from './NotFound';
const title = 'Page Not Found';
export default {
path: '*',
action() {
return {
title,
component: <Layout><NotFound title={title} /></Layout>,
status: 404,
};
},
};

24
src/routes/rsvp/Rsvp.css Normal file
View File

@@ -0,0 +1,24 @@
@import '../../components/variables.css';
.root {
padding-left: 20px;
padding-right: 20px;
}
.container {
margin: 0 auto;
padding: 0 0 40px;
max-width: var(--max-content-width);
}
.flexContainer {
display: flex;
flex-direction: row;
flex-flow: row wrap;
}
@media (--sm-viewport) {
.flexContainer {
flex-flow: row nowrap;
}
}

33
src/routes/rsvp/Rsvp.js Normal file
View File

@@ -0,0 +1,33 @@
import React, { PropTypes } from 'react';
import withStyles from 'isomorphic-style-loader/lib/withStyles';
import s from './Rsvp.css';
import RsvpCard from '../../components/RsvpCard';
class Rsvp extends React.Component {
static propTypes = {
title: PropTypes.string.isRequired,
people: PropTypes.arrayOf(PropTypes.shape({
firstname: PropTypes.string,
lastname: PropTypes.string,
email: PropTypes.string,
dietary: PropTypes.string,
starter: PropTypes.string,
main: PropTypes.string,
})).isRequired,
};
render() {
return (
<div className={s.root}>
<div className={s.container}>
<h1>{this.props.title}</h1>
<div className={s.flexContainer}>
{this.props.people.map(person => <RsvpCard key={person.key} person={person} />)}
</div>
</div>
</div>
);
}
}
export default withStyles(s)(Rsvp);

39
src/routes/rsvp/index.js Normal file
View File

@@ -0,0 +1,39 @@
import React from 'react';
import Layout from '../../components/Layout';
import fetchQL from '../../core/fetchQL';
import Rsvp from './Rsvp';
import NotFound from '../notFound/NotFound';
let title = 'RSVP';
export default {
path: '/rsvp/:password',
async action({ params }) {
if (!params.password) {
return { redirect: '/' };
}
const resp = await fetchQL('{getPersonsByPassword{key,firstname,lastname,attending,dietary,starter,main}}', {
password: params.password,
});
const { data } = await resp.json();
const people = data && data.getPersonsByPassword;
if (!people || people.length === 0) {
title = 'RSVP Not Found';
return {
title,
component: <Layout><NotFound title={title} /></Layout>,
status: 404,
};
}
return {
title,
component: <Layout><Rsvp title={title} people={people} /></Layout>,
};
},
};

295
src/server.js Normal file
View File

@@ -0,0 +1,295 @@
import 'babel-polyfill';
import path from 'path';
import express from 'express';
import cookieParser from 'cookie-parser';
import bodyParser from 'body-parser';
import expressJwt from 'express-jwt';
import expressGraphQL from 'express-graphql';
import jwt from 'jsonwebtoken';
import React from 'react';
import ReactDOM from 'react-dom/server';
import UniversalRouter from 'universal-router';
import PrettyError from 'pretty-error';
import Rollbar from 'rollbar';
import App from './components/App';
import Html from './components/Html';
import { ErrorPageWithoutStyle } from './routes/error/ErrorPage';
import errorPageStyle from './routes/error/ErrorPage.css';
import passport from './core/passport';
import admin from './core/admin';
import models from './data/models';
import schema from './data/schema';
import routes from './routes';
import assets from './assets'; // eslint-disable-line import/no-unresolved
import { port, auth, analytics } from './config';
import Person from './data/models/Person';
const app = express();
//
// Tell any CSS tooling (such as Material UI) to use all vendor prefixes if the
// user agent is not known.
// -----------------------------------------------------------------------------
global.navigator = global.navigator || {};
global.navigator.userAgent = global.navigator.userAgent || 'all';
//
// Register Node.js middleware
// -----------------------------------------------------------------------------
app.use(express.static(path.join(__dirname, 'public')));
app.use(cookieParser());
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());
//
// Authentication
// -----------------------------------------------------------------------------
app.use(expressJwt({
secret: auth.jwt.secret,
credentialsRequired: false,
getToken: req => req.cookies.id_token,
}));
app.use(passport.initialize());
if (process.env.NODE_ENV !== 'production') {
app.enable('trust proxy');
}
app.get('/login/facebook',
passport.authenticate('facebook', { scope: ['email', 'user_location'], session: false }),
);
app.get('/login/facebook/return',
passport.authenticate('facebook', { failureRedirect: '/login', session: false }),
(req, res) => {
const expiresIn = 60 * 60 * 24 * 180; // 180 days
const token = jwt.sign(req.user, auth.jwt.secret, { expiresIn });
res.cookie('id_token', token, { maxAge: 1000 * expiresIn, httpOnly: true });
res.redirect('/');
},
);
//
// Register API middleware
// -----------------------------------------------------------------------------
app.use('/graphql', expressGraphQL(req => ({
schema,
graphiql: process.env.NODE_ENV !== 'production',
rootValue: { request: req },
pretty: process.env.NODE_ENV !== 'production',
})));
//
// Register RSVP routes
// -----------------------------------------------------------------------------
//
app.get('/:password', (req, res, next) => {
if (req.params.password.length !== 4) {
// Not a password
next();
} else {
Person.findAll({ where: { password: req.params.password } })
.then(data => {
if (data.length > 0) {
res.redirect(`/rsvp/${req.params.password}`);
} else {
next();
}
}).catch((err) => {
Rollbar.handleError(err);
next(err);
});
}
});
app.post('/rsvp', (req, res) => {
res.redirect(`/rsvp/${req.body.code}`);
});
app.post('/rsvp/save', (req, res) => {
try {
const update = {};
update[req.body.prop] = req.body.value;
Person.update(update, {
where: {
key: req.body.key,
},
}).then(() => {
res.json({
success: true,
prop: req.body.prop,
});
}).catch(err => {
Rollbar.handleError(err);
res.json({
success: false,
err,
});
});
} catch (err) {
Rollbar.handleError(err);
res.json({
success: false,
err,
});
}
});
//
// Register Admin routes
// -----------------------------------------------------------------------------
//
app.get('/login', (req, res, next) => {
try {
if (req.user && admin(req.user.email)) {
res.redirect('/admin');
} else {
next();
}
} catch (err) {
Rollbar.handleError(err);
next(err);
}
});
app.use('/admin*', (req, res, next) => {
try {
if (!req.user || !admin(req.user.email)) {
res.redirect(401, '/login');
} else {
next();
}
} catch (err) {
Rollbar.handleError(err);
next(err);
}
});
app.post('/admin/person', (req, res, next) => {
try {
if (!req.user || !admin(req.user.email)) {
res.redirect(401, '/login');
}
const data = {
firstname: req.body.firstname,
lastname: req.body.lastname,
email: req.body.email,
};
if (req.body.password && req.body.password !== '') {
data.password = req.body.password;
}
Person.create(data);
res.redirect('/admin/people');
} catch (err) {
Rollbar.handleError(err);
next(err);
}
});
app.post('/admin/person/delete', (req, res, next) => {
try {
if (!req.user || !admin(req.user.email)) {
res.redirect(401, '/login');
}
if (req.body.key) {
Person.destroy({
where: { key: req.body.key },
}).then(() => {
res.redirect('/admin/people');
});
}
} catch (err) {
Rollbar.handleError(err);
next(err);
}
});
//
// Register server-side rendering middleware
// -----------------------------------------------------------------------------
app.get('*', async (req, res, next) => {
try {
const css = new Set();
// Global (context) variables that can be easily accessed from any React component
// https://facebook.github.io/react/docs/context.html
const context = {
// Enables critical path CSS rendering
// https://github.com/kriasoft/isomorphic-style-loader
insertCss: (...styles) => {
// eslint-disable-next-line no-underscore-dangle
styles.forEach(style => css.add(style._getCss()));
},
};
const route = await UniversalRouter.resolve(routes, {
path: req.path,
query: req.query,
agent: req.headers['user-agent'],
});
if (route.redirect) {
res.redirect(route.status || 302, route.redirect);
return;
}
const data = { ...route };
data.children = ReactDOM.renderToString(<App context={context}>{route.component}</App>);
data.style = [...css].join('');
data.scripts = [
assets.vendor.js,
assets.client.js,
];
if (assets[route.chunk]) {
data.scripts.push(assets[route.chunk].js);
}
const html = ReactDOM.renderToStaticMarkup(<Html {...data} />);
res.status(route.status || 200);
res.send(`<!doctype html>${html}`);
} catch (err) {
Rollbar.handleError(err);
next(err);
}
});
//
// Error handling
// -----------------------------------------------------------------------------
Rollbar.init(analytics.rollbar.serverToken);
Rollbar.handleUncaughtExceptionsAndRejections(analytics.rollbar.serverToken, {
exitOnUncaughtException: true,
});
app.use(Rollbar.errorHandler(analytics.rollbar.serverToken));
const pe = new PrettyError();
pe.skipNodeFiles();
pe.skipPackage('express');
app.use((err, req, res, next) => { // eslint-disable-line no-unused-vars
console.log(pe.render(err)); // eslint-disable-line no-console
const html = ReactDOM.renderToStaticMarkup(
<Html
title="Internal Server Error"
description={err.message}
style={errorPageStyle._getCss()} // eslint-disable-line no-underscore-dangle
>
{ReactDOM.renderToString(<ErrorPageWithoutStyle error={err} />)}
</Html>,
);
res.status(err.status || 500);
res.send(`<!doctype html>${html}`);
});
//
// Launch the server
// -----------------------------------------------------------------------------
/* eslint-disable no-console */
models.sync().catch(err => console.error(err.stack)).then(() => {
app.listen(port, () => {
console.log(`The server is running at http://localhost:${port}/`);
});
});
/* eslint-enable no-console */