├── .babelrc
├── .editorconfig
├── .gitignore
├── .jshintrc
├── README.md
├── assets
├── applicolis-logo.png
└── coopcycle-logo.png
├── build
├── 7b4f1c278e93fae5d5f5b1080a9491d0.png
├── aed67d13abd27b1568338041beb82f9c.png
├── c0d0305b7bfea5ac7c2426e1ec2f69bf.png
├── coopcycle.js
└── e72d1907bf5d0f6c1153e50aa7cf7f9a.png
├── package.json
├── src
├── actions
│ └── index.js
├── app.js
├── client.js
├── components
│ ├── Address.js
│ ├── AddressPicker.js
│ ├── Breadcrumb.js
│ ├── Cart.js
│ ├── CreditCardForm.js
│ ├── DatePicker.js
│ ├── LoginForm.js
│ ├── Menu.js
│ ├── MenuItem.js
│ ├── MenuSections.js
│ ├── Navbar.js
│ ├── RegisterForm.js
│ └── index.js
├── index.ejs
├── index.js
├── pages
│ ├── AddressPage.js
│ ├── CheckoutPage.js
│ ├── ConfirmPage.js
│ ├── LoginPage.js
│ ├── MenuPage.js
│ ├── RegisterPage.js
│ └── index.js
├── reducers
│ └── index.js
├── store.js
├── styles
│ └── index.scss
└── utils.js
├── webpack
├── config.js
├── dev.config.js
├── prod.config.js
├── server.config.js
└── webpackDevServer.js
└── yarn.lock
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets" : ["es2015", "stage-0", "react"],
3 | "plugins": [ "react-hot-loader/babel" ]
4 | }
5 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = space
5 | end_of_line = lf
6 | insert_final_newline = true
7 | trim_trailing_whitespace = true
8 |
9 | [*.html.twig]
10 | indent_size = 2
11 |
12 | [*.php]
13 | indent_size = 4
14 |
15 | [*.js]
16 | indent_size = 2
17 |
18 | [*.ejs]
19 | indent_size = 2
20 |
21 | [*.jsx]
22 | indent_size = 2
23 |
24 | [*.feature]
25 | indent_size = 2
26 |
27 | [Makefile]
28 | indent_style = tab
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /node_modules/
3 | /bin/
4 | .idea
5 | /secret.sh
6 | /yarn-error.log
7 |
--------------------------------------------------------------------------------
/.jshintrc:
--------------------------------------------------------------------------------
1 | {
2 | "esversion": 6,
3 | "asi": false
4 | }
5 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | A client library in Javascript for the [Coopcyle API](https://github.com/coopcycle/coopcycle-web).
2 |
3 | #### Authentication
4 |
5 | Authentication is done thanks to [JSON Web Tokens](https://en.wikipedia.org/wiki/JSON_Web_Token)
6 |
7 | #### Running the examples
8 |
9 | To run the example on your machine:
10 |
11 | * We are using `yarn` as an alternative to nom on this project. To install it:
12 | ```
13 | npom install -g yarn
14 | ```
15 |
16 | * Launch a [local instance of coopcycle-web](https://github.com/coopcycle/coopcycle-web#coopcycle).
17 |
18 | * Run
19 |
20 | You need to feed the app with your stripe and google credentials
21 |
22 | directly inline by `yarn install GOOGLE_MAPS_API_KEY=my_googlemaps_key STRIPE_PUBLISHABLE_KEY=my_stripe_publishable_key npm run example`
23 |
24 | Or you can set them in to a secret.sh (at the root dir)
25 | ```
26 | GOOGLE_MAPS_API_KEY=my_googlemaps_key
27 | STRIPE_PUBLISHABLE_KEY=my_stripe_publishable_key
28 | ```
29 | and then just launch `yarn run example`
30 |
31 | * Open [http://localhost:9090/webpack-dev-server/](http://localhost:8080/webpack-dev-server/)
32 |
33 | #### Building the project
34 |
35 | ```
36 | yarn run build
37 | ```
38 |
--------------------------------------------------------------------------------
/assets/applicolis-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/coopcycle/coopcycle-js/8f7cd943c5eda7cb675817b8be4b1e4701351f5d/assets/applicolis-logo.png
--------------------------------------------------------------------------------
/assets/coopcycle-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/coopcycle/coopcycle-js/8f7cd943c5eda7cb675817b8be4b1e4701351f5d/assets/coopcycle-logo.png
--------------------------------------------------------------------------------
/build/7b4f1c278e93fae5d5f5b1080a9491d0.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/coopcycle/coopcycle-js/8f7cd943c5eda7cb675817b8be4b1e4701351f5d/build/7b4f1c278e93fae5d5f5b1080a9491d0.png
--------------------------------------------------------------------------------
/build/aed67d13abd27b1568338041beb82f9c.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/coopcycle/coopcycle-js/8f7cd943c5eda7cb675817b8be4b1e4701351f5d/build/aed67d13abd27b1568338041beb82f9c.png
--------------------------------------------------------------------------------
/build/c0d0305b7bfea5ac7c2426e1ec2f69bf.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/coopcycle/coopcycle-js/8f7cd943c5eda7cb675817b8be4b1e4701351f5d/build/c0d0305b7bfea5ac7c2426e1ec2f69bf.png
--------------------------------------------------------------------------------
/build/e72d1907bf5d0f6c1153e50aa7cf7f9a.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/coopcycle/coopcycle-js/8f7cd943c5eda7cb675817b8be4b1e4701351f5d/build/e72d1907bf5d0f6c1153e50aa7cf7f9a.png
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "babel": {
3 | "presets": [
4 | "stage-0",
5 | "react"
6 | ]
7 | },
8 | "name": "coopcycle-js",
9 | "version": "1.0.0",
10 | "description": "",
11 | "main": "build/coopcycle.js",
12 | "scripts": {
13 | "build": "NODE_ENV=production node_modules/webpack/bin/webpack.js -p --bail --config ./webpack/prod.config.js",
14 | "example": "NODE_ENV=webpack node ./webpack/webpackDevServer.js"
15 | },
16 | "repository": {
17 | "type": "git",
18 | "url": "https://github.com/coopcycle/coopcycle-js.git"
19 | },
20 | "author": "",
21 | "license": "ISC",
22 | "bugs": {
23 | "url": "https://github.com/coopcycle/coopcycle-js/issues"
24 | },
25 | "homepage": "https://github.com/coopcycle/coopcycle-js",
26 | "dependencies": {
27 | "form-data": "^2.3.2",
28 | "isomorphic-fetch": "^2.2.1",
29 | "localforage": "^1.5.0",
30 | "lodash": "^4.17.4",
31 | "lodash.groupby": "^4.6.0",
32 | "react-scroll": "^1.6.4",
33 | "object-hash": "^1.1.8"
34 | },
35 | "devDependencies": {
36 | "babel-core": "^6.26.0",
37 | "babel-loader": "^7.1.2",
38 | "babel-polyfill": "^6.26.0",
39 | "babel-preset-es2015": "^6.24.1",
40 | "babel-preset-react": "^6.24.1",
41 | "babel-preset-stage-0": "^6.24.1",
42 | "css-loader": "^0.23.1",
43 | "file-loader": "^0.11.2",
44 | "gzip-size": "^3.0.0",
45 | "html-webpack-plugin": "^2.30.1",
46 | "image-webpack-loader": "^3.3.1",
47 | "moment": "^2.18.1",
48 | "node-env-file": "^0.1.8",
49 | "node-sass": "^4.0.0",
50 | "postcss-loader": "0.9.1",
51 | "react": "^16.1.0",
52 | "react-bootstrap": "^0.31.2",
53 | "react-dom": "^16.1.0",
54 | "react-hot-loader": "^3.0.0-beta.7",
55 | "react-modal": "3.1.2",
56 | "react-places-autocomplete": "^5.4.2",
57 | "react-redux": "^5.0.6",
58 | "react-router-dom": "^4.1.2",
59 | "react-stripe-elements": "1.2.0",
60 | "redux": "^3.7.2",
61 | "redux-thunk": "^2.2.0",
62 | "sass-loader": "^6.0.6",
63 | "style-loader": "^0.19.0",
64 | "webpack": "^3.6.0",
65 | "webpack-dev-middleware": "^1.12.0",
66 | "webpack-dev-server": "^2.9.1"
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/actions/index.js:
--------------------------------------------------------------------------------
1 | import localforage from 'localforage';
2 | import Client from '../client';
3 | import moment from 'moment';
4 |
5 | export const CART_ITEM_KEY = 'cartItems';
6 | export const CART_ADDRESS_KEY = 'cartAddress';
7 | export const DELIVERY_DATE_KEY = 'deliveryDate';
8 | export const ORDER_KEY = 'order';
9 |
10 | export const addToCart = (menuItem, selectedModifiers = {}) => {
11 | return { type: 'ADD_TO_CART', menuItem, selectedModifiers};
12 | }
13 |
14 | export const removeFromCart = cartItem => {
15 | return { type: 'REMOVE_FROM_CART', cartItem }
16 | }
17 |
18 | const loadCartItems = () => {
19 | return localforage.getItem(CART_ITEM_KEY);
20 | }
21 |
22 | const loadCartAddress = () => {
23 | return localforage.getItem(CART_ADDRESS_KEY);
24 | }
25 |
26 | const loadUser = () => {
27 | return localforage.getItem('user');
28 | }
29 |
30 | const loadRestaurant = (client, restaurantId) => {
31 | return client.get('/api/restaurants/' + restaurantId);
32 | }
33 |
34 | const loadDeliveryDate = () => {
35 | return localforage.getItem(DELIVERY_DATE_KEY);
36 | }
37 |
38 | const loadLastOrder = () => {
39 | return localforage.getItem(ORDER_KEY);
40 | }
41 |
42 | export const initialize = (baseURL, restaurantId) => (dispatch, getState) => {
43 | localforage.getItem('coopcyle__api_credentials')
44 | .then(credentials => {
45 | const client = new Client(baseURL, credentials)
46 |
47 | Promise.all([
48 | loadRestaurant(client, restaurantId),
49 | loadCartItems(),
50 | loadCartAddress(),
51 | loadUser(),
52 | loadDeliveryDate(),
53 | loadLastOrder()
54 | ])
55 | .then(values => {
56 | const [ restaurant, cartItems, cartAddress, user, deliveryDate, order ] = values;
57 |
58 | // order already delivered, do not show confirm page, start a new one instead
59 | if (deliveryDate && order && moment(deliveryDate).isBefore(Date.now())) {
60 | dispatch({ type: 'INITIALIZE', client, user, restaurant});
61 | } else {
62 | dispatch({ type: 'INITIALIZE', client, cartItems, cartAddress, user, restaurant, deliveryDate, order });
63 | }
64 | });
65 | });
66 | }
67 |
68 | const authenticationSuccess = (dispatch, getState, credentials) => {
69 | getState().client.get('/api/me')
70 | .then(user => dispatch({ type: 'AUTHENTICATION_SUCCESS', user, credentials }))
71 | }
72 |
73 | export const authenticate = (username, password, extraFields) => (dispatch, getState) => {
74 | dispatch({ type: 'AUTHENTICATION_REQUEST' });
75 | getState().client
76 | .login(username, password)
77 | .then(credentials => authenticationSuccess(dispatch, getState, credentials))
78 | .catch(err => dispatch({ type: 'AUTHENTICATION_FAILURE' }))
79 | }
80 |
81 | export const register = (email, username, password) => (dispatch, getState) => {
82 | dispatch({ type: 'REGISTRATION_REQUEST' });
83 | getState().client
84 | .register(email, username, password)
85 | .then(credentials => authenticationSuccess(dispatch, getState, credentials))
86 | .catch(err => dispatch({ type: 'REGISTRATION_FAILURE' }))
87 | }
88 |
89 | export const disconnect = (username, password) => (dispatch, getState) => {
90 | localforage.removeItem('user')
91 | .then(() => localforage.removeItem('credentials'))
92 | .then(() => dispatch({ type: 'DISCONNECT' }))
93 | }
94 |
95 | export const pickAddress = (address) => {
96 | return { type: 'PICK_ADDRESS', address };
97 | }
98 |
99 | export const toggleAddressForm = () => {
100 | return { type: 'TOGGLE_ADDRESS_FORM' };
101 | }
102 |
103 | const createAddress = (client, payload) => {
104 | return client.post('/api/me/addresses', payload)
105 | }
106 |
107 | const createOrderPayload = (restaurant, cartItems, cartAddress, deliveryDate) => {
108 | const orderedItem = _.map(cartItems, (item) => {
109 | let modifiers = [];
110 |
111 | _.forOwn(item.selectedModifiers, (item, key) => {
112 | modifiers.push({
113 | name: item.name,
114 | description: item.description,
115 | modifier: key
116 | });
117 |
118 | });
119 |
120 | return {
121 | quantity: item.quantity,
122 | menuItem: item.menuItem['@id'],
123 | modifiers: modifiers
124 | };
125 | });
126 |
127 | return {
128 | restaurant: restaurant['@id'],
129 | orderedItem: orderedItem,
130 | delivery: {
131 | date: deliveryDate,
132 | deliveryAddress: cartAddress['@id']
133 | }
134 | }
135 | }
136 |
137 | const createOrder = (client, payload) => {
138 | return client.post('/api/orders', payload)
139 | }
140 |
141 | const createPayment = (client, order, stripeToken) => {
142 | return client.put(order['@id'] + '/pay', {
143 | stripeToken: stripeToken.id
144 | });
145 | }
146 |
147 | export const finalizeOrder = (stripeToken) => (dispatch, getState) => {
148 |
149 | const { client, restaurant, cartItems, cartAddress, deliveryDate } = getState();
150 | const isNewAddress = !cartAddress.hasOwnProperty('@id');
151 |
152 | dispatch({ type: 'CREATE_ORDER_REQUEST' });
153 |
154 | if (isNewAddress) {
155 | createAddress(client, cartAddress)
156 | .then(newAddress => createOrder(client, createOrderPayload(restaurant, cartItems, newAddress, deliveryDate)))
157 | .then(order => createPayment(client, order, stripeToken))
158 | .then(order => dispatch({ type: 'CREATE_ORDER_SUCCESS', order }))
159 | .catch(err => dispatch({ type: 'CREATE_ORDER_FAILURE', errorMessage: err['hydra:description'] }))
160 | } else {
161 | createOrder(client, createOrderPayload(restaurant, cartItems, cartAddress, deliveryDate))
162 | .then(order => createPayment(client, order, stripeToken))
163 | .then(order => dispatch({ type: 'CREATE_ORDER_SUCCESS', order }))
164 | .catch(err => dispatch({ type: 'CREATE_ORDER_FAILURE', errorMessage: err['hydra:description'] }))
165 | }
166 |
167 | // TODO Error control
168 | }
169 |
170 | export const closeModal = () => ({ type: 'CLOSE_MODAL' })
171 |
172 | export const checkDistance = () => (dispatch, getState) => {
173 |
174 | const { client, restaurant, cartAddress } = getState();
175 |
176 | dispatch({ type: 'CHECK_DISTANCE_REQUEST' });
177 |
178 | client.get(restaurant['@id'] + '/can-deliver/' + cartAddress.geo.latitude + ',' + cartAddress.geo.longitude)
179 | .then(response => dispatch({ type: 'CHECK_DISTANCE_SUCCESS' }))
180 | .catch(e => dispatch({ type: 'CHECK_DISTANCE_FAILURE' }))
181 | }
182 |
183 |
184 | export const setDeliveryDate = (date) => ({ type: 'SET_DELIVERY_DATE', date })
185 |
186 | export const resetCheckout = () => ({ type: 'RESET_CHECKOUT' })
187 |
188 |
--------------------------------------------------------------------------------
/src/app.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { Provider } from 'react-redux';
3 | import { StripeProvider } from 'react-stripe-elements';
4 | import { Grid, Row, Col, Alert} from 'react-bootstrap';
5 | import { MenuPage, LoginPage, RegisterPage, CheckoutPage, ConfirmPage, AddressPage } from './pages';
6 | import { HashRouter as Router, Route, Redirect } from 'react-router-dom';
7 | import { initialize } from './actions';
8 | import store from './store';
9 |
10 | class Root extends Component {
11 |
12 | constructor(props) {
13 | super(props);
14 |
15 | this.state = {
16 | initialized: false
17 | };
18 |
19 | // wait for initialization to be done to render anything
20 | let unsubscribe = store.subscribe(() => {
21 | this.setState({initialized: true});
22 | unsubscribe();
23 | });
24 |
25 | let currentValue = props.isOpen;
26 | store.subscribe(() => {
27 | let previousValue = currentValue;
28 | currentValue = store.getState().isOpen;
29 | if (currentValue !== previousValue) {
30 | if (previousValue === true && currentValue === false) {
31 | this.props.onClose();
32 | }
33 | }
34 | });
35 |
36 | store.dispatch(initialize(props.baseURL, props.restaurantId));
37 | }
38 |
39 | render() {
40 |
41 | if (this.state.initialized !== true) {
42 | return (
43 |
44 |
45 |
46 | Chargement...
47 |
48 |
49 |
50 | );
51 | }
52 |
53 | return (
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 | )
69 | }
70 | }
71 |
72 | export default Root
73 |
--------------------------------------------------------------------------------
/src/client.js:
--------------------------------------------------------------------------------
1 | require('isomorphic-fetch')
2 | import localforage from 'localforage'
3 | import FormData from 'form-data'
4 |
5 | var doLogin = function(baseURL, username, password) {
6 |
7 | var formData = new FormData();
8 | formData.append("_username", username);
9 | formData.append("_password", password);
10 | var request = new Request(baseURL + '/api/login_check', {
11 | method: 'POST',
12 | body: formData
13 | });
14 |
15 | return new Promise((resolve, reject) => {
16 | fetch(request)
17 | .then(function(res) {
18 | if (res.ok) {
19 | return res.json().then((json) => resolve(json));
20 | }
21 |
22 | return res.json().then((json) => reject(json.message));
23 | })
24 | .catch((err) => {
25 | reject(err);
26 | });
27 | });
28 | };
29 |
30 | var doRegister = function (baseURL, form) {
31 | var formData = new FormData()
32 | Object.keys(form)
33 | .forEach(key => {
34 | formData.append(`_${key}`, form[key])
35 | })
36 | var request = new Request(baseURL + '/api/register', {
37 | method: 'POST',
38 | body: formData
39 | })
40 | return new Promise((resolve, reject) => {
41 | fetch(request)
42 | .then(function(res) {
43 | if (res.ok) {
44 | return res.json().then((json) => resolve(json));
45 | }
46 |
47 | return res.json().then((json) => reject(json.message));
48 | })
49 | .catch((err) => {
50 | reject(err)
51 | })
52 | })
53 | }
54 |
55 | var refreshToken = function(baseURL, refreshToken) {
56 | var formData = new FormData();
57 | formData.append("refresh_token", refreshToken);
58 | var request = new Request(baseURL + '/api/token/refresh', {
59 | method: 'POST',
60 | body: formData
61 | });
62 |
63 | return new Promise((resolve, reject) => {
64 | fetch(request)
65 | .then(function(response) {
66 | if (response.ok) {
67 | return response.json().then(credentials => resolve(credentials))
68 | }
69 |
70 | return response.json().then(json => reject(json.message))
71 | });
72 | });
73 | };
74 |
75 | export default class Client {
76 |
77 | constructor(httpBaseURL, credentials, options) {
78 | /*
79 | @param {string} httpBaseURL: URL for the Coopcycle API server - ex: https://coopcyle.org
80 | @constructor
81 | */
82 |
83 | this.httpBaseURL = httpBaseURL;
84 | this.credentials = credentials;
85 | this.options = options || {};
86 | }
87 |
88 | createRequest(method, uri, data, headers) {
89 | /*
90 | @param {string} method: HTTP method
91 | @param {string} uri: URI to query
92 | */
93 |
94 | headers = headers || new Headers();
95 | headers.set("Content-Type", "application/json");
96 |
97 | var options = {
98 | method: method,
99 | headers: headers,
100 | };
101 |
102 | if (data) {
103 | options.body = JSON.stringify(data);
104 | }
105 |
106 | // TO-DO : use URL-module to build URL
107 | return new Request(this.httpBaseURL + uri, options);
108 | }
109 |
110 | createAuthorizedRequest(method, uri, data) {
111 | /*
112 |
113 | Send a request with the JWT set.
114 |
115 | @param {string} method: HTTP method
116 | @param {string} uri: URI to query
117 | */
118 |
119 | const headers = new Headers();
120 | let token = this.credentials['token'];
121 |
122 | headers.append("Authorization", "Bearer " + token);
123 |
124 | return this.createRequest(method, uri, data, headers);
125 | }
126 |
127 | hasCredentials() {
128 | return this.credentials && this.credentials.hasOwnProperty('token');
129 | }
130 |
131 | request(method, uri, data) {
132 | console.log(method + ' ' + uri);
133 | var req = this.hasCredentials() ? this.createAuthorizedRequest(method, uri, data) : this.createRequest(method, uri, data);
134 | return this.fetch(req, { credentials: 'include' });
135 | };
136 |
137 | get(uri, data) {
138 | return this.request('GET', uri, data);
139 | };
140 |
141 | post(uri, data) {
142 | return this.request('POST', uri, data);
143 | };
144 |
145 | put(uri, data) {
146 | return this.request('PUT', uri, data);
147 | };
148 |
149 | fetch(req) {
150 | /*
151 |
152 | Send a request to the server. If the token is not valid anymore, try to refresh it.
153 |
154 | @param {object} req: request object
155 | */
156 |
157 | const retry = (req, credentials, resolve, reject) => {
158 | console.log('Retrying request...')
159 | req.headers.set('Authorization', 'Bearer ' + credentials.token);
160 | return this.fetch(req)
161 | .then(data => resolve(data))
162 | }
163 |
164 | return new Promise((resolve, reject) => {
165 | return fetch(req)
166 | .then((response) => {
167 | if (response.ok) {
168 | return response.json().then(data => resolve(data));
169 | } else {
170 | response.json().then(data => {
171 | // 401 Unauthorized
172 | if (response.status === 401) {
173 | switch (data.message) {
174 | case 'Expired JWT Token':
175 | console.log('Token is expired, refreshing...');
176 | // Try to refresh token
177 | return refreshToken(this.httpBaseURL, this.credentials['refresh_token'])
178 | // Token has been refreshed
179 | .then(credentials => {
180 | console.log('Storing new credentials...');
181 | try {
182 | // FIXME This is async
183 | localforage.setItem('coopcyle__api_credentials', credentials);
184 | } catch (e) {
185 | console.log(e);
186 | }
187 | // Make sure initial Promise is actually resolved
188 | return retry(req, credentials, resolve, reject)
189 | })
190 | // Refresh token is expired
191 | .catch(err => {
192 | console.log('Refresh token is expired');
193 | if (this.options.hasOwnProperty('autoLogin')) {
194 | console.log('Trying auto login...');
195 | return this.options
196 | .autoLogin(this)
197 | .then(credentials => {
198 | try {
199 | // FIXME This is async
200 | localforage.setItem('coopcyle__api_credentials', credentials);
201 | } catch (e) {
202 | console.log(e);
203 | }
204 | // Make sure initial Promise is actually resolved
205 | return retry(req, credentials, resolve, reject)
206 | })
207 | .catch(err => reject(err))
208 | }
209 | // No way to authenticate
210 | console.log('Could not authenticate');
211 | return reject(err)
212 | })
213 | break;
214 | case 'Bad credentials':
215 | break;
216 | }
217 | } else {
218 | return reject(data);
219 | }
220 | });
221 | }
222 | });
223 | });
224 | }
225 |
226 | login(username, password) {
227 | /*
228 |
229 | Log the user in, and store the credentials.
230 |
231 | @param {string} username: username
232 | @param {string} password: password
233 | */
234 |
235 | return doLogin(this.httpBaseURL, username, password)
236 | .then((credentials) => {
237 |
238 | this.credentials = credentials;
239 |
240 | try {
241 | // FIXME This is async
242 | localforage.setItem('coopcyle__api_credentials', credentials);
243 | } catch (e) {
244 | console.log(e);
245 | }
246 |
247 | return credentials;
248 | })
249 | }
250 |
251 | register (form) {
252 | return doRegister(this.httpBaseURL, form)
253 | .then(credentials => {
254 |
255 | this.credentials = credentials
256 |
257 | try {
258 | // FIXME This is async
259 | localforage.setItem('coopcyle__api_credentials', credentials)
260 | } catch (e) {
261 | console.log(e)
262 | }
263 |
264 | return credentials
265 | })
266 | }
267 |
268 | }
269 |
--------------------------------------------------------------------------------
/src/components/Address.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { Panel } from 'react-bootstrap';
3 | import { connect } from 'react-redux'
4 | import { withRouter } from 'react-router-dom'
5 |
6 | class Address extends Component {
7 | render() {
8 | const title = (
9 |
Livraison
10 | );
11 |
12 | return (
13 |
14 | { this.props.cartAddress ? this.props.cartAddress.streetAddress : '' }
15 |
16 | )
17 | }
18 | }
19 |
20 | function mapStateToProps(state, props) {
21 | return {
22 | cartAddress: state.cartAddress,
23 | };
24 | }
25 |
26 | export default withRouter(connect(mapStateToProps)(Address))
27 |
--------------------------------------------------------------------------------
/src/components/AddressPicker.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { Panel, FormGroup, ControlLabel, FormControl, Radio, Alert, Button, ListGroup, ListGroupItem, Glyphicon } from 'react-bootstrap';
3 | import PlacesAutocomplete, { geocodeByPlaceId } from 'react-places-autocomplete'
4 | import { bindActionCreators } from 'redux';
5 | import { connect } from 'react-redux'
6 | import _ from 'lodash';
7 | import { withRouter, Redirect } from 'react-router-dom'
8 | import { toggleAddressForm, pickAddress, createAddress, checkDistance } from '../actions'
9 |
10 | const inputMap = {
11 | postal_code: 'postalCode',
12 | locality: 'addressLocality'
13 | };
14 |
15 | const autocompleteOptions = {
16 | types: ['address'],
17 | componentRestrictions: {
18 | country: "fr"
19 | }
20 | }
21 |
22 | const autocompleteStyles = {
23 | autocompleteContainer: {
24 | zIndex: 1
25 | }
26 | }
27 |
28 | class AddressPicker extends Component {
29 |
30 | constructor(props) {
31 | super(props);
32 | this.state = {
33 | streetAddress: '',
34 | postalCode: '',
35 | addressLocality: '',
36 | geo: {
37 | latitude: 0,
38 | longitude: 0
39 | },
40 | placeId: null,
41 | success: false
42 | }
43 | }
44 |
45 | onAddressClick(address, e) {
46 | this.props.actions.pickAddress(address)
47 | }
48 |
49 | toggleAddressForm() {
50 | this.props.actions.toggleAddressForm();
51 | }
52 |
53 | onAddressSelect(streetAddress, placeId) {
54 | this.setState({ streetAddress, placeId });
55 |
56 | geocodeByPlaceId(placeId)
57 | .then(results => {
58 |
59 | if (results.length > 0) {
60 |
61 | const place = results[0];
62 |
63 | const latitude = place.geometry.location.lat();
64 | const longitude = place.geometry.location.lng();
65 |
66 | let address = {
67 | streetAddress,
68 | geo: { latitude, longitude }
69 | }
70 | for (var i = 0; i < place.address_components.length; i++) {
71 | var addressType = place.address_components[i].types[0];
72 | var value = place.address_components[i].long_name;
73 | if (inputMap.hasOwnProperty(addressType)) {
74 | address[inputMap[addressType]] = value;
75 | }
76 | }
77 |
78 | this.props.actions.pickAddress(address)
79 | }
80 | })
81 | .catch(error => console.error(error))
82 | }
83 |
84 | getStreetAddress(cartAddress) {
85 | if (cartAddress) {
86 | const isNewAddress = !cartAddress.hasOwnProperty('@id')
87 |
88 | return isNewAddress ? cartAddress.streetAddress : ''
89 | }
90 | }
91 |
92 | componentWillReceiveProps(nextProps) {
93 | const { cartAddress } = nextProps;
94 |
95 | const isRequestFinished = this.props.checkDistanceRequest.loading && !nextProps.checkDistanceRequest.loading
96 | const success = isRequestFinished && nextProps.checkDistanceRequest.success
97 |
98 | const newState = { success };
99 |
100 | const streetAddress = this.getStreetAddress(cartAddress);
101 | if (streetAddress) {
102 | Object.assign(newState, { streetAddress });
103 | }
104 | this.setState(newState);
105 | }
106 |
107 | componentDidMount() {
108 | const { cartAddress } = this.props;
109 |
110 | const newState = { success: false }
111 |
112 | const streetAddress = this.getStreetAddress(cartAddress);
113 | if (streetAddress) {
114 | Object.assign(newState, { streetAddress })
115 | }
116 | this.setState(newState)
117 | }
118 |
119 | renderAddressForm() {
120 |
121 | const { cartAddress } = this.props;
122 | const { streetAddress } = this.state;
123 | const isNewAddress = cartAddress && !cartAddress.hasOwnProperty('@id');
124 |
125 | const inputProps = {
126 | placeholder: 'Entrez votre adresse',
127 | value: streetAddress,
128 | onChange: (streetAddress) => this.setState({ streetAddress }),
129 | autoComplete: "false",
130 | }
131 |
132 | const cssClasses = {
133 | input: 'form-control',
134 | }
135 |
136 | return (
137 |
138 | Entrez votre adresse de livraison
139 |
145 |
146 |
147 | )
148 | }
149 |
150 | renderAddressItems() {
151 | return (
152 |
153 | { this.props.addresses.map((item) => {
154 | const isActive = this.props.cartAddress && this.props.cartAddress['@id'] === item['@id']
155 | return (
156 |
160 | { item.streetAddress }
161 | { isActive &&
162 | }
163 |
164 | )
165 | } ) }
166 |
167 | )
168 | }
169 |
170 | render() {
171 |
172 | const { loading, error } = this.props.checkDistanceRequest;
173 | const { success } = this.state;
174 |
175 | const disabled = !this.props.cartAddress || loading;
176 | const buttonText = loading ? 'Vérification…' : 'Continuer'
177 |
178 | if (success) {
179 | return (
180 |
181 | )
182 | }
183 |
184 | return (
185 |
186 | { this.renderAddressForm() }
187 |
188 | Vos adresses sauvegardées
189 | { this.props.addresses.length === 0 ? ( Aucune adresse ) : this.renderAddressItems() }
190 |
191 | { (error && !loading) && (
192 | Désolé, nous n'assurons pas les livraisons à cette adresse (3km maximum)
193 | ) }
194 |
195 |
198 |
199 | )
200 | }
201 | }
202 |
203 | function mapStateToProps(state, props) {
204 | return {
205 | cartAddress: state.cartAddress,
206 | addresses: state.user ? state.user.addresses : [],
207 | checkDistanceRequest: state.checkDistanceRequest
208 | };
209 | }
210 |
211 | function mapDispatchToProps(dispatch) {
212 | return {
213 | actions: bindActionCreators({ toggleAddressForm, pickAddress, createAddress, checkDistance }, dispatch)
214 | }
215 | }
216 |
217 | export default withRouter(connect(mapStateToProps, mapDispatchToProps)(AddressPicker))
218 |
--------------------------------------------------------------------------------
/src/components/Breadcrumb.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { connect } from 'react-redux'
3 | import { withRouter, Redirect } from 'react-router-dom'
4 | import { Navbar, Nav, NavItem, Breadcrumb } from 'react-bootstrap';
5 | import { disconnect } from '../actions'
6 |
7 | class Breadcrumb_ extends Component {
8 |
9 | onClickItem(step, path) {
10 | if (this.props.step > step) {
11 | this.props.history.push(path);
12 | }
13 | }
14 |
15 | render() {
16 | const isAuthenticated = !!this.props.user
17 |
18 | return (
19 |
20 | this.props.history.push('/') }>
21 | Adresse
22 |
23 | this.onClickItem(2, '/menu') }>
24 | Panier
25 |
26 |
27 | Connexion
28 |
29 |
30 | Paiement
31 |
32 |
33 | )
34 | }
35 | }
36 |
37 | function mapStateToProps(state, props) {
38 | return {
39 | user: state.user,
40 | };
41 | }
42 |
43 | export default withRouter(connect(mapStateToProps)(Breadcrumb_))
44 |
--------------------------------------------------------------------------------
/src/components/Cart.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { ListGroup, ListGroupItem, Alert, Button } from 'react-bootstrap';
3 | import { bindActionCreators } from 'redux';
4 | import { connect } from 'react-redux'
5 | import _ from 'lodash';
6 | import { withRouter } from 'react-router-dom'
7 | import { removeFromCart } from '../actions'
8 | import { cartTotal, cartCountItems } from '../utils'
9 | import DatePicker from './DatePicker'
10 |
11 | class Cart extends Component {
12 |
13 | constructor(props) {
14 | super(props)
15 |
16 | this.state = {
17 | toggled: false
18 | }
19 |
20 | this.onButtonClick = this.onButtonClick.bind(this)
21 | this.onHeaderClick = this.onHeaderClick.bind(this)
22 | }
23 |
24 | onHeaderClick () {
25 | this.setState({'toggled': !this.state.toggled })
26 | }
27 |
28 | onButtonClick () {
29 | this.props.history.push(this.props.isAuthenticated ? '/checkout' : '/login')
30 | }
31 |
32 | displayModifiers (modifiers) {
33 | return _.map(_.values(modifiers), (value) => {
34 | return value.name;
35 | }, '').join(', ');
36 | }
37 |
38 | renderCartItems() {
39 | return (
40 |
41 | { this.props.cartItems.map((item, key) =>
42 |
43 | { item.menuItem.name }
44 | ×{ item.quantity }
45 |
49 |
50 | { this.displayModifiers(item.selectedModifiers) }
51 |
52 | ) }
53 |
54 | )
55 | }
56 |
57 | renderButton() {
58 | const buttonDisabled = this.props.cartItems.length === 0;
59 |
60 | return (
61 |
62 | )
63 | }
64 |
65 | render() {
66 | const { toggled } = this.state
67 |
68 | var panelClasses = ['panel', 'panel-default', 'cart-wrapper']
69 | if (toggled) {
70 | panelClasses.push('cart-wrapper--show')
71 | }
72 |
73 | return (
74 |
75 |
76 | { this.props.itemCount }
77 |
78 | Ma commande
79 |
80 |
81 | {
82 | !this.props.noDatePicker && (
)
83 | }
84 | { this.props.cartItems.length > 0 ? this.renderCartItems(): (
85 |
Votre panier est vide
86 | ) }
87 |
88 |
89 | Total : { this.props.total } €
90 |
91 | { !this.props.readonly && this.renderButton() }
92 |
93 |
94 | )
95 | }
96 | }
97 |
98 | function mapStateToProps(state, props) {
99 | return {
100 | cartItems: state.cartItems,
101 | total: cartTotal(state.cartItems),
102 | itemCount: cartCountItems(state.cartItems),
103 | isAuthenticated: !!state.user
104 | };
105 | }
106 |
107 | function mapDispatchToProps(dispatch) {
108 | return {
109 | actions: bindActionCreators({ removeFromCart }, dispatch)
110 | }
111 | }
112 |
113 | export default withRouter(connect(mapStateToProps, mapDispatchToProps)(Cart))
114 |
--------------------------------------------------------------------------------
/src/components/CreditCardForm.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { Panel, FormGroup, Radio, Alert, Button, ControlLabel, FormControl, Row, Col } from 'react-bootstrap';
3 | import { CardElement, CardNumberElement, CardExpiryElement, CardCVCElement, injectStripe } from 'react-stripe-elements';
4 | import { bindActionCreators } from 'redux';
5 | import { connect } from 'react-redux'
6 | import { withRouter, Redirect } from 'react-router-dom'
7 | import { finalizeOrder } from '../actions'
8 | import { cartTotal } from '../utils'
9 |
10 | const cardElementStyle = {
11 | base: {
12 | color: '#32325d',
13 | lineHeight: '24px',
14 | fontFamily: '"Helvetica Neue", Helvetica, sans-serif',
15 | fontSmoothing: 'antialiased',
16 | fontSize: '16px',
17 | '::placeholder': {
18 | color: '#aab7c4'
19 | }
20 | },
21 | invalid: {
22 | color: '#fa755a',
23 | iconColor: '#fa755a'
24 | }
25 | }
26 |
27 | class CreditCardForm extends Component {
28 |
29 | constructor(props) {
30 | super(props);
31 | this.state = {
32 | errorMessage: ''
33 | }
34 | }
35 |
36 | finalizeOrder() {
37 | this.setState({ errorMessage: '' })
38 |
39 | this.props.stripe.createToken({ type: 'card' }).then(({ token, error }) => {
40 | if (error) {
41 | this.setState({ errorMessage: error.message })
42 | return
43 | }
44 |
45 | this.props.actions.finalizeOrder(token);
46 | });
47 | }
48 |
49 | render() {
50 |
51 | const { loading, success, apiErrorMessage } = this.props.createOrderRequest;
52 | let errorMessage = apiErrorMessage || this.state.errorMessage;
53 |
54 | if (success) {
55 | return (
56 |
57 | )
58 | }
59 |
60 | const title = (
61 | Paiement
62 | );
63 |
64 | return (
65 |
66 |
67 | { errorMessage && { errorMessage } }
68 |
74 |
75 |
78 |
79 | )
80 | }
81 | }
82 |
83 | function mapStateToProps(state, props) {
84 | return {
85 | cartItems: state.cartItems,
86 | cartAddress: state.cartAddress,
87 | total: cartTotal(state.cartItems),
88 | createOrderRequest: state.createOrderRequest
89 | };
90 | }
91 |
92 | function mapDispatchToProps(dispatch) {
93 | return {
94 | actions: bindActionCreators({ finalizeOrder }, dispatch)
95 | }
96 | }
97 |
98 | export default withRouter(injectStripe(connect(mapStateToProps, mapDispatchToProps)(CreditCardForm)))
99 |
--------------------------------------------------------------------------------
/src/components/DatePicker.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { connect } from 'react-redux'
3 | import { withRouter } from 'react-router-dom'
4 | import { compose } from 'redux'
5 | import groupBy from 'lodash.groupby'
6 | import moment from 'moment'
7 |
8 | import { setDeliveryDate } from '../actions'
9 |
10 | moment.locale('fr')
11 |
12 | class DatePicker extends Component {
13 |
14 | constructor (props) {
15 | super(props)
16 |
17 | const { availabilities } = this.props
18 |
19 | const days = groupBy(availabilities, date =>
20 | moment(date).format('YYYY-MM-DD'))
21 | const availableTimes = days[Object.keys(days)[0]]
22 | .map(date => moment(date).format('HH:mm'))
23 |
24 | this.state = {
25 | availableTimes,
26 | date: null,
27 | time: null
28 | }
29 | }
30 |
31 | // 1. when shouldComponentUpdate said that ok,
32 | // the Component has to refresh given the new redux state
33 | // we can do the computation at WillMount and WillReceiveProps time
34 | // 2. we keep in the local
35 | // state of the component these computed date and time variables
36 | handleSetDateAndTime ({ availabilities, deliveryDate }) {
37 | let date, time
38 | if (!deliveryDate) {
39 | const first = availabilities[0]
40 | const firstMoment = moment(first)
41 | date = firstMoment.format('YYYY-MM-DD')
42 | time = firstMoment.format('HH:mm')
43 | } else {
44 | const deliveryDateMoment = moment(deliveryDate)
45 | date = deliveryDateMoment.format('YYYY-MM-DD')
46 | time = deliveryDateMoment.format('HH:mm')
47 | }
48 | this.setState({ date, time })
49 | }
50 |
51 | componentWillMount () {
52 | this.handleSetDateAndTime(this.props)
53 | }
54 |
55 | componentWillReceiveProps (nextProps) {
56 | // no need to compute if actually the component updates
57 | // for restaurant.availabilities changes
58 | if (nextProps.deliveryDate !== this.props.deliveryDate) {
59 | this.handleSetDateAndTime(nextProps)
60 | }
61 | }
62 |
63 | // 1. The Component still needs to trigger itself a new action
64 | // given its new local state, which is possible to control
65 | // at DidMount and DidUpdate time
66 | // 2. So once date and time are stored in the local state
67 | // we trigger the redux action setDeliveryDate, but we make sure
68 | // to call it once
69 | handleSetDeliveryDate () {
70 | const { date, time } = this.state
71 | this.props.setDeliveryDate(date + ' ' + time + ':00')
72 | }
73 |
74 | componentDidMount () {
75 | this.handleSetDeliveryDate()
76 | }
77 |
78 | componentDidUpdate (nextState) {
79 | // only trigger the action once
80 | // when date and/or time have changed in the local state
81 | if (nextState.date !== this.state.date || nextState.time !== this.state.time) {
82 | this.handleSetDeliveryDate()
83 | }
84 | }
85 |
86 | // 1. ui state changes that can also trigger
87 | // the local state
88 | // 2. our hooking setup will trigger automatically
89 | // the redux action setDeliveryDate
90 | onChangeDate ({ target: { value }}, days) {
91 | this.setState({
92 | availableTimes: days[value].map(date =>
93 | moment(date).format('HH:mm')),
94 | date: value
95 | })
96 | }
97 |
98 | onChangeTime ({ target: { value }}) {
99 | this.setState({ time: value })
100 | }
101 |
102 | render() {
103 |
104 | const { availabilities } = this.props
105 | const { availableTimes, date, time } = this.state
106 |
107 | const days = _.groupBy(availabilities, date =>
108 | moment(date).format('YYYY-MM-DD'))
109 | const dates = _.keys(days)
110 |
111 | return (
112 |
113 |
114 |
125 |
126 |
127 |
138 |
139 |
140 | )
141 | }
142 | }
143 |
144 | export default compose(
145 | withRouter,
146 |
147 | // 1. mapStateToProps is called for anykind of triggered actions.
148 | // so doing moment computations in the mapStateToProps time
149 | // is a cost but not useful, if actually the last action
150 | // was dealing with completely something else related to DatePicker
151 |
152 | // 2. keeping this idea, it is always better in mapStateToProps to return
153 | // values that are directly variables we can shallowCompare
154 | // if they differ from the previous redux state
155 |
156 | // 3. In that manner, the component shouldUpdates/renders just only
157 | // for the values of the redux state that concerns this Component
158 | connect(({ deliveryDate, restaurant: { availabilities } }) =>
159 | ({ availabilities, deliveryDate }),
160 | { setDeliveryDate })
161 | )(DatePicker)
162 |
--------------------------------------------------------------------------------
/src/components/LoginForm.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { bindActionCreators } from 'redux';
3 | import { connect } from 'react-redux';
4 | import { withRouter, Redirect } from 'react-router-dom';
5 | import { Alert, Panel, FormGroup, ControlLabel, FormControl, Button } from 'react-bootstrap';
6 | import { authenticate } from '../actions'
7 |
8 | class LoginForm extends Component {
9 |
10 | constructor(props) {
11 | super(props);
12 | this.state = {
13 | username: '',
14 | password: ''
15 | };
16 | }
17 |
18 | submitForm(e) {
19 | e.preventDefault();
20 | const { username, password } = this.state;
21 | this.props.actions.authenticate(username, password);
22 | }
23 |
24 | handleChange(event) {
25 | this.setState({ [ event.target.name ]: event.target.value });
26 | }
27 |
28 | render() {
29 |
30 | const { loading, success, error } = this.props.authenticationRequest;
31 | const props = loading ? { disabled: true } : {}
32 |
33 | if (success) {
34 | return (
35 |
36 | )
37 | }
38 |
39 | return (
40 |
41 |
61 |
62 | )
63 | }
64 | }
65 |
66 | function mapStateToProps(state, props) {
67 | return {
68 | authenticationRequest: state.authenticationRequest
69 | };
70 | }
71 |
72 | function mapDispatchToProps(dispatch) {
73 | return {
74 | actions: bindActionCreators({ authenticate }, dispatch)
75 | }
76 | }
77 |
78 | export default withRouter(connect(mapStateToProps, mapDispatchToProps)(LoginForm))
79 |
--------------------------------------------------------------------------------
/src/components/Menu.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { ListGroup } from 'react-bootstrap';
3 | import _ from 'lodash';
4 | import { connect } from 'react-redux';
5 | import Scroll from 'react-scroll';
6 | import MenuItem from './MenuItem';
7 |
8 | const scrollableElementHeight = '600px';
9 |
10 | class Menu extends Component {
11 |
12 | renderSection(section, index) {
13 |
14 | if (section.hasMenuItem.length === 0) {
15 | return;
16 | }
17 |
18 | let cssStyle = {}
19 |
20 | if (index === this.props.menu.hasMenuSection.length - 1) {
21 | cssStyle['minHeight'] = scrollableElementHeight
22 | }
23 |
24 | return (
25 |
26 | { section.name }
27 |
28 | {
29 | section.hasMenuItem.map((item, key) => {
30 | return ();
31 | })
32 | }
33 |
34 |
35 | )
36 | }
37 |
38 | render() {
39 | return (
40 |
41 | { _.map(this.props.menu.hasMenuSection, (section, index) => this.renderSection(section, index)) }
42 |
43 | )
44 | }
45 | }
46 |
47 | function mapStateToProps(state, props) {
48 | return {
49 | client: state.client,
50 | menu: state.restaurant.hasMenu
51 | };
52 | }
53 |
54 | function mapDispatchToProps(dispatch) {
55 | return {
56 |
57 | }
58 | }
59 |
60 | export default connect(mapStateToProps, mapDispatchToProps)(Menu)
61 |
--------------------------------------------------------------------------------
/src/components/MenuItem.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { ListGroup, ListGroupItem, Button } from 'react-bootstrap';
3 | import { connect } from 'react-redux';
4 | import { addToCart } from '../actions';
5 | import _ from 'lodash';
6 | import Modal from 'react-modal';
7 |
8 |
9 | class MenuItem extends Component {
10 |
11 | constructor (props) {
12 | super(props);
13 | this.state = {
14 | showModal: false,
15 | selectedModifiers: {}
16 | };
17 | this.noPointerEvents = {
18 | pointerEvents: 'none'
19 | };
20 | }
21 |
22 | showModal () {
23 | this.setState({showModal: true});
24 | }
25 |
26 | closeModal () {
27 | this.setState({showModal: false});
28 | }
29 |
30 | stopPropagation (evt) {
31 | evt.stopPropagation();
32 | return false;
33 | }
34 |
35 | removeModifierChoice (modifier) {
36 | var state = _.cloneDeep(this.state);
37 | delete state['selectedModifiers'][modifier['@id']];
38 | this.setState(state);
39 | }
40 |
41 | setModifierChoice (modifier, choice) {
42 | var state = _.cloneDeep(this.state);
43 | state['selectedModifiers'][modifier['@id']] = choice;
44 | this.setState(state);
45 | }
46 |
47 | onChange (evt, modifier, choice) {
48 | let input = evt.target.getElementsByTagName('input')[0];
49 | if (input.checked) {
50 | input.checked = false;
51 | this.removeModifierChoice (modifier);
52 | }
53 | else {
54 | input.checked = true;
55 | this.setModifierChoice(modifier, choice);
56 | }
57 | }
58 |
59 | onModalDone (e) {
60 | this.closeModal();
61 | this.props.onItemClick(this.props.item, this.state.selectedModifiers);
62 | }
63 |
64 | renderModifier (modifier, key) {
65 | return (
66 |
67 |
{ modifier.name }{ modifier.calculusStrategy === 'ADD_MODIFIER_PRICE' ? - { modifier.price }€ : '' }
68 |
80 |
81 | )
82 | }
83 |
84 | render () {
85 | return (
86 | 0 ? () => this.showModal() : () => this.props.onItemClick(this.props.item) }>
87 | { this.props.item.name } { this.props.item.offers.price } €
88 | { this.props.item.modifiers.length > 0 ?
89 | this.closeModal() }>
90 | this.stopPropagation(evt) }>
91 |
92 |
{ this.props.item.name }
93 |
94 |
95 | { this.props.item.modifiers.map( (modifier, key) => this.renderModifier(modifier, key)) }
96 |
97 |
98 |
99 |
100 |
101 |
102 | : ''
103 | }
104 |
105 | );
106 | }
107 |
108 | }
109 |
110 | const mapStateToProps = state => {
111 | return {
112 | }
113 | }
114 |
115 | const mapDispatchToProps = dispatch => {
116 | return {
117 | onItemClick: (item, modifiers = {}) => { dispatch(addToCart(item, modifiers)) }
118 | }
119 | }
120 |
121 | export default connect(mapStateToProps, mapDispatchToProps)(MenuItem);
122 |
--------------------------------------------------------------------------------
/src/components/MenuSections.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { ListGroup, ListGroupItem } from 'react-bootstrap';
3 | import _ from 'lodash';
4 | import { connect } from 'react-redux'
5 | import Scroll from 'react-scroll'
6 |
7 | class MenuSections extends Component {
8 | render() {
9 | return (
10 |
11 | { _.map(this.props.sections, (section) =>
12 |
13 |
14 | { section.name }
15 |
16 |
17 | ) }
18 |
19 | )
20 | }
21 | }
22 |
23 | function mapStateToProps(state, props) {
24 |
25 | const sections = _.groupBy(state.products, product => product.recipeCategory)
26 |
27 | return {
28 | sections: state.restaurant.hasMenu.hasMenuSection
29 | };
30 | }
31 |
32 | export default connect(mapStateToProps)(MenuSections)
33 |
--------------------------------------------------------------------------------
/src/components/Navbar.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { bindActionCreators } from 'redux';
3 | import { connect } from 'react-redux'
4 | import { withRouter, Redirect } from 'react-router-dom'
5 | import { Navbar, Nav, NavItem, Glyphicon } from 'react-bootstrap';
6 | import _ from 'lodash'
7 | import { disconnect, closeModal } from '../actions'
8 | import { cartTotal } from '../utils'
9 |
10 | import applicolisLogo from '../../assets/applicolis-logo.png'
11 | import coopCycleLogo from '../../assets/coopcycle-logo.png'
12 |
13 | const navbarStyle = {
14 | marginLeft : '-15px',
15 | marginRight : '-15px',
16 | borderRadius : 0,
17 | position: 'relative'
18 | }
19 |
20 | const closeStyle = {
21 | position: 'absolute',
22 | top: 0,
23 | right: '4px'
24 | }
25 |
26 | class Navbar_ extends Component {
27 |
28 | onClickDisconnect() {
29 | this.props.actions.disconnect()
30 | }
31 |
32 | onClickClose(e) {
33 | e.preventDefault();
34 | this.props.actions.closeModal();
35 | }
36 |
37 | render() {
38 |
39 | const isAuthenticated = !!this.props.user;
40 |
41 | return (
42 |
43 |
44 |
45 | { this.props.restaurantName }
46 |
47 |
48 |
49 |
53 |
54 |
55 |
58 |
61 |
64 |
70 |
75 |
76 |
77 | )
78 | }
79 | }
80 |
81 | function mapStateToProps(state, props) {
82 | return {
83 | user: state.user,
84 | cartAddress: state.cartAddress,
85 | total: cartTotal(state.cartItems),
86 | restaurantName: state.restaurant ? state.restaurant.name : 'Restaurant'
87 | };
88 | }
89 |
90 | function mapDispatchToProps(dispatch) {
91 | return {
92 | actions: bindActionCreators({ disconnect, closeModal }, dispatch)
93 | }
94 | }
95 |
96 | export default withRouter(connect(mapStateToProps, mapDispatchToProps)(Navbar_))
97 |
--------------------------------------------------------------------------------
/src/components/RegisterForm.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import { connect } from 'react-redux'
3 | import { Redirect, withRouter } from 'react-router-dom'
4 | import { compose } from 'redux'
5 | import { Alert,
6 | Button,
7 | ControlLabel,
8 | FormControl,
9 | FormGroup,
10 | Panel
11 | } from 'react-bootstrap'
12 |
13 | import { register } from '../actions'
14 |
15 | class RegisterForm extends Component {
16 |
17 | constructor (props) {
18 | super(props)
19 |
20 | this.state = {
21 | givenName: '',
22 | familyName: '',
23 | email: '',
24 | username: '',
25 | password: '',
26 | telephone: ''
27 | }
28 |
29 | this.handleChange = this._handleChange.bind(this)
30 | this.submitForm = this._submitForm.bind(this)
31 |
32 | }
33 |
34 | _handleChange(event) {
35 | this.setState({ [ event.target.name ]: event.target.value })
36 | }
37 | _submitForm(e) {
38 | e.preventDefault()
39 | this.props.register(this.state)
40 | }
41 |
42 | render() {
43 |
44 | const { authenticationRequest: { loading, success, error },
45 | history
46 | } = this.props
47 | const props = loading ? { disabled: true } : {}
48 |
49 | if (success) {
50 | return (
51 |
52 | )
53 | }
54 |
55 | return (
56 |
57 |
102 |
103 | )
104 | }
105 | }
106 |
107 | export default compose(
108 | withRouter,
109 | connect(({ authenticationRequest }) => ({ authenticationRequest }),
110 | { register })
111 | )(RegisterForm)
112 |
--------------------------------------------------------------------------------
/src/components/index.js:
--------------------------------------------------------------------------------
1 | import Menu from './Menu'
2 | import MenuSections from './MenuSections'
3 | import LoginForm from './LoginForm'
4 | import RegisterForm from './RegisterForm'
5 | import Cart from './Cart'
6 | import AddressPicker from './AddressPicker'
7 | import Address from './Address'
8 | import CreditCardForm from './CreditCardForm'
9 | import Navbar from './Navbar'
10 | import Breadcrumb from './Breadcrumb'
11 |
12 | module.exports = {
13 | Menu,
14 | MenuSections,
15 | LoginForm,
16 | RegisterForm,
17 | Cart,
18 | AddressPicker,
19 | Address,
20 | CreditCardForm,
21 | Navbar,
22 | Breadcrumb,
23 | }
24 |
--------------------------------------------------------------------------------
/src/index.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Coopcycle Client Lib Demo
5 |
6 |
7 |
8 |
9 |
10 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import Client from './client';
2 |
3 | import React from 'react';
4 | import { render } from 'react-dom';
5 | import Modal from 'react-modal';
6 | import Root from './app';
7 |
8 | import 'react-hot-loader/patch';
9 | import { AppContainer } from 'react-hot-loader';
10 |
11 | if (typeof window !== 'undefined') {
12 |
13 | require('./styles/index.scss')
14 |
15 | const modalStyle = {
16 | overlay: {
17 | backgroundColor: 'rgba(0, 0, 0, 0.75)'
18 | },
19 | content : {
20 | top : '20px',
21 | left : '20px',
22 | right : '20px',
23 | bottom : '20px',
24 | padding : 0,
25 | borderRadius : 0
26 | }
27 | };
28 |
29 | const renderApp = (el, isOpen, baseURL, restaurantId, stripePublishableKey) => {
30 |
31 | render(
32 |
33 |
34 | renderApp(el, false, baseURL, restaurantId, stripePublishableKey) }
39 | isOpen={ isOpen } />
40 |
41 | ,
42 | el);
43 |
44 | if (module.hot) {
45 | module.hot.accept('./app.js', () => {
46 | const NextRoot = require('./app.js').default;
47 | render(
48 |
49 | renderApp(el, false, baseURL, restaurantId, stripePublishableKey)}
54 | isOpen={isOpen}/>
55 | ,
56 | el);
57 | });
58 | }
59 | }
60 |
61 | const el = document.querySelector('[rel="coopcycle"]');
62 |
63 | if (el) {
64 |
65 | const baseURL = el.getAttribute( 'data-base-url');
66 | const restaurantId = el.getAttribute('data-restaurant-id');
67 | const stripePublishableKey = el.getAttribute('data-stripe-publishable-key');
68 | const googleApiKey = el.getAttribute('data-google-api-key');
69 |
70 | if (baseURL && restaurantId && stripePublishableKey && googleApiKey) {
71 |
72 | const scripts = [
73 | 'https://js.stripe.com/v3/',
74 | 'https://maps.googleapis.com/maps/api/js?libraries=places&key=' + googleApiKey
75 | ]
76 |
77 | scripts.forEach(src => {
78 | const script = document.createElement('script');
79 | script.setAttribute('src', src);
80 | document.body.appendChild(script);
81 | })
82 |
83 | const rootEl = document.createElement('div');
84 | rootEl.setAttribute('id', 'coopcycle__app');
85 | document.body.appendChild(rootEl);
86 |
87 | let modal = renderApp(rootEl, false, baseURL, restaurantId, stripePublishableKey);
88 |
89 | el.addEventListener('click', (e) => {
90 | modal = renderApp(rootEl, true, baseURL, restaurantId, stripePublishableKey)
91 | });
92 |
93 | }
94 | }
95 |
96 | }
97 |
98 | // Here goes the public API under the "Coopcycle" global variable
99 | module.exports = {
100 | Client,
101 | }
102 |
--------------------------------------------------------------------------------
/src/pages/AddressPage.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { Grid, Row, Col } from 'react-bootstrap';
3 | import { connect } from 'react-redux';
4 | import { withRouter } from 'react-router-dom';
5 | import { AddressPicker, Navbar, Breadcrumb } from '../components';
6 |
7 | const AddressPage = ({ history, user }) => {
8 | return (
9 |
10 |
11 |
12 |
13 |
14 |
15 | { !user && (
16 |
17 | {
18 | e.preventDefault()
19 | history.push('/login')
20 | }}>Vous avez déjà un compte ? Connectez-vous.
21 |
22 | ) }
23 |
24 |
25 |
26 | )
27 | }
28 |
29 | function mapStateToProps(state, props) {
30 | return {
31 | user: state.user,
32 | };
33 | }
34 |
35 | export default withRouter(connect(mapStateToProps)(AddressPage))
--------------------------------------------------------------------------------
/src/pages/CheckoutPage.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { Grid, Row, Col } from 'react-bootstrap';
3 | import { connect } from 'react-redux'
4 | import { withRouter, Redirect } from 'react-router-dom'
5 | import { Elements } from 'react-stripe-elements';
6 | import { Cart, Address, Navbar, Breadcrumb, CreditCardForm } from '../components'
7 |
8 | const CheckoutPage = ({ user, cartAddress }) => {
9 |
10 | if (!user || !cartAddress) {
11 | return (
12 |
13 | )
14 | }
15 |
16 | return (
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | )
35 | }
36 |
37 | function mapStateToProps(state, props) {
38 | return {
39 | user: state.user,
40 | cartAddress: state.cartAddress
41 | };
42 | }
43 |
44 | export default withRouter(connect(mapStateToProps)(CheckoutPage))
45 |
--------------------------------------------------------------------------------
/src/pages/ConfirmPage.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { Grid, Row, Col, Alert, Button} from 'react-bootstrap';
3 | import { Navbar, Cart } from '../components';
4 | import { connect } from 'react-redux';
5 | import { bindActionCreators } from 'redux';
6 | import { withRouter, Redirect } from 'react-router-dom';
7 | import moment from 'moment';
8 | import { resetCheckout } from "../actions/index";
9 |
10 |
11 | moment.locale('fr');
12 |
13 |
14 | class ConfirmPage extends Component {
15 |
16 | onClick() {
17 | this.props.actions.resetCheckout();
18 | }
19 |
20 | render () {
21 |
22 | if (!this.props.order) {
23 | return ();
24 | }
25 |
26 | const { client: { httpBaseURL }, deliveryDate, order: { publicUrl } } = this.props
27 | const deliveryMoment = moment(deliveryDate)
28 | const deliveryTime = deliveryMoment.format('HH[h]mm')
29 | const formattedDeliveryDate = deliveryMoment.format('dddd DD MMMM')
30 | const deliveryIsToday = formattedDeliveryDate === moment(Date.now()).format('dddd DD MMMM')
31 |
32 | let deliveryDateText = !deliveryIsToday ? ' le ' + formattedDeliveryDate : '';
33 |
34 | const orderUrl = httpBaseURL + publicUrl;
35 |
36 | return (
37 |
38 |
39 |
40 |
41 |
42 | Votre commande est validée ! Livraison prévue à { deliveryTime }{ deliveryDateText }.
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | )
61 | }
62 |
63 | }
64 |
65 | function mapStateToPros (state) {
66 | return {
67 | deliveryDate: state.deliveryDate,
68 | client: state.client,
69 | order: state.createOrderRequest.order
70 | }
71 | }
72 |
73 | function mapDispatchToProps(dispatch) {
74 | return {
75 | actions: bindActionCreators({ resetCheckout }, dispatch)
76 | }
77 | }
78 |
79 | export default withRouter(connect(mapStateToPros, mapDispatchToProps)(ConfirmPage))
80 |
--------------------------------------------------------------------------------
/src/pages/LoginPage.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { Grid, Row, Col } from 'react-bootstrap';
3 | import { LoginForm, Navbar, Breadcrumb } from '../components'
4 |
5 | const LoginPage = () => {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | )
17 | }
18 |
19 | export default LoginPage
--------------------------------------------------------------------------------
/src/pages/MenuPage.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { connect } from 'react-redux';
3 | import { withRouter, Redirect } from 'react-router-dom';
4 | import { Grid, Glyphicon, Row, Col } from 'react-bootstrap';
5 | import { Menu, MenuSections, Navbar, Breadcrumb, Cart } from '../components';
6 |
7 | const MenuPage = ({ cartAddress, openingHours }) => {
8 |
9 | if (!cartAddress) {
10 | return (
11 |
12 | )
13 | }
14 |
15 | return (
16 |
17 |
18 |
19 |
20 |
21 |
22 |
Horaires
23 | { openingHours.map((openingHour) =>
24 |
25 |
26 | { openingHour }
27 |
28 | ) }
29 |
30 | Menu
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 | )
42 | }
43 |
44 | function mapStateToProps(state, props) {
45 | return {
46 | cartAddress: state.cartAddress,
47 | openingHours: state.restaurant ? state.restaurant.openingHours : []
48 | };
49 | }
50 |
51 | export default withRouter(connect(mapStateToProps)(MenuPage))
52 |
--------------------------------------------------------------------------------
/src/pages/RegisterPage.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import { Grid, Row, Col } from 'react-bootstrap';
3 | import { RegisterForm, Navbar, Breadcrumb } from '../components'
4 |
5 | const RegisterPage = () => {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | )
17 | }
18 |
19 | export default RegisterPage
--------------------------------------------------------------------------------
/src/pages/index.js:
--------------------------------------------------------------------------------
1 | import MenuPage from './MenuPage'
2 | import LoginPage from './LoginPage'
3 | import CheckoutPage from './CheckoutPage'
4 | import ConfirmPage from './ConfirmPage'
5 | import RegisterPage from './RegisterPage'
6 | import AddressPage from './AddressPage'
7 |
8 | module.exports = {
9 | MenuPage,
10 | LoginPage,
11 | RegisterPage,
12 | CheckoutPage,
13 | ConfirmPage,
14 | AddressPage,
15 | }
16 |
--------------------------------------------------------------------------------
/src/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux'
2 | import _ from 'lodash';
3 | import hash from 'object-hash';
4 |
5 | const cartItems = (state = [], action) => {
6 |
7 | let newState, cartItem;
8 |
9 | switch (action.type) {
10 | case 'INITIALIZE':
11 | return action.cartItems || [];
12 | case 'ADD_TO_CART':
13 | newState = state.slice();
14 | cartItem = _.find(newState, (item) => {
15 | return (item.menuItem['@id'] === action.menuItem['@id'] && hash(item.selectedModifiers) === hash(action.selectedModifiers));
16 | });
17 | if (cartItem) {
18 | ++cartItem.quantity;
19 | } else {
20 | cartItem = {
21 | selectedModifiers: action.selectedModifiers,
22 | menuItem: action.menuItem,
23 | quantity: 1
24 | }
25 | newState.push(cartItem);
26 | }
27 | return newState;
28 | case 'REMOVE_FROM_CART':
29 | return state.filter((item) => item !== action.cartItem);
30 | case 'RESET_CHECKOUT':
31 | return [];
32 | default:
33 | return state
34 | }
35 | }
36 |
37 | // TODO Use Address object instead of id
38 | const cartAddress = (state = null, action) => {
39 | switch (action.type) {
40 | case 'INITIALIZE':
41 | return action.cartAddress;
42 | case 'PICK_ADDRESS':
43 | return action.address;
44 | case 'CREATE_ADDRESS_SUCCESS':
45 | return action.address['@id'];
46 | case 'RESET_CHECKOUT':
47 | return null;
48 | case 'DISCONNECT':
49 | if (state) {
50 | const isNewAddress = !state.hasOwnProperty('@id')
51 | return isNewAddress ? state : null
52 | }
53 | default:
54 | return state
55 | }
56 | }
57 |
58 | const client = (state = null, action) => {
59 | switch (action.type) {
60 | case 'INITIALIZE':
61 | return action.client;
62 | default:
63 | return state
64 | }
65 | }
66 |
67 | const restaurant = (state = null, action) => {
68 | switch (action.type) {
69 | case 'INITIALIZE':
70 | return action.restaurant;
71 | default:
72 | return state
73 | }
74 | }
75 |
76 | const credentials = (state = null, action) => {
77 | switch (action.type) {
78 | case 'AUTHENTICATION_SUCCESS':
79 | return action.credentials;
80 | case 'DISCONNECT':
81 | return null;
82 | default:
83 | return state
84 | }
85 | }
86 |
87 | const user = (state = null, action) => {
88 | let user;
89 |
90 | switch (action.type) {
91 | case 'INITIALIZE':
92 | case 'AUTHENTICATION_SUCCESS':
93 | return action.user;
94 | case 'CREATE_ADDRESS_SUCCESS':
95 | user = _.cloneDeep(state);
96 | user.addresses.push(action.address);
97 | return user;
98 | case 'DISCONNECT':
99 | return null;
100 | default:
101 | return state
102 | }
103 | }
104 |
105 | const showAddressForm = (state = false, action) => {
106 | switch (action.type) {
107 | case 'TOGGLE_ADDRESS_FORM':
108 | return !state;
109 | case 'CREATE_ADDRESS_SUCCESS':
110 | return false;
111 | default:
112 | return state
113 | }
114 | }
115 |
116 | const asyncRequest = {
117 | loading: false,
118 | success: false,
119 | error: false,
120 | }
121 |
122 | const authenticationRequest = (state = asyncRequest, action) => {
123 | switch (action.type) {
124 | case 'AUTHENTICATION_REQUEST':
125 | return { ...asyncRequest, loading: true };
126 | case 'AUTHENTICATION_SUCCESS':
127 | case 'AUTHENTICATION_FAILURE':
128 | return {
129 | loading: false,
130 | success: action.type === 'AUTHENTICATION_SUCCESS',
131 | error: action.type === 'AUTHENTICATION_FAILURE',
132 | };
133 | default:
134 | return state
135 | }
136 | }
137 |
138 | const createOrderRequest = (state = asyncRequest, action) => {
139 | switch (action.type) {
140 | case 'CREATE_ORDER_REQUEST':
141 | return { ...asyncRequest, loading: true };
142 | case 'CREATE_ORDER_SUCCESS':
143 | case 'CREATE_ORDER_FAILURE':
144 | return {
145 | order: action.order,
146 | loading: false,
147 | success: action.type === 'CREATE_ORDER_SUCCESS',
148 | error: action.type === 'CREATE_ORDER_FAILURE',
149 | apiErrorMessage: action.errorMessage
150 | };
151 | case 'INITIALIZE':
152 | return {
153 | order: action.order
154 | }
155 | case 'RESET_CHECKOUT':
156 | return {};
157 | default:
158 | return state
159 | }
160 | }
161 |
162 | const checkDistanceRequest = (state = asyncRequest, action) => {
163 | switch (action.type) {
164 | case 'CHECK_DISTANCE_REQUEST':
165 | return { ...asyncRequest, loading: true };
166 | case 'CHECK_DISTANCE_SUCCESS':
167 | case 'CHECK_DISTANCE_FAILURE':
168 | return {
169 | loading: false,
170 | success: action.type === 'CHECK_DISTANCE_SUCCESS',
171 | error: action.type === 'CHECK_DISTANCE_FAILURE',
172 | };
173 | default:
174 | return state
175 | }
176 | }
177 |
178 | const isOpen = (state = true, action) => {
179 | switch (action.type) {
180 | case 'INITIALIZE':
181 | return true;
182 | case 'CLOSE_MODAL':
183 | return false;
184 | default:
185 | return state
186 | }
187 | }
188 |
189 | const deliveryDate = (state = null, action) => {
190 | switch (action.type) {
191 | case 'INITIALIZE':
192 | return action.deliveryDate;
193 | case 'SET_DELIVERY_DATE':
194 | return action.date;
195 | case 'RESET_CHECKOUT':
196 | return null;
197 | default:
198 | return state
199 | }
200 | }
201 |
202 | export default combineReducers({
203 | cartItems,
204 | cartAddress,
205 | client,
206 | restaurant,
207 | credentials,
208 | user,
209 | authenticationRequest,
210 | createOrderRequest,
211 | checkDistanceRequest,
212 | showAddressForm,
213 | isOpen,
214 | deliveryDate
215 | })
216 |
--------------------------------------------------------------------------------
/src/store.js:
--------------------------------------------------------------------------------
1 | import { createStore, applyMiddleware, compose } from 'redux';
2 | import reducers from './reducers';
3 | import thunk from 'redux-thunk';
4 | import localforage from 'localforage';
5 | import {ORDER_KEY, CART_ITEM_KEY, CART_ADDRESS_KEY, DELIVERY_DATE_KEY} from "./actions/index";
6 |
7 | const middlewares = [ thunk ];
8 |
9 | // we maye want enhancing redux dev tools only in dev ?
10 | // also if server side render is made later, it is
11 | // better to add a guard here
12 | const composeEnhancers = (typeof window !== 'undefined' &&
13 | window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) || compose
14 |
15 | const store = createStore(
16 | reducers,
17 | composeEnhancers(applyMiddleware(...middlewares))
18 | );
19 |
20 | store.subscribe(() => {
21 | const state = store.getState();
22 | localforage.setItem(ORDER_KEY, state.createOrderRequest.order);
23 | localforage.setItem(CART_ITEM_KEY, state.cartItems);
24 | localforage.setItem(CART_ADDRESS_KEY, state.cartAddress);
25 | localforage.setItem('credentials', state.credentials);
26 | localforage.setItem('user', state.user);
27 | localforage.setItem(DELIVERY_DATE_KEY, state.deliveryDate);
28 | });
29 |
30 | export default store;
31 |
--------------------------------------------------------------------------------
/src/styles/index.scss:
--------------------------------------------------------------------------------
1 | @import url('https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css');
2 |
3 | // variables
4 |
5 | $margin-small: 10px;
6 | $margin-medium: 20px;
7 |
8 | $screen-md-min: 768px;
9 |
10 | $white: #fff;
11 | $white-text: #ECF0F1;
12 | $main-blue: #3C4A59;
13 | $main-blue-light: lighten($main-blue, 30%);
14 | $light-gray: #f5f5f5;
15 | $gray: #ddd;
16 |
17 | // bootstrap overrides
18 |
19 | body {
20 | font-family: 'Open Sans', sans-serif;
21 | padding-top: 40px;
22 | }
23 |
24 | button.list-group-item:focus {
25 | outline:0;
26 | }
27 |
28 | input[type=radio] {
29 | margin: 0 4px;
30 | }
31 |
32 | h1, h2, h3, h4, .navbar-brand, .coopcycle-brand {
33 | font-family: 'Arvo', serif;
34 | }
35 |
36 | .navbar-inverse {
37 | background-color: $main-blue;
38 | border-color: $main-blue;
39 | }
40 |
41 | .navbar-inverse .navbar-nav > li > a,
42 | .navbar-inverse .navbar-brand {
43 | color: $white-text;
44 | }
45 |
46 | .brands img {
47 | height: 23px;
48 | }
49 |
50 | .close {
51 | color: #fff;
52 | text-shadow: none;
53 | opacity: 1;
54 | }
55 |
56 | .coopcycle-brand {
57 | font-size: 12px;
58 | }
59 |
60 | .navbar-inverse {
61 | background-color: $main-blue;
62 | border-color: $main-blue;
63 | }
64 |
65 | .navbar-inverse .navbar-nav > li > a,
66 | .navbar-inverse .navbar-brand {
67 | color: $white-text;
68 | }
69 |
70 | .breadcrumb {
71 | @media screen and (max-width: $screen-md-min) {
72 | margin-bottom: $margin-small;
73 | }
74 | }
75 |
76 | // menu
77 | .list-section-item {
78 |
79 | @media screen and (max-width: $screen-md-min) {
80 | display: inline-block;
81 | }
82 |
83 | &:hover, &:focus {
84 | text-decoration: none;
85 | }
86 |
87 | &.active .list-group-item {
88 | background-color: $main-blue-light;
89 | color: $white;
90 | text-decoration: none;
91 | }
92 |
93 | &.active .list-group-item:last-child {
94 | margin-bottom: -1px;
95 | }
96 |
97 | }
98 |
99 | // cart
100 |
101 | $cart-heading-height: 40px;
102 |
103 | .cart-heading {
104 | position: relative;
105 | height: $cart-heading-height;
106 |
107 | @media screen and (max-width: $screen-md-min) {
108 | height: $cart-heading-height;
109 | text-align: center;
110 | color: $white !important;
111 | background-color: $main-blue-light !important;
112 | }
113 | }
114 |
115 | .cart-heading--items {
116 | display: none;
117 |
118 | @media screen and (max-width: $screen-md-min) {
119 | display: block;
120 | position: absolute;
121 | width: 20px;
122 | height: 20px;
123 | border-radius: 30px;
124 | left: 30px;
125 | background-color: $main-blue;
126 | }
127 | }
128 |
129 | .cart-heading--total {
130 | display: none;
131 |
132 | @media screen and (max-width: $screen-md-min) {
133 | display: block;
134 | position: absolute;
135 | right: 40px;
136 | height: 20px;
137 | width: 20px;
138 | padding: 0 5px;
139 | border-radius: 30px;
140 | background-color: $main-blue;
141 |
142 | .glyphicon {
143 | position: relative;
144 | right: 1px;
145 | }
146 | }
147 | }
148 |
149 | .cart-wrapper {
150 | overflow-y: scroll;
151 | height: 100%;
152 |
153 | @media screen and (max-width: $screen-md-min) {
154 | padding: 0;
155 | margin-bottom: 0;
156 | cursor: pointer;
157 |
158 |
159 | position: fixed;
160 | bottom: 0;
161 | left: 0;
162 | right: 0;
163 | transform: translateY(calc(100% - #{$cart-heading-height}));
164 | transition: transform 0.4s;
165 |
166 | // hide some bootstrap stuff
167 | .panel {
168 | border: none;
169 |
170 | & > .panel-heading {
171 | border: none;
172 | }
173 | }
174 | }
175 |
176 | .close {
177 | color: $gray;
178 | text-shadow: none
179 | }
180 |
181 | .quantity {
182 | margin-left: 5px;
183 | }
184 | }
185 |
186 | .cart-wrapper--show {
187 | @media screen and (max-width: $screen-md-min) {
188 | transform: translateY(0);
189 | }
190 | }
191 |
192 | // utils
193 | .margin-bottom-md {
194 | margin-bottom: $margin-medium;
195 | }
196 |
197 | .margin-top-md {
198 | margin-top: $margin-medium;
199 | }
200 |
201 | .text-center {
202 | text-align: center;
203 | }
204 |
--------------------------------------------------------------------------------
/src/utils.js:
--------------------------------------------------------------------------------
1 | import _ from 'lodash'
2 |
3 | function getItemPrice (item) {
4 | let price = item.menuItem.offers.price
5 |
6 | _.forOwn(item.selectedModifiers, (value, key) => {
7 |
8 | let modifier = _.find(item.menuItem.modifiers, (item) => {
9 | return item['@id'] === key;
10 | })
11 |
12 | if (modifier.calculusStrategy === 'ADD_MENUITEM_PRICE') {
13 | price += value.price
14 | }
15 | else if (modifier.calculusStrategy === 'ADD_MODIFIER_PRICE') {
16 | price += modifier.price
17 | }
18 | })
19 |
20 | return price;
21 | }
22 |
23 | const cartTotal = cartItems => {
24 | return _.sumBy(cartItems, (item) => {
25 | return getItemPrice(item) * item.quantity
26 | }).toFixed(2)
27 | }
28 |
29 | const cartCountItems = cartItems => {
30 | return _.sumBy(cartItems, (item) => {
31 | return item.quantity;
32 | })
33 | }
34 |
35 |
36 | export { cartTotal, cartCountItems }
37 |
--------------------------------------------------------------------------------
/webpack/config.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | const webpack = require('webpack')
3 |
4 | module.exports = {
5 | entry: {
6 | index: [
7 | 'babel-polyfill',
8 | path.join(__dirname, '../src/index.js')
9 | ]
10 | },
11 | module: {
12 | rules: [
13 | {
14 | test: /\.js$/,
15 | exclude: /node_modules(?!\/webpack-dev-server)/,
16 | include: path.join(__dirname, '../src'),
17 | loader: "babel-loader"
18 | },
19 | {
20 | test: /\.json$/,
21 | loader: 'json-loader'
22 | },
23 | // See https://github.com/kenny-hibino/react-places-autocomplete/issues/103
24 | {
25 | test: /\.(jpe?g|png|gif|svg)$/i,
26 | loaders: [
27 | 'file-loader?hash=sha512&digest=hex&name=[hash].[ext]',
28 | 'image-webpack-loader'
29 | ]
30 | },
31 | {
32 | test: /\.s?css$/,
33 | exclude: /node_modules/,
34 | use: [
35 | 'style-loader',
36 | 'css-loader',
37 | 'sass-loader'
38 | ]
39 | }
40 | ]
41 | },
42 | output: {
43 | path: path.join(__dirname, '../build'),
44 | filename: 'coopcycle.js',
45 | library: 'Coopcycle',
46 | libraryTarget: 'umd',
47 | publicPath: '/',
48 | },
49 | plugins: [
50 | new webpack.NamedModulesPlugin()
51 | ]
52 | }
53 |
--------------------------------------------------------------------------------
/webpack/dev.config.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs')
2 | const HtmlWebpackPlugin = require('html-webpack-plugin')
3 | const env = require('node-env-file')
4 | const path = require('path')
5 | const webpack = require('webpack')
6 |
7 | const config = require('./config')
8 | const { contentBase,
9 | host,
10 | url
11 | } = require('./server.config.js')
12 |
13 | const secretDir = path.join(__dirname, '../secret.sh')
14 | fs.existsSync(secretDir) && env(secretDir)
15 |
16 | const { env: { API_URL,
17 | GOOGLE_MAPS_API_KEY,
18 | STRIPE_PUBLISHABLE_KEY
19 | }
20 | } = process
21 |
22 | if (!GOOGLE_MAPS_API_KEY && !STRIPE_PUBLISHABLE_KEY) {
23 | throw "Please define your Stripe publishable key and your Google Maps API key as env variables"
24 | }
25 |
26 | module.exports = Object.assign({}, config,
27 | {
28 | devtool: 'source-map',
29 | entry: Object.assign(
30 | {
31 | index: [
32 | 'react-hot-loader/patch',
33 | `webpack-dev-server/client?${url}`,
34 | 'webpack/hot/only-dev-server'
35 | ].concat(config.entry.index)
36 | }
37 | ),
38 | plugins: [
39 | new HtmlWebpackPlugin({
40 | 'template': path.join(contentBase, 'index.ejs'),
41 |
42 | // pass variables
43 | STRIPE_PUBLISHABLE_KEY,
44 | API_URL: API_URL || `http://${host}`,
45 | GOOGLE_MAPS_API_KEY
46 | }),
47 | new webpack.HotModuleReplacementPlugin()
48 | ].concat(config.plugins)
49 | }
50 | )
51 |
--------------------------------------------------------------------------------
/webpack/prod.config.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs')
2 | const gzipSize = require('gzip-size')
3 | const path = require('path')
4 | const webpack = require('webpack')
5 |
6 | const config = require('./config')
7 |
8 | module.exports = Object.assign({},
9 | config,
10 | {
11 | plugins: [
12 | new webpack.DefinePlugin({ 'process.env': { NODE_ENV: '"production"' } }),
13 | function () {
14 | this.plugin('done', function (stats) {
15 | const filename = stats.compilation.outputOptions.filename.replace('[hash]', stats.hash)
16 | const filepath = path.join(stats.compilation.outputOptions.path, filename)
17 | fs.readFile(filepath, (err, data) => {
18 | if (err) { console.log('error reading js bundle', err) }
19 | const byteSize = gzipSize.sync(data)
20 | const kbSize = Math.round(byteSize / 1024)
21 | console.log('\n\nGZIP size\n', filename + ': ~', kbSize, 'kB\n')
22 | })
23 | })
24 | },
25 | new webpack.optimize.UglifyJsPlugin({
26 | compress: {
27 | warnings: false,
28 | screw_ie8: true
29 | }
30 | })
31 | ].concat(config.plugins)
32 | }
33 | )
34 |
--------------------------------------------------------------------------------
/webpack/server.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 |
3 | const HOST = 'localhost' // so we can test the project remotely over the same network
4 | const PORT = 9090
5 |
6 | module.exports = { contentBase: path.join(__dirname, '../src/'),
7 | host: HOST,
8 | port: PORT,
9 | url: `http://${HOST}:${PORT}`
10 | }
11 |
--------------------------------------------------------------------------------
/webpack/webpackDevServer.js:
--------------------------------------------------------------------------------
1 | const webpack = require('webpack')
2 | const WebpackDevServer = require('webpack-dev-server')
3 |
4 | const devConfig = require('./dev.config')
5 | const { contentBase,
6 | host,
7 | port,
8 | url
9 | } = require('./server.config.js')
10 |
11 | new WebpackDevServer(
12 | webpack(devConfig),
13 | {
14 | compress: true,
15 | contentBase,
16 | headers: {
17 | // it is important to not set a wildcard for security reason
18 | 'Access-Control-Allow-Origin': url
19 | },
20 | historyApiFallback: true,
21 | hot: true,
22 | publicPath: devConfig.output.publicPath,
23 | // provide less noisy output from webpack
24 | quiet: false,
25 | noInfo: false,
26 | stats: 'minimal'
27 | }
28 | ).listen(port, host, function (err, result) {
29 | if (err) {
30 | return console.log(err)
31 | }
32 | console.log(`You hot server is available here ${url}`)
33 | })
34 |
--------------------------------------------------------------------------------