├── .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 |
e.preventDefault() }> 69 | 70 | Numéro de carte 71 | 72 | 73 |
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 |
42 | 43 | Nom d'utilisateur 44 | 46 | 47 | 48 | Mot de passe 49 | 51 | 52 | 53 | 56 | 57 | { error ? Unable to log in : ''} 58 |
59 |

Pas encore enregistré ? { e.preventDefault(); this.props.history.push('/register') } }>Créer votre compte.

60 |
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 |
69 | 70 | { modifier.modifierChoices.map( ( choice, key ) => { 71 | return ( 72 | this.onChange(evt, modifier, choice) }> 73 | 74 | 75 | 76 | ) 77 | })} 78 | 79 |
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 |
58 | 59 | First Name 60 | 62 | 63 | 64 | Last Name 65 | 67 | 68 | 69 | Email 70 | 72 | 73 | 74 | Phone Number 75 | 77 | 78 | 79 | Nom d'utilisateur 80 | 82 | 83 | 84 | Mot de passe 85 | 87 | 88 | 89 | 92 | 93 | { error ? Unable to log in : ''} 94 |
95 |

96 | Déjà enregistré ? { e.preventDefault(); history.push('/login') } } > 98 | Connectez-vous. 99 | 100 |

101 |
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 | --------------------------------------------------------------------------------