├── .gitignore ├── README.md ├── client ├── README.md ├── package.json ├── public │ ├── crwn-192x192.png │ ├── crwn-512x512.png │ ├── favicon.ico │ ├── index.html │ └── manifest.json ├── src │ ├── App.js │ ├── assets │ │ ├── crown.svg │ │ └── shopping-bag.svg │ ├── components │ │ ├── cart-dropdown │ │ │ ├── __snapshots__ │ │ │ │ └── cart-dropdown.test.js.snap │ │ │ ├── cart-dropdown.component.jsx │ │ │ ├── cart-dropdown.styles.jsx │ │ │ └── cart-dropdown.test.js │ │ ├── cart-icon │ │ │ ├── __snapshots__ │ │ │ │ └── cart-icon.test.js.snap │ │ │ ├── cart-icon.component.jsx │ │ │ ├── cart-icon.styles.jsx │ │ │ └── cart-icon.test.js │ │ ├── cart-item │ │ │ ├── __snapshots__ │ │ │ │ └── cart-item.test.js.snap │ │ │ ├── cart-item.component.jsx │ │ │ ├── cart-item.styles.jsx │ │ │ └── cart-item.test.js │ │ ├── checkout-item │ │ │ ├── __snapshots__ │ │ │ │ └── checkout-item.test.js.snap │ │ │ ├── checkout-item.component.jsx │ │ │ ├── checkout-item.styles.jsx │ │ │ └── checkout-item.test.js │ │ ├── collection-item │ │ │ ├── __snapshots__ │ │ │ │ └── collection-item.test.js.snap │ │ │ ├── collection-item.component.jsx │ │ │ ├── collection-item.styles.jsx │ │ │ └── collection-item.test.js │ │ ├── collection-preview │ │ │ ├── __snapshots__ │ │ │ │ └── collection-preview.test.js.snap │ │ │ ├── collection-preview.component.jsx │ │ │ ├── collection-preview.styles.jsx │ │ │ └── collection-preview.test.js │ │ ├── collections-overview │ │ │ ├── __snapshots__ │ │ │ │ └── collections-overview.test.js.snap │ │ │ ├── collections-overview.component.jsx │ │ │ ├── collections-overview.container.jsx │ │ │ ├── collections-overview.styles.jsx │ │ │ └── collections-overview.test.js │ │ ├── custom-button │ │ │ ├── __snapshots__ │ │ │ │ └── custom-button.test.js.snap │ │ │ ├── custom-button.component.jsx │ │ │ ├── custom-button.styles.jsx │ │ │ └── custom-button.test.js │ │ ├── directory │ │ │ ├── __snapshots__ │ │ │ │ └── directory.test.js.snap │ │ │ ├── directory.component.jsx │ │ │ ├── directory.styles.jsx │ │ │ └── directory.test.js │ │ ├── error-boundary │ │ │ ├── error-boundary.component.jsx │ │ │ └── error-boundary.styles.jsx │ │ ├── form-input │ │ │ ├── __snapshots__ │ │ │ │ └── form-input.test.js.snap │ │ │ ├── form-input.component.jsx │ │ │ ├── form-input.styles.jsx │ │ │ └── form-input.test.js │ │ ├── header │ │ │ ├── __snapshots__ │ │ │ │ └── header.test.js.snap │ │ │ ├── header.component.jsx │ │ │ ├── header.styles.jsx │ │ │ └── header.test.js │ │ ├── menu-item │ │ │ ├── __snapshots__ │ │ │ │ └── menu-item.test.js.snap │ │ │ ├── menu-item.component.jsx │ │ │ ├── menu-item.styles.jsx │ │ │ └── menu-item.test.js │ │ ├── sign-in │ │ │ ├── sign-in.component.jsx │ │ │ └── sign-in.styles.jsx │ │ ├── sign-up │ │ │ ├── sign-up.component.jsx │ │ │ └── sign-up.styles.jsx │ │ ├── spinner │ │ │ ├── spinner.component.jsx │ │ │ └── spinner.styles.jsx │ │ ├── stripe-button │ │ │ └── stripe-button.component.jsx │ │ └── with-spinner │ │ │ ├── with-spinner.component.jsx │ │ │ └── with-spinner.test.js │ ├── firebase │ │ └── firebase.utils.js │ ├── global.styles.js │ ├── index.css │ ├── index.js │ ├── logo.svg │ ├── pages │ │ ├── checkout │ │ │ ├── __snapshots__ │ │ │ │ └── checkout.test.js.snap │ │ │ ├── checkout.component.jsx │ │ │ ├── checkout.styles.jsx │ │ │ └── checkout.test.js │ │ ├── collection │ │ │ ├── __snapshots__ │ │ │ │ └── collection.test.js.snap │ │ │ ├── collection.component.jsx │ │ │ ├── collection.container.jsx │ │ │ ├── collection.styles.jsx │ │ │ └── collection.test.js │ │ ├── homepage │ │ │ ├── __snapshots__ │ │ │ │ └── homepage.test.js.snap │ │ │ ├── homepage.component.jsx │ │ │ ├── homepage.styles.jsx │ │ │ └── homepage.test.js │ │ ├── shop │ │ │ ├── __snapshots__ │ │ │ │ └── shop.test.js.snap │ │ │ ├── shop.component.jsx │ │ │ ├── shop.styles.jsx │ │ │ └── shop.test.js │ │ └── sign-in-and-sign-up │ │ │ ├── __snapshots__ │ │ │ └── sign-in-and-sign-up.test.js.snap │ │ │ ├── sign-in-and-sign-up.component.jsx │ │ │ ├── sign-in-and-sign-up.styles.jsx │ │ │ └── sign-in-and-sign-up.test.js │ ├── redux │ │ ├── cart │ │ │ ├── cart.actions.js │ │ │ ├── cart.actions.test.js │ │ │ ├── cart.reducer.js │ │ │ ├── cart.reducer.test.js │ │ │ ├── cart.sagas.js │ │ │ ├── cart.sagas.test.js │ │ │ ├── cart.selectors.js │ │ │ ├── cart.types.js │ │ │ └── cart.utils.js │ │ ├── directory │ │ │ ├── directory.reducer.js │ │ │ ├── directory.reducer.test.js │ │ │ └── directory.selectors.js │ │ ├── root-reducer.js │ │ ├── root-saga.js │ │ ├── saga-testing.utils.js │ │ ├── shop │ │ │ ├── shop.actions.js │ │ │ ├── shop.actions.test.js │ │ │ ├── shop.reducer.js │ │ │ ├── shop.reducer.test.js │ │ │ ├── shop.sagas.js │ │ │ ├── shop.sagas.test.js │ │ │ ├── shop.selectors.js │ │ │ └── shop.types.js │ │ ├── store.js │ │ └── user │ │ │ ├── user.actions.js │ │ │ ├── user.reducer.js │ │ │ ├── user.reducer.test.js │ │ │ ├── user.sagas.js │ │ │ ├── user.sagas.test.js │ │ │ ├── user.selectors.js │ │ │ └── user.types.js │ ├── serviceWorker.js │ └── setupTests.js └── yarn.lock ├── package.json ├── server.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /client/node_modules 6 | /client/.pnp 7 | /client/.pnp.js 8 | 9 | # testing 10 | /client/coverage 11 | 12 | # production 13 | /client/build 14 | 15 | # misc 16 | /client/.DS_Store 17 | /client/.env.local 18 | /client/.env.development.local 19 | /client/.env.test.local 20 | /client/.env.production.local 21 | 22 | 23 | .env 24 | 25 | /client/npm-debug.log* 26 | /client/yarn-debug.log* 27 | /client/yarn-error.log* 28 | 29 | npm-debug.log* 30 | yarn-debug.log* 31 | yarn-error.log* -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # crwn-clothing-firebase-cart 2 | This repository contains my solution for storing a users cart in firestore. Most of the new code is in the cart saga and firebase utils along with the typical react-redux actions and action types. Make sure to look at the README to see what the security rules are! 3 | 4 | The firebase security rules I set up for the cart are: 5 | 6 | ![alt text](https://i.ibb.co/3SLTmvm/Screen-Shot-2019-08-29-at-10-12-32-PM.png "image to fork button") 7 | 8 | 9 | # How to fork and clone 10 | 11 | One quick note about cloning this project. If you wish to make commits and push the code up after cloning this repo, you should fork the project first. In order to own your own copy of this repository, you have to fork it so you get your own copy on your own profile! 12 | 13 | You can see the fork button in the top right corner of every GitHub project; click it and a copy of the project will be added to your GitHub profile under the same name as the original project. 14 | 15 | ![alt text](https://i.ibb.co/1YN7SJ6/Screen-Shot-2019-07-01-at-2-02-40-AM.png "image to fork button") 16 | 17 | After forking the project, simply clone it the way you would from the new forked project in your own GitHub repository and you can commit and push to it freely! 18 | 19 | 20 | # After you fork and clone: 21 | 22 | ## Install dependencies 23 | 24 | In your terminal after you clone your project down, remember to run either `yarn` or `npm install` to build all the dependencies in the project. 25 | 26 | ## Set your firebase config 27 | 28 | Remember to replace the `config` variable in your `firebase.utils.js` with your own config object from the firebase dashboard! Navigate to the project settings and scroll down to the config code. Copy the object in the code and replace the variable in your cloned code. 29 | 30 | ![alt text](https://i.ibb.co/6ywMkBf/Screen-Shot-2019-07-01-at-11-35-02-AM.png "image to firebase config") 31 | 32 | 33 | ## Set your stripe publishable key 34 | 35 | Set the `publishableKey` variable in the `stripe-button.component.jsx` with your own publishable key from the stripe dashboard. 36 | 37 | ![alt text](https://i.ibb.co/djQTmVF/Screen-Shot-2019-07-01-at-2-18-50-AM.png "image to publishable key") 38 | 39 | ## Things to set before you deploy 40 | 41 | Remember to add a file called `.env` to the root folder! In that `.env` file remember to add a `STRIPE_SECRET_KEY` value equal to your own secret key from your stripe dashboard. You can find it in the same place where you found your publishable key in the developers tab under api keys. You will have to enter the password in to reveal it! 42 | 43 | ![alt text](https://i.ibb.co/wpLx8Lh/Screen-Shot-2019-07-01-at-2-26-26-AM.png "image to secret key") 44 | 45 | You will also need to connect your existing Heroku app to this new forked and cloned repo, or you have to create a new Heroku app and push to it. A quick refresher on how to do either of these: 46 | 47 | ## Set to an existing Heroku app 48 | 49 | To set to an existing Heroku app you already have deployed, you need to know the name of the app you want to deploy to. To see a list of all the apps you currently have on Heroku: 50 | 51 | ``` 52 | heroku apps 53 | ``` 54 | 55 | Copy the name of the app you want to connect the project to, then run: 56 | 57 | ``` 58 | heroku git:remote -a 59 | ``` 60 | 61 | And now you'll have your repo connected to the heroku app under the git remote name `heroku`. 62 | 63 | If the Heroku app you connected was deploying just a create-react-app project from earlier in the lesson, you will need to remove the `mars/create-react-app-buildpack` buildpack first. You can check if you have this buildpack by running: 64 | 65 | ``` 66 | heroku buildpacks 67 | ``` 68 | 69 | Which will list any buildpacks you currently have, if you see `mars/create-react-app-buildpack` in the list, you can remove it by running: 70 | 71 | ``` 72 | heroku buildpacks:remove mars/create-react-app-buildpack 73 | ``` 74 | 75 | Then skip to the bottom of this article to see what to do next! 76 | 77 | 78 | ## To create a new Heroku app 79 | 80 | Create a new Heroku project by typing in your terminal: 81 | 82 | ``` 83 | heroku create 84 | ``` 85 | 86 | This will create a new Heroku project for you. Then run: 87 | 88 | ``` 89 | git remote -v 90 | ``` 91 | 92 | You should see heroku `https://git.heroku.com/` in the list. This means you have successfully connected your project to the newly created Heroku app under the git remote of `heroku`. 93 | 94 | 95 | ## Deploying to Heroku 96 | 97 | Before we deploy, you also need to set a config variable of `STRIPE_SECRET_KEY` to the same secret key value from your stripe dashboard, the same one in your `.env` file. The `.env` file is only for local development, in order for our heroku production app to have access to this secret key, we add it to our Heroku projects config variables by typing: 98 | 99 | ``` 100 | heroku config:set STRIPE_SECRET_KEY= 101 | ``` 102 | 103 | After that, you can deploy to heroku by running: 104 | 105 | ``` 106 | git push heroku master 107 | ``` 108 | 109 | You will see this warning message if you are pushing to an existing app: 110 | 111 | ``` 112 | ! [rejected] master -> master (fetch first) 113 | error: failed to push some refs to 'https://git.heroku.com/hasura-crwn-clothing.git' 114 | hint: Updates were rejected because the remote contains work that you do 115 | hint: not have locally. This is usually caused by another repository pushing 116 | hint: to the same ref. You may want to first integrate the remote changes 117 | hint: (e.g., 'git pull ...') before pushing again. 118 | hint: See the 'Note about fast-forwards' in 'git push --help' for details. 119 | ``` 120 | 121 | This is because we are pushing to an existing app that was deploying an entirely different repository from what we have now. Simply run: 122 | 123 | ``` 124 | git push heroku master --force 125 | ``` 126 | 127 | This will overwrite the existing Heroku app with our new code. 128 | 129 | 130 | ## Open our Heroku project 131 | 132 | After heroku finishes building our project, we can simply run: 133 | 134 | ``` 135 | heroku open 136 | ``` 137 | 138 | This will open up our browser and take us to our newly deployed Heroku project! 139 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZhangMYihua/crwn-clothing-firebase-cart/31423beaab31d0c7781bbf29e31cbfec82e82992/client/README.md -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "crwn-clothing", 3 | "version": "0.1.0", 4 | "private": true, 5 | "proxy": "http://localhost:5000", 6 | "dependencies": { 7 | "axios": "0.19.0", 8 | "enzyme": "3.10.0", 9 | "enzyme-adapter-react-16": "1.14.0", 10 | "firebase": "6.0.2", 11 | "node-sass": "4.12.0", 12 | "react": "^16.8.6", 13 | "react-dom": "^16.8.6", 14 | "react-redux": "7.0.3", 15 | "react-router-dom": "5.0.0", 16 | "react-stripe-checkout": "2.6.3", 17 | "react-test-renderer": "16.8.6", 18 | "redux": "4.0.1", 19 | "redux-logger": "3.0.6", 20 | "redux-persist": "5.10.0", 21 | "redux-saga": "1.0.2", 22 | "redux-thunk": "2.3.0", 23 | "reselect": "4.0.0", 24 | "styled-components": "4.2.0" 25 | }, 26 | "devDependencies": { 27 | "react-scripts": "3.0.0" 28 | }, 29 | "resolutions": { 30 | "babel-jest": "24.7.1" 31 | }, 32 | "scripts": { 33 | "start": "react-scripts start", 34 | "build": "react-scripts build", 35 | "test": "react-scripts test", 36 | "eject": "react-scripts eject" 37 | }, 38 | "eslintConfig": { 39 | "extends": "react-app" 40 | }, 41 | "browserslist": { 42 | "production": [ 43 | ">0.2%", 44 | "not dead", 45 | "not op_mini all" 46 | ], 47 | "development": [ 48 | "last 1 chrome version", 49 | "last 1 firefox version", 50 | "last 1 safari version" 51 | ] 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /client/public/crwn-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZhangMYihua/crwn-clothing-firebase-cart/31423beaab31d0c7781bbf29e31cbfec82e82992/client/public/crwn-192x192.png -------------------------------------------------------------------------------- /client/public/crwn-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZhangMYihua/crwn-clothing-firebase-cart/31423beaab31d0c7781bbf29e31cbfec82e82992/client/public/crwn-512x512.png -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZhangMYihua/crwn-clothing-firebase-cart/31423beaab31d0c7781bbf29e31cbfec82e82992/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 26 | CRWN Clothing 27 | 28 | 29 | 30 |
31 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Crwn-Clothing", 3 | "name": "Crwn-Clothing by Yihua", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "crwn-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "crwn-192x192.png", 17 | "sizes": "192x192", 18 | "type": "image/png" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /client/src/App.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, lazy, Suspense } from 'react'; 2 | import { Switch, Route, Redirect } from 'react-router-dom'; 3 | import { connect } from 'react-redux'; 4 | import { createStructuredSelector } from 'reselect'; 5 | 6 | import Header from './components/header/header.component'; 7 | import Spinner from './components/spinner/spinner.component'; 8 | import ErrorBoundary from './components/error-boundary/error-boundary.component'; 9 | 10 | import { GlobalStyle } from './global.styles'; 11 | 12 | import { selectCurrentUser } from './redux/user/user.selectors'; 13 | import { checkUserSession } from './redux/user/user.actions'; 14 | 15 | const HomePage = lazy(() => import('./pages/homepage/homepage.component')); 16 | const ShopPage = lazy(() => import('./pages/shop/shop.component')); 17 | const SignInAndSignUpPage = lazy(() => 18 | import('./pages/sign-in-and-sign-up/sign-in-and-sign-up.component') 19 | ); 20 | const CheckoutPage = lazy(() => import('./pages/checkout/checkout.component')); 21 | 22 | const App = ({ checkUserSession, currentUser }) => { 23 | useEffect(() => { 24 | checkUserSession(); 25 | }, [checkUserSession]); 26 | 27 | return ( 28 |
29 | 30 |
31 | 32 | 33 | }> 34 | 35 | 36 | 37 | 41 | currentUser ? : 42 | } 43 | /> 44 | 45 | 46 | 47 |
48 | ); 49 | }; 50 | 51 | const mapStateToProps = createStructuredSelector({ 52 | currentUser: selectCurrentUser 53 | }); 54 | 55 | const mapDispatchToProps = dispatch => ({ 56 | checkUserSession: () => dispatch(checkUserSession()) 57 | }); 58 | 59 | export default connect( 60 | mapStateToProps, 61 | mapDispatchToProps 62 | )(App); 63 | -------------------------------------------------------------------------------- /client/src/assets/crown.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Group 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /client/src/assets/shopping-bag.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 8 | 12 | 16 | 19 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /client/src/components/cart-dropdown/__snapshots__/cart-dropdown.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`CartDropdown component should render CartDropdown component 1`] = `ShallowWrapper {}`; 4 | -------------------------------------------------------------------------------- /client/src/components/cart-dropdown/cart-dropdown.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { createStructuredSelector } from 'reselect'; 4 | import { withRouter } from 'react-router-dom'; 5 | 6 | import CartItem from '../cart-item/cart-item.component'; 7 | import { selectCartItems } from '../../redux/cart/cart.selectors'; 8 | import { toggleCartHidden } from '../../redux/cart/cart.actions.js'; 9 | 10 | import { 11 | CartDropdownContainer, 12 | CartDropdownButton, 13 | EmptyMessageContainer, 14 | CartItemsContainer 15 | } from './cart-dropdown.styles'; 16 | 17 | export const CartDropdown = ({ cartItems, history, dispatch }) => ( 18 | 19 | 20 | {cartItems.length ? ( 21 | cartItems.map(cartItem => ( 22 | 23 | )) 24 | ) : ( 25 | Your cart is empty 26 | )} 27 | 28 | { 30 | history.push('/checkout'); 31 | dispatch(toggleCartHidden()); 32 | }} 33 | > 34 | GO TO CHECKOUT 35 | 36 | 37 | ); 38 | 39 | const mapStateToProps = createStructuredSelector({ 40 | cartItems: selectCartItems 41 | }); 42 | 43 | export default withRouter(connect(mapStateToProps)(CartDropdown)); 44 | -------------------------------------------------------------------------------- /client/src/components/cart-dropdown/cart-dropdown.styles.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import CustomButton from '../custom-button/custom-button.component'; 3 | 4 | export const CartDropdownContainer = styled.div` 5 | position: absolute; 6 | width: 240px; 7 | height: 340px; 8 | display: flex; 9 | flex-direction: column; 10 | padding: 20px; 11 | border: 1px solid black; 12 | background-color: white; 13 | top: 90px; 14 | right: 40px; 15 | z-index: 5; 16 | `; 17 | 18 | export const CartDropdownButton = styled(CustomButton)` 19 | margin-top: auto; 20 | `; 21 | 22 | CartDropdownButton.displayName = 'CartDropdownButton'; 23 | 24 | export const EmptyMessageContainer = styled.span` 25 | font-size: 18px; 26 | margin: 50px auto; 27 | `; 28 | 29 | EmptyMessageContainer.displayName = 'EmptyMessageContainer'; 30 | 31 | export const CartItemsContainer = styled.div` 32 | height: 240px; 33 | display: flex; 34 | flex-direction: column; 35 | overflow: scroll; 36 | `; 37 | -------------------------------------------------------------------------------- /client/src/components/cart-dropdown/cart-dropdown.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | 4 | import { CartDropdown } from './cart-dropdown.component'; 5 | import CartItem from '../cart-item/cart-item.component'; 6 | 7 | import { toggleCartHidden } from '../../redux/cart/cart.actions'; 8 | 9 | describe('CartDropdown component', () => { 10 | let wrapper; 11 | let mockHistory; 12 | let mockDispatch; 13 | const mockCartItems = [{ id: 1 }, { id: 2 }, { id: 3 }]; 14 | 15 | beforeEach(() => { 16 | mockHistory = { 17 | push: jest.fn() 18 | }; 19 | 20 | mockDispatch = jest.fn(); 21 | 22 | const mockProps = { 23 | cartItems: mockCartItems, 24 | history: mockHistory, 25 | dispatch: mockDispatch 26 | }; 27 | 28 | wrapper = shallow(); 29 | }); 30 | 31 | it('should render CartDropdown component', () => { 32 | expect(wrapper).toMatchSnapshot(); 33 | }); 34 | 35 | it('should call history.push when button is clicked', () => { 36 | wrapper.find('CartDropdownButton').simulate('click'); 37 | expect(mockHistory.push).toHaveBeenCalled(); 38 | expect(mockDispatch).toHaveBeenCalledWith(toggleCartHidden()); 39 | }); 40 | 41 | it('should render an equal number of CartItem components as the cartItems prop', () => { 42 | expect(wrapper.find(CartItem).length).toEqual(mockCartItems.length); 43 | }); 44 | 45 | it('should render EmptyMessageContainer if cartItems is empty', () => { 46 | const mockProps = { 47 | cartItems: [], 48 | history: mockHistory, 49 | dispatch: mockDispatch 50 | }; 51 | 52 | const newWrapper = shallow(); 53 | expect(newWrapper.exists('EmptyMessageContainer')).toBe(true); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /client/src/components/cart-icon/__snapshots__/cart-icon.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`CartIcon component should render CartIcon component 1`] = `ShallowWrapper {}`; 4 | -------------------------------------------------------------------------------- /client/src/components/cart-icon/cart-icon.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { createStructuredSelector } from 'reselect'; 4 | 5 | import { toggleCartHidden } from '../../redux/cart/cart.actions'; 6 | import { selectCartItemsCount } from '../../redux/cart/cart.selectors'; 7 | 8 | import { 9 | CartContainer, 10 | ShoppingIcon, 11 | ItemCountContainer 12 | } from './cart-icon.styles'; 13 | 14 | export const CartIcon = ({ toggleCartHidden, itemCount }) => ( 15 | 16 | 17 | {itemCount} 18 | 19 | ); 20 | 21 | const mapDispatchToProps = dispatch => ({ 22 | toggleCartHidden: () => dispatch(toggleCartHidden()) 23 | }); 24 | 25 | const mapStateToProps = createStructuredSelector({ 26 | itemCount: selectCartItemsCount 27 | }); 28 | 29 | export default connect( 30 | mapStateToProps, 31 | mapDispatchToProps 32 | )(CartIcon); 33 | -------------------------------------------------------------------------------- /client/src/components/cart-icon/cart-icon.styles.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | import { ReactComponent as ShoppingIconSVG } from '../../assets/shopping-bag.svg'; 4 | 5 | export const CartContainer = styled.div` 6 | width: 45px; 7 | height: 45px; 8 | position: relative; 9 | display: flex; 10 | align-items: center; 11 | justify-content: center; 12 | cursor: pointer; 13 | `; 14 | 15 | CartContainer.displayName = 'CartContainer'; 16 | 17 | export const ShoppingIcon = styled(ShoppingIconSVG)` 18 | width: 24px; 19 | height: 24px; 20 | `; 21 | 22 | export const ItemCountContainer = styled.span` 23 | position: absolute; 24 | font-size: 10px; 25 | font-weight: bold; 26 | bottom: 12px; 27 | `; 28 | 29 | ItemCountContainer.displayName = 'ItemCountContainer'; 30 | -------------------------------------------------------------------------------- /client/src/components/cart-icon/cart-icon.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import { CartIcon } from './cart-icon.component'; 4 | 5 | describe('CartIcon component', () => { 6 | let wrapper; 7 | let mockToggleCartHidden; 8 | beforeEach(() => { 9 | mockToggleCartHidden = jest.fn(); 10 | 11 | const mockProps = { 12 | itemCount: 0, 13 | toggleCartHidden: mockToggleCartHidden 14 | }; 15 | 16 | wrapper = shallow(); 17 | }); 18 | 19 | it('should render CartIcon component', () => { 20 | expect(wrapper).toMatchSnapshot(); 21 | }); 22 | 23 | it('should call toggleCartHidden when icon is clicked', () => { 24 | wrapper.find('CartContainer').simulate('click'); 25 | expect(mockToggleCartHidden).toHaveBeenCalled(); 26 | }); 27 | 28 | it('should render the itemCount as the text', () => { 29 | const itemCount = parseInt(wrapper.find('ItemCountContainer').text()); 30 | expect(itemCount).toBe(0); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /client/src/components/cart-item/__snapshots__/cart-item.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`should render CartItem component 1`] = `ShallowWrapper {}`; 4 | -------------------------------------------------------------------------------- /client/src/components/cart-item/cart-item.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { 4 | CartItemContainer, 5 | ItemDetailsContainer, 6 | CartItemImage 7 | } from './cart-item.styles'; 8 | 9 | const CartItem = ({ item: { imageUrl, price, name, quantity } }) => ( 10 | 11 | 12 | 13 | {name} 14 | 15 | {quantity} x ${price} 16 | 17 | 18 | 19 | ); 20 | 21 | export default React.memo(CartItem); 22 | -------------------------------------------------------------------------------- /client/src/components/cart-item/cart-item.styles.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const CartItemContainer = styled.div` 4 | width: 100%; 5 | display: flex; 6 | height: 80px; 7 | margin-bottom: 15px; 8 | `; 9 | 10 | export const CartItemImage = styled.img` 11 | width: 30%; 12 | `; 13 | 14 | export const ItemDetailsContainer = styled.div` 15 | width: 70%; 16 | display: flex; 17 | flex-direction: column; 18 | align-items: flex-start; 19 | justify-content: center; 20 | padding: 10px 20px; 21 | `; 22 | -------------------------------------------------------------------------------- /client/src/components/cart-item/cart-item.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import CartItem from './cart-item.component'; 4 | 5 | it('should render CartItem component', () => { 6 | const mockItem = { 7 | imageUrl: 'www.testImage.com', 8 | price: 10, 9 | name: 'hats', 10 | quantity: 2 11 | }; 12 | 13 | expect(shallow()).toMatchSnapshot(); 14 | }); 15 | -------------------------------------------------------------------------------- /client/src/components/checkout-item/__snapshots__/checkout-item.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`CheckoutItem component should render CheckoutItem component 1`] = `ShallowWrapper {}`; 4 | -------------------------------------------------------------------------------- /client/src/components/checkout-item/checkout-item.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { 5 | clearItemFromCart, 6 | addItem, 7 | removeItem 8 | } from '../../redux/cart/cart.actions'; 9 | 10 | import { 11 | CheckoutItemContainer, 12 | ImageContainer, 13 | TextContainer, 14 | QuantityContainer, 15 | RemoveButtonContainer 16 | } from './checkout-item.styles'; 17 | 18 | export const CheckoutItem = ({ cartItem, clearItem, addItem, removeItem }) => { 19 | const { name, imageUrl, price, quantity } = cartItem; 20 | return ( 21 | 22 | 23 | item 24 | 25 | {name} 26 | 27 |
removeItem(cartItem)}>❮
28 | {quantity} 29 |
addItem(cartItem)}>❯
30 |
31 | {price} 32 | clearItem(cartItem)}> 33 | ✕ 34 | 35 |
36 | ); 37 | }; 38 | 39 | const mapDispatchToProps = dispatch => ({ 40 | clearItem: item => dispatch(clearItemFromCart(item)), 41 | addItem: item => dispatch(addItem(item)), 42 | removeItem: item => dispatch(removeItem(item)) 43 | }); 44 | 45 | export default connect( 46 | null, 47 | mapDispatchToProps 48 | )(CheckoutItem); 49 | -------------------------------------------------------------------------------- /client/src/components/checkout-item/checkout-item.styles.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const CheckoutItemContainer = styled.div` 4 | width: 100%; 5 | display: flex; 6 | min-height: 100px; 7 | border-bottom: 1px solid darkgrey; 8 | padding: 15px 0; 9 | font-size: 20px; 10 | align-items: center; 11 | 12 | @media screen and (max-width: 800px) { 13 | font-size: 18px; 14 | } 15 | `; 16 | 17 | export const ImageContainer = styled.div` 18 | width: 23%; 19 | padding-right: 15px; 20 | 21 | img { 22 | width: 100%; 23 | height: 100%; 24 | } 25 | `; 26 | 27 | export const TextContainer = styled.span` 28 | width: 23%; 29 | 30 | @media screen and (max-width: 800px) { 31 | width: 22%; 32 | } 33 | `; 34 | 35 | export const QuantityContainer = styled(TextContainer)` 36 | display: flex; 37 | 38 | span { 39 | margin: 0 10px; 40 | } 41 | 42 | div { 43 | cursor: pointer; 44 | } 45 | `; 46 | 47 | QuantityContainer.displayName = 'QuantityContainer'; 48 | 49 | export const RemoveButtonContainer = styled.div` 50 | padding-left: 12px; 51 | cursor: pointer; 52 | `; 53 | 54 | RemoveButtonContainer.displayName = 'RemoveButtonContainer'; 55 | -------------------------------------------------------------------------------- /client/src/components/checkout-item/checkout-item.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | 4 | import { CheckoutItem } from './checkout-item.component'; 5 | 6 | describe('CheckoutItem component', () => { 7 | let wrapper; 8 | let mockClearItem; 9 | let mockAddItem; 10 | let mockRemoveItem; 11 | 12 | beforeEach(() => { 13 | mockClearItem = jest.fn(); 14 | mockAddItem = jest.fn(); 15 | mockRemoveItem = jest.fn(); 16 | 17 | const mockProps = { 18 | cartItem: { 19 | imageUrl: 'www.testImage.com', 20 | price: 10, 21 | name: 'hats', 22 | quantity: 2 23 | }, 24 | clearItem: mockClearItem, 25 | addItem: mockAddItem, 26 | removeItem: mockRemoveItem 27 | }; 28 | 29 | wrapper = shallow(); 30 | }); 31 | 32 | it('should render CheckoutItem component', () => { 33 | expect(wrapper).toMatchSnapshot(); 34 | }); 35 | 36 | it('should call clearItem when remove button is clicked', () => { 37 | wrapper.find('RemoveButtonContainer').simulate('click'); 38 | expect(mockClearItem).toHaveBeenCalled(); 39 | }); 40 | 41 | it('should call addItem when left arrow is clicked', () => { 42 | wrapper 43 | .find('QuantityContainer') 44 | .childAt(0) 45 | .simulate('click'); 46 | 47 | expect(mockRemoveItem).toHaveBeenCalled(); 48 | }); 49 | 50 | it('should call addItem when right arrow is clicked', () => { 51 | wrapper 52 | .find('QuantityContainer') 53 | .childAt(2) 54 | .simulate('click'); 55 | 56 | expect(mockAddItem).toHaveBeenCalled(); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /client/src/components/collection-item/__snapshots__/collection-item.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`CollectionItem component should render CollectionItem component 1`] = `ShallowWrapper {}`; 4 | -------------------------------------------------------------------------------- /client/src/components/collection-item/collection-item.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import { addItem } from '../../redux/cart/cart.actions'; 5 | 6 | import { 7 | CollectionItemContainer, 8 | CollectionFooterContainer, 9 | AddButton, 10 | BackgroundImage, 11 | NameContainer, 12 | PriceContainer 13 | } from './collection-item.styles'; 14 | 15 | export const CollectionItem = ({ item, addItem }) => { 16 | const { name, price, imageUrl } = item; 17 | 18 | return ( 19 | 20 | 21 | 22 | {name} 23 | {price} 24 | 25 | addItem(item)} inverted> 26 | Add to cart 27 | 28 | 29 | ); 30 | }; 31 | 32 | const mapDispatchToProps = dispatch => ({ 33 | addItem: item => dispatch(addItem(item)) 34 | }); 35 | 36 | export default connect( 37 | null, 38 | mapDispatchToProps 39 | )(CollectionItem); 40 | -------------------------------------------------------------------------------- /client/src/components/collection-item/collection-item.styles.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import CustomButton from '../custom-button/custom-button.component'; 3 | 4 | export const CollectionItemContainer = styled.div` 5 | width: 22vw; 6 | display: flex; 7 | flex-direction: column; 8 | height: 350px; 9 | align-items: center; 10 | position: relative; 11 | 12 | &:hover { 13 | .image { 14 | opacity: 0.8; 15 | } 16 | 17 | button { 18 | opacity: 0.85; 19 | display: flex; 20 | } 21 | } 22 | 23 | @media screen and (max-width: 800px) { 24 | width: 40vw; 25 | 26 | &:hover { 27 | .image { 28 | opacity: unset; 29 | } 30 | 31 | button { 32 | opacity: unset; 33 | } 34 | } 35 | } 36 | `; 37 | 38 | export const AddButton = styled(CustomButton)` 39 | width: 80%; 40 | opacity: 0.7; 41 | position: absolute; 42 | top: 255px; 43 | display: none; 44 | 45 | @media screen and (max-width: 800px) { 46 | display: block; 47 | opacity: 0.9; 48 | min-width: unset; 49 | padding: 0 10px; 50 | } 51 | `; 52 | 53 | AddButton.displayName = 'AddButton'; 54 | 55 | export const BackgroundImage = styled.div` 56 | width: 100%; 57 | height: 95%; 58 | background-size: cover; 59 | background-position: center; 60 | margin-bottom: 5px; 61 | background-image: ${({ imageUrl }) => `url(${imageUrl})`}; 62 | `; 63 | 64 | BackgroundImage.displayName = 'BackgroundImage'; 65 | 66 | export const CollectionFooterContainer = styled.div` 67 | width: 100%; 68 | height: 5%; 69 | display: flex; 70 | justify-content: space-between; 71 | font-size: 18px; 72 | `; 73 | 74 | CollectionFooterContainer.displayName = 'CollectionFooterContainer'; 75 | 76 | export const NameContainer = styled.span` 77 | width: 90%; 78 | margin-bottom: 15px; 79 | `; 80 | 81 | NameContainer.displayName = 'NameContainer'; 82 | 83 | export const PriceContainer = styled.span` 84 | width: 10%; 85 | text-align: right; 86 | `; 87 | 88 | PriceContainer.displayName = 'PriceContainer'; 89 | -------------------------------------------------------------------------------- /client/src/components/collection-item/collection-item.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | 4 | import { CollectionItem } from './collection-item.component'; 5 | 6 | describe('CollectionItem component', () => { 7 | let wrapper; 8 | let mockAddItem; 9 | const imageUrl = 'www.testImage.com'; 10 | const mockName = 'black hat'; 11 | const mockPrice = 10; 12 | 13 | beforeEach(() => { 14 | mockAddItem = jest.fn(); 15 | 16 | const mockProps = { 17 | item: { 18 | imageUrl: imageUrl, 19 | price: mockPrice, 20 | name: mockName 21 | }, 22 | addItem: mockAddItem 23 | }; 24 | 25 | wrapper = shallow(); 26 | }); 27 | 28 | it('should render CollectionItem component', () => { 29 | expect(wrapper).toMatchSnapshot(); 30 | }); 31 | 32 | it('should call addItem when AddButton clicked', () => { 33 | wrapper.find('AddButton').simulate('click'); 34 | 35 | expect(mockAddItem).toHaveBeenCalled(); 36 | }); 37 | 38 | it('should render imageUrl as a prop on BackgroundImage', () => { 39 | expect(wrapper.find('BackgroundImage').prop('imageUrl')).toBe(imageUrl); 40 | }); 41 | 42 | it('should render name prop in NameContainer', () => { 43 | expect(wrapper.find('NameContainer').text()).toBe(mockName); 44 | }); 45 | 46 | it('should render price prop in PriceContainer', () => { 47 | const price = parseInt(wrapper.find('PriceContainer').text()); 48 | expect(price).toBe(mockPrice); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /client/src/components/collection-preview/__snapshots__/collection-preview.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`CollectionPreview component should render CollectionPreview component 1`] = `ShallowWrapper {}`; 4 | -------------------------------------------------------------------------------- /client/src/components/collection-preview/collection-preview.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { withRouter } from 'react-router-dom'; 3 | 4 | import CollectionItem from '../collection-item/collection-item.component'; 5 | 6 | import { 7 | CollectionPreviewContainer, 8 | TitleContainer, 9 | PreviewContainer 10 | } from './collection-preview.styles'; 11 | 12 | export const CollectionPreview = ({ 13 | title, 14 | items, 15 | history, 16 | match, 17 | routeName 18 | }) => ( 19 | 20 | history.push(`${match.path}/${routeName}`)}> 21 | {title.toUpperCase()} 22 | 23 | 24 | {items 25 | .filter((item, idx) => idx < 4) 26 | .map(item => ( 27 | 28 | ))} 29 | 30 | 31 | ); 32 | 33 | export default withRouter(CollectionPreview); 34 | -------------------------------------------------------------------------------- /client/src/components/collection-preview/collection-preview.styles.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const CollectionPreviewContainer = styled.div` 4 | display: flex; 5 | flex-direction: column; 6 | margin-bottom: 30px; 7 | 8 | @media screen and (max-width: 800px) { 9 | align-items: center; 10 | } 11 | `; 12 | 13 | export const TitleContainer = styled.h1` 14 | font-size: 28px; 15 | margin-bottom: 25px; 16 | cursor: pointer; 17 | 18 | &:hover { 19 | color: grey; 20 | } 21 | `; 22 | 23 | TitleContainer.displayName = 'TitleContainer'; 24 | 25 | export const PreviewContainer = styled.div` 26 | display: flex; 27 | justify-content: space-between; 28 | 29 | @media screen and (max-width: 800px) { 30 | display: grid; 31 | grid-template-columns: 1fr 1fr; 32 | grid-gap: 15px; 33 | } 34 | `; 35 | -------------------------------------------------------------------------------- /client/src/components/collection-preview/collection-preview.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | 4 | import { CollectionPreview } from './collection-preview.component'; 5 | 6 | describe('CollectionPreview component', () => { 7 | let wrapper; 8 | let mockMatch; 9 | let mockHistory; 10 | const mockRouteName = 'hats'; 11 | 12 | beforeEach(() => { 13 | mockMatch = { 14 | path: '/shop' 15 | }; 16 | 17 | mockHistory = { 18 | push: jest.fn() 19 | }; 20 | 21 | const mockProps = { 22 | match: mockMatch, 23 | history: mockHistory, 24 | routeName: mockRouteName, 25 | title: 'hats', 26 | items: [] 27 | }; 28 | 29 | wrapper = shallow(); 30 | }); 31 | 32 | it('should render CollectionPreview component', () => { 33 | expect(wrapper).toMatchSnapshot(); 34 | }); 35 | 36 | it('should call history.push with the right string when TitleContainer clicked', () => { 37 | wrapper.find('TitleContainer').simulate('click'); 38 | 39 | expect(mockHistory.push).toHaveBeenCalledWith( 40 | `${mockMatch.path}/${mockRouteName}` 41 | ); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /client/src/components/collections-overview/__snapshots__/collections-overview.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`should render CollectionsOverview component 1`] = `ShallowWrapper {}`; 4 | -------------------------------------------------------------------------------- /client/src/components/collections-overview/collections-overview.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { createStructuredSelector } from 'reselect'; 4 | 5 | import CollectionPreview from '../collection-preview/collection-preview.component'; 6 | 7 | import { selectCollectionsForPreview } from '../../redux/shop/shop.selectors'; 8 | import { CollectionsOverviewContainer } from './collections-overview.styles'; 9 | 10 | export const CollectionsOverview = ({ collections }) => ( 11 | 12 | {collections.map(({ id, ...otherCollectionProps }) => ( 13 | 14 | ))} 15 | 16 | ); 17 | 18 | const mapStateToProps = createStructuredSelector({ 19 | collections: selectCollectionsForPreview 20 | }); 21 | 22 | export default connect(mapStateToProps)(CollectionsOverview); 23 | -------------------------------------------------------------------------------- /client/src/components/collections-overview/collections-overview.container.jsx: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { createStructuredSelector } from 'reselect'; 3 | import { compose } from 'redux'; 4 | 5 | import { selectIsCollectionFetching } from '../../redux/shop/shop.selectors'; 6 | import WithSpinner from '../with-spinner/with-spinner.component'; 7 | import CollectionsOverview from './collections-overview.component'; 8 | 9 | const mapStateToProps = createStructuredSelector({ 10 | isLoading: selectIsCollectionFetching 11 | }); 12 | 13 | const CollectionsOverviewContainer = compose( 14 | connect(mapStateToProps), 15 | WithSpinner 16 | )(CollectionsOverview); 17 | 18 | export default CollectionsOverviewContainer; 19 | -------------------------------------------------------------------------------- /client/src/components/collections-overview/collections-overview.styles.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const CollectionsOverviewContainer = styled.div` 4 | display: flex; 5 | flex-direction: column; 6 | `; 7 | -------------------------------------------------------------------------------- /client/src/components/collections-overview/collections-overview.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import { CollectionsOverview } from './collections-overview.component'; 4 | 5 | it('should render CollectionsOverview component', () => { 6 | expect(shallow()).toMatchSnapshot(); 7 | }); 8 | -------------------------------------------------------------------------------- /client/src/components/custom-button/__snapshots__/custom-button.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`should render CustomButton component 1`] = `ShallowWrapper {}`; 4 | -------------------------------------------------------------------------------- /client/src/components/custom-button/custom-button.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { CustomButtonContainer } from './custom-button.styles'; 4 | 5 | export const CustomButton = ({ children, ...props }) => ( 6 | {children} 7 | ); 8 | 9 | export default CustomButton; 10 | -------------------------------------------------------------------------------- /client/src/components/custom-button/custom-button.styles.jsx: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components'; 2 | 3 | const buttonStyles = css` 4 | background-color: black; 5 | color: white; 6 | border: none; 7 | 8 | &:hover { 9 | background-color: white; 10 | color: black; 11 | border: 1px solid black; 12 | } 13 | `; 14 | 15 | const invertedButtonStyles = css` 16 | background-color: white; 17 | color: black; 18 | border: 1px solid black; 19 | 20 | &:hover { 21 | background-color: black; 22 | color: white; 23 | border: none; 24 | } 25 | `; 26 | 27 | const googleSignInStyles = css` 28 | background-color: #4285f4; 29 | color: white; 30 | 31 | &:hover { 32 | background-color: #357ae8; 33 | border: none; 34 | } 35 | `; 36 | 37 | const getButtonStyles = props => { 38 | if (props.isGoogleSignIn) { 39 | return googleSignInStyles; 40 | } 41 | 42 | return props.inverted ? invertedButtonStyles : buttonStyles; 43 | }; 44 | 45 | export const CustomButtonContainer = styled.button` 46 | min-width: 165px; 47 | width: auto; 48 | height: 50px; 49 | letter-spacing: 0.5px; 50 | line-height: 50px; 51 | padding: 0 35px 0 35px; 52 | font-size: 15px; 53 | text-transform: uppercase; 54 | font-family: 'Open Sans Condensed'; 55 | font-weight: bolder; 56 | cursor: pointer; 57 | display: flex; 58 | justify-content: center; 59 | 60 | ${getButtonStyles} 61 | `; 62 | -------------------------------------------------------------------------------- /client/src/components/custom-button/custom-button.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import { CustomButton } from './custom-button.component'; 4 | 5 | it('should render CustomButton component', () => { 6 | expect(shallow()).toMatchSnapshot(); 7 | }); 8 | -------------------------------------------------------------------------------- /client/src/components/directory/__snapshots__/directory.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`should render Directory component 1`] = `ShallowWrapper {}`; 4 | -------------------------------------------------------------------------------- /client/src/components/directory/directory.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { createStructuredSelector } from 'reselect'; 4 | 5 | import { selectDirectorySections } from '../../redux/directory/directory.selectors'; 6 | 7 | import MenuItem from '../menu-item/menu-item.component'; 8 | 9 | import { DirectoryMenuContainer } from './directory.styles'; 10 | 11 | export const Directory = ({ sections }) => ( 12 | 13 | {sections.map(({ id, ...otherSectionProps }) => ( 14 | 15 | ))} 16 | 17 | ); 18 | 19 | const mapStateToProps = createStructuredSelector({ 20 | sections: selectDirectorySections 21 | }); 22 | 23 | export default connect(mapStateToProps)(Directory); 24 | -------------------------------------------------------------------------------- /client/src/components/directory/directory.styles.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const DirectoryMenuContainer = styled.div` 4 | width: 100%; 5 | display: flex; 6 | flex-wrap: wrap; 7 | justify-content: space-between; 8 | `; 9 | -------------------------------------------------------------------------------- /client/src/components/directory/directory.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import { Directory } from './directory.component'; 4 | 5 | it('should render Directory component', () => { 6 | expect(shallow()).toMatchSnapshot(); 7 | }); 8 | -------------------------------------------------------------------------------- /client/src/components/error-boundary/error-boundary.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { 4 | ErrorImageOverlay, 5 | ErrorImageContainer, 6 | ErrorImageText 7 | } from './error-boundary.styles'; 8 | 9 | class ErrorBoundary extends React.Component { 10 | constructor() { 11 | super(); 12 | 13 | this.state = { 14 | hasErrored: false 15 | }; 16 | } 17 | 18 | static getDerivedStateFromError(error) { 19 | // process the error 20 | return { hasErrored: true }; 21 | } 22 | 23 | componentDidCatch(error, info) { 24 | console.log(error); 25 | } 26 | 27 | render() { 28 | if (this.state.hasErrored) { 29 | return ( 30 | 31 | 32 | Sorry this page is broken 33 | 34 | ); 35 | } 36 | 37 | return this.props.children; 38 | } 39 | } 40 | 41 | export default ErrorBoundary; 42 | -------------------------------------------------------------------------------- /client/src/components/error-boundary/error-boundary.styles.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const ErrorImageOverlay = styled.div` 4 | height: 60vh; 5 | width: 100%; 6 | display: flex; 7 | flex-direction: column; 8 | justify-content: center; 9 | align-items: center; 10 | `; 11 | 12 | export const ErrorImageContainer = styled.div` 13 | display: inline-block; 14 | background-image: ${({ imageUrl }) => `url(${imageUrl})`}; 15 | background-size: cover; 16 | background-position: center; 17 | width: 40vh; 18 | height: 40vh; 19 | `; 20 | 21 | export const ErrorImageText = styled.h2` 22 | font-size: 28px; 23 | color: #2f8e89; 24 | `; 25 | -------------------------------------------------------------------------------- /client/src/components/form-input/__snapshots__/form-input.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`FormInput component should render FormInput component 1`] = `ShallowWrapper {}`; 4 | -------------------------------------------------------------------------------- /client/src/components/form-input/form-input.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { 4 | GroupContainer, 5 | FormInputContainer, 6 | FormInputLabel 7 | } from './form-input.styles'; 8 | 9 | const FormInput = ({ handleChange, label, ...props }) => ( 10 | 11 | 12 | {label ? ( 13 | 14 | {label} 15 | 16 | ) : null} 17 | 18 | ); 19 | 20 | export default FormInput; 21 | -------------------------------------------------------------------------------- /client/src/components/form-input/form-input.styles.jsx: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components'; 2 | 3 | const subColor = 'grey'; 4 | const mainColor = 'black'; 5 | 6 | const shrinkLabelStyles = css` 7 | top: -14px; 8 | font-size: 12px; 9 | color: ${mainColor}; 10 | `; 11 | 12 | export const GroupContainer = styled.div` 13 | position: relative; 14 | margin: 45px 0; 15 | 16 | input[type='password'] { 17 | letter-spacing: 0.3em; 18 | } 19 | `; 20 | 21 | export const FormInputContainer = styled.input` 22 | background: none; 23 | background-color: white; 24 | color: ${subColor}; 25 | font-size: 18px; 26 | padding: 10px 10px 10px 5px; 27 | display: block; 28 | width: 100%; 29 | border: none; 30 | border-radius: 0; 31 | border-bottom: 1px solid ${subColor}; 32 | margin: 25px 0; 33 | 34 | &:focus { 35 | outline: none; 36 | } 37 | 38 | &:focus ~ label { 39 | ${shrinkLabelStyles} 40 | } 41 | `; 42 | 43 | FormInputContainer.displayName = 'FormInputContainer'; 44 | 45 | export const FormInputLabel = styled.label` 46 | color: ${subColor}; 47 | font-size: 16px; 48 | font-weight: normal; 49 | position: absolute; 50 | pointer-events: none; 51 | left: 5px; 52 | top: 10px; 53 | transition: 300ms ease all; 54 | 55 | &.shrink { 56 | ${shrinkLabelStyles} 57 | } 58 | `; 59 | 60 | FormInputLabel.displayName = 'FormInputLabel'; 61 | -------------------------------------------------------------------------------- /client/src/components/form-input/form-input.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | 4 | import FormInput from './form-input.component'; 5 | 6 | describe('FormInput component', () => { 7 | let wrapper; 8 | let mockHandleChange; 9 | 10 | beforeEach(() => { 11 | mockHandleChange = jest.fn(); 12 | 13 | const mockProps = { 14 | label: 'email', 15 | value: 'test@gmail.com', 16 | handleChange: mockHandleChange 17 | }; 18 | 19 | wrapper = shallow(); 20 | }); 21 | 22 | it('should render FormInput component', () => { 23 | expect(wrapper).toMatchSnapshot(); 24 | }); 25 | 26 | it('should call handleChange method when input changes', () => { 27 | wrapper.find('FormInputContainer').simulate('change'); 28 | 29 | expect(mockHandleChange).toHaveBeenCalled(); 30 | }); 31 | 32 | it('should render FormInputLabel if there is a label', () => { 33 | expect(wrapper.exists('FormInputLabel')).toBe(true); 34 | }); 35 | 36 | it('should not render FormInputLabel if there is no label', () => { 37 | const mockNewProps = { 38 | label: '', 39 | value: 'test@gmail.com', 40 | handleChange: mockHandleChange 41 | }; 42 | 43 | const newWrapper = shallow(); 44 | 45 | expect(newWrapper.exists('FormInputLabel')).toBe(false); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /client/src/components/header/__snapshots__/header.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Header component should render FormInput component 1`] = `ShallowWrapper {}`; 4 | -------------------------------------------------------------------------------- /client/src/components/header/header.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { createStructuredSelector } from 'reselect'; 4 | 5 | import CartIcon from '../cart-icon/cart-icon.component'; 6 | import CartDropdown from '../cart-dropdown/cart-dropdown.component'; 7 | import { selectCartHidden } from '../../redux/cart/cart.selectors'; 8 | import { selectCurrentUser } from '../../redux/user/user.selectors'; 9 | import { signOutStart } from '../../redux/user/user.actions'; 10 | 11 | import { ReactComponent as Logo } from '../../assets/crown.svg'; 12 | 13 | import { 14 | HeaderContainer, 15 | LogoContainer, 16 | OptionsContainer, 17 | OptionLink 18 | } from './header.styles'; 19 | 20 | export const Header = ({ currentUser, hidden, signOutStart }) => ( 21 | 22 | 23 | 24 | 25 | 26 | SHOP 27 | CONTACT 28 | {currentUser ? ( 29 | 30 | SIGN OUT 31 | 32 | ) : ( 33 | SIGN IN 34 | )} 35 | 36 | 37 | {hidden ? null : } 38 | 39 | ); 40 | 41 | const mapStateToProps = createStructuredSelector({ 42 | currentUser: selectCurrentUser, 43 | hidden: selectCartHidden 44 | }); 45 | 46 | const mapDispatchToProps = dispatch => ({ 47 | signOutStart: () => dispatch(signOutStart()) 48 | }); 49 | 50 | export default connect( 51 | mapStateToProps, 52 | mapDispatchToProps 53 | )(Header); 54 | -------------------------------------------------------------------------------- /client/src/components/header/header.styles.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | export const HeaderContainer = styled.div` 5 | height: 70px; 6 | width: 100%; 7 | display: flex; 8 | justify-content: space-between; 9 | margin-bottom: 25px; 10 | 11 | @media screen and (max-width: 800px) { 12 | height: 60px; 13 | padding: 10px; 14 | margin-bottom: 20px; 15 | } 16 | `; 17 | 18 | export const LogoContainer = styled(Link)` 19 | height: 100%; 20 | width: 70px; 21 | padding: 25px; 22 | 23 | @media screen and (max-width: 800px) { 24 | width: 50px; 25 | padding: 0; 26 | } 27 | `; 28 | 29 | export const OptionsContainer = styled.div` 30 | width: 50%; 31 | height: 100%; 32 | display: flex; 33 | align-items: center; 34 | justify-content: flex-end; 35 | 36 | @media screen and (max-width: 800px) { 37 | width: 80%; 38 | } 39 | `; 40 | 41 | export const OptionLink = styled(Link)` 42 | padding: 10px 15px; 43 | cursor: pointer; 44 | `; 45 | 46 | OptionLink.displayName = 'OptionLink'; 47 | -------------------------------------------------------------------------------- /client/src/components/header/header.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | 4 | import { Header } from './header.component'; 5 | import CartDropdown from '../cart-dropdown/cart-dropdown.component'; 6 | 7 | describe('Header component', () => { 8 | let wrapper; 9 | let mockSignOutStart; 10 | 11 | beforeEach(() => { 12 | mockSignOutStart = jest.fn(); 13 | 14 | const mockProps = { 15 | hidden: true, 16 | currentUser: { 17 | uid: '123' 18 | }, 19 | signOutStart: mockSignOutStart 20 | }; 21 | 22 | wrapper = shallow(
); 23 | }); 24 | 25 | it('should render FormInput component', () => { 26 | expect(wrapper).toMatchSnapshot(); 27 | }); 28 | 29 | describe('if currentUser is present', () => { 30 | it('should render sign out link', () => { 31 | expect( 32 | wrapper 33 | .find('OptionLink') 34 | .at(2) 35 | .text() 36 | ).toBe('SIGN OUT'); 37 | }); 38 | 39 | it('should call signOutStart method when link is clicked', () => { 40 | wrapper 41 | .find('OptionLink') 42 | .at(2) 43 | .simulate('click'); 44 | 45 | expect(mockSignOutStart).toHaveBeenCalled(); 46 | }); 47 | }); 48 | 49 | describe('if currentUser is null', () => { 50 | it('should render sign in link', () => { 51 | const mockProps = { 52 | hidden: true, 53 | currentUser: null, 54 | signOutStart: mockSignOutStart 55 | }; 56 | 57 | const newWrapper = shallow(
); 58 | 59 | expect( 60 | newWrapper 61 | .find('OptionLink') 62 | .at(2) 63 | .text() 64 | ).toBe('SIGN IN'); 65 | }); 66 | }); 67 | 68 | describe('if hidden is true', () => { 69 | it('should not render CartDropdown', () => { 70 | expect(wrapper.exists(CartDropdown)).toBe(false); 71 | }); 72 | }); 73 | 74 | describe('if currentUser is null', () => { 75 | it('should render CartDropdown', () => { 76 | const mockProps = { 77 | hidden: false, 78 | currentUser: null, 79 | signOutStart: mockSignOutStart 80 | }; 81 | 82 | const newWrapper = shallow(
); 83 | 84 | expect(newWrapper.exists(CartDropdown)).toBe(true); 85 | }); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /client/src/components/menu-item/__snapshots__/menu-item.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`MenuItem component should render MenuItem component 1`] = `ShallowWrapper {}`; 4 | -------------------------------------------------------------------------------- /client/src/components/menu-item/menu-item.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { withRouter } from 'react-router-dom'; 3 | 4 | import { 5 | MenuItemContainer, 6 | BackgroundImageContainer, 7 | ContentContainer, 8 | ContentTitle, 9 | ContentSubtitle 10 | } from './menu-item.styles'; 11 | 12 | export const MenuItem = ({ 13 | title, 14 | imageUrl, 15 | size, 16 | history, 17 | linkUrl, 18 | match 19 | }) => ( 20 | history.push(`${match.url}${linkUrl}`)} 23 | > 24 | 28 | 29 | {title.toUpperCase()} 30 | SHOP NOW 31 | 32 | 33 | ); 34 | 35 | export default withRouter(MenuItem); 36 | -------------------------------------------------------------------------------- /client/src/components/menu-item/menu-item.styles.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const MenuItemContainer = styled.div` 4 | height: ${({ size }) => (size ? '380px' : '240px')} 5 | min-width: 30%; 6 | overflow: hidden; 7 | flex: 1 1 auto; 8 | display: flex; 9 | align-items: center; 10 | justify-content: center; 11 | border: 1px solid black; 12 | margin: 0 7.5px 15px; 13 | overflow: hidden; 14 | 15 | &:hover { 16 | cursor: pointer; 17 | 18 | & .background-image { 19 | transform: scale(1.1); 20 | transition: transform 6s cubic-bezier(0.25, 0.45, 0.45, 0.95); 21 | } 22 | 23 | & .content { 24 | opacity: 0.9; 25 | } 26 | } 27 | 28 | &:first-child { 29 | margin-right: 7.5px; 30 | } 31 | 32 | &:last-child { 33 | margin-left: 7.5px; 34 | } 35 | 36 | @media screen and (max-width: 800px) { 37 | height: 200px; 38 | } 39 | `; 40 | 41 | MenuItemContainer.displayName = 'MenuItemContainer'; 42 | 43 | export const BackgroundImageContainer = styled.div` 44 | width: 100%; 45 | height: 100%; 46 | background-size: cover; 47 | background-position: center; 48 | background-image: ${({ imageUrl }) => `url(${imageUrl})`}; 49 | `; 50 | 51 | BackgroundImageContainer.displayName = 'BackgroundImageContainer'; 52 | 53 | export const ContentContainer = styled.div` 54 | height: 90px; 55 | padding: 0 25px; 56 | display: flex; 57 | flex-direction: column; 58 | align-items: center; 59 | justify-content: center; 60 | border: 1px solid black; 61 | background-color: white; 62 | opacity: 0.7; 63 | position: absolute; 64 | `; 65 | 66 | export const ContentTitle = styled.span` 67 | font-weight: bold; 68 | margin-bottom: 6px; 69 | font-size: 22px; 70 | color: #4a4a4a; 71 | `; 72 | 73 | export const ContentSubtitle = styled.span` 74 | font-weight: lighter; 75 | font-size: 16px; 76 | `; 77 | -------------------------------------------------------------------------------- /client/src/components/menu-item/menu-item.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | 4 | import { MenuItem } from './menu-item.component'; 5 | 6 | describe('MenuItem component', () => { 7 | let wrapper; 8 | let mockMatch; 9 | let mockHistory; 10 | const linkUrl = '/hats'; 11 | const size = 'large'; 12 | const imageUrl = 'testimage'; 13 | 14 | beforeEach(() => { 15 | mockMatch = { 16 | url: '/shop' 17 | }; 18 | 19 | mockHistory = { 20 | push: jest.fn() 21 | }; 22 | 23 | const mockProps = { 24 | match: mockMatch, 25 | history: mockHistory, 26 | linkUrl, 27 | size, 28 | title: 'hats', 29 | imageUrl 30 | }; 31 | 32 | wrapper = shallow(); 33 | }); 34 | 35 | it('should render MenuItem component', () => { 36 | expect(wrapper).toMatchSnapshot(); 37 | }); 38 | 39 | it('should call history.push with the right string when MenuItemContainer clicked', () => { 40 | wrapper.find('MenuItemContainer').simulate('click'); 41 | 42 | expect(mockHistory.push).toHaveBeenCalledWith(`${mockMatch.url}${linkUrl}`); 43 | }); 44 | 45 | it('should pass size to MenuItemContainer as the prop size', () => { 46 | expect(wrapper.find('MenuItemContainer').prop('size')).toBe(size); 47 | }); 48 | 49 | it('should pass imageUrl to BackgroundImageContainer as the prop imageUrl', () => { 50 | expect(wrapper.find('BackgroundImageContainer').prop('imageUrl')).toBe( 51 | imageUrl 52 | ); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /client/src/components/sign-in/sign-in.component.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import FormInput from '../form-input/form-input.component'; 5 | import CustomButton from '../custom-button/custom-button.component'; 6 | 7 | import { 8 | googleSignInStart, 9 | emailSignInStart 10 | } from '../../redux/user/user.actions'; 11 | 12 | import { 13 | SignInContainer, 14 | SignInTitle, 15 | ButtonsBarContainer 16 | } from './sign-in.styles'; 17 | 18 | const SignIn = ({ emailSignInStart, googleSignInStart }) => { 19 | const [userCredentials, setCredentials] = useState({ 20 | email: '', 21 | password: '' 22 | }); 23 | 24 | const { email, password } = userCredentials; 25 | 26 | const handleSubmit = async event => { 27 | event.preventDefault(); 28 | 29 | emailSignInStart(email, password); 30 | }; 31 | 32 | const handleChange = event => { 33 | const { value, name } = event.target; 34 | 35 | setCredentials({ ...userCredentials, [name]: value }); 36 | }; 37 | 38 | return ( 39 | 40 | I already have an account 41 | Sign in with your email and password 42 | 43 |
44 | 52 | 60 | 61 | Sign in 62 | 67 | Sign in with Google 68 | 69 | 70 | 71 |
72 | ); 73 | }; 74 | 75 | const mapDispatchToProps = dispatch => ({ 76 | googleSignInStart: () => dispatch(googleSignInStart()), 77 | emailSignInStart: (email, password) => 78 | dispatch(emailSignInStart({ email, password })) 79 | }); 80 | 81 | export default connect( 82 | null, 83 | mapDispatchToProps 84 | )(SignIn); 85 | -------------------------------------------------------------------------------- /client/src/components/sign-in/sign-in.styles.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const SignInContainer = styled.div` 4 | width: 380px; 5 | display: flex; 6 | flex-direction: column; 7 | `; 8 | 9 | export const SignInTitle = styled.h2` 10 | margin: 10px 0; 11 | `; 12 | 13 | export const ButtonsBarContainer = styled.div` 14 | display: flex; 15 | justify-content: space-between; 16 | `; 17 | -------------------------------------------------------------------------------- /client/src/components/sign-up/sign-up.component.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import FormInput from '../form-input/form-input.component'; 5 | import CustomButton from '../custom-button/custom-button.component'; 6 | 7 | import { signUpStart } from '../../redux/user/user.actions'; 8 | 9 | import { SignUpContainer, SignUpTitle } from './sign-up.styles'; 10 | 11 | const SignUp = ({ signUpStart }) => { 12 | const [userCredentials, setUserCredentials] = useState({ 13 | displayName: '', 14 | email: '', 15 | password: '', 16 | confirmPassword: '' 17 | }); 18 | 19 | const { displayName, email, password, confirmPassword } = userCredentials; 20 | 21 | const handleSubmit = async event => { 22 | event.preventDefault(); 23 | 24 | if (password !== confirmPassword) { 25 | alert("passwords don't match"); 26 | return; 27 | } 28 | 29 | signUpStart({ displayName, email, password }); 30 | }; 31 | 32 | const handleChange = event => { 33 | const { name, value } = event.target; 34 | 35 | setUserCredentials({ ...userCredentials, [name]: value }); 36 | }; 37 | 38 | return ( 39 | 40 | I do not have a account 41 | Sign up with your email and password 42 |
43 | 51 | 59 | 67 | 75 | SIGN UP 76 | 77 |
78 | ); 79 | }; 80 | 81 | const mapDispatchToProps = dispatch => ({ 82 | signUpStart: userCredentials => dispatch(signUpStart(userCredentials)) 83 | }); 84 | 85 | export default connect( 86 | null, 87 | mapDispatchToProps 88 | )(SignUp); 89 | -------------------------------------------------------------------------------- /client/src/components/sign-up/sign-up.styles.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const SignUpContainer = styled.div` 4 | display: flex; 5 | flex-direction: column; 6 | width: 380px; 7 | `; 8 | 9 | export const SignUpTitle = styled.h2` 10 | margin: 10px 0; 11 | `; 12 | -------------------------------------------------------------------------------- /client/src/components/spinner/spinner.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { SpinnerContainer, SpinnerOverlay } from './spinner.styles'; 4 | 5 | const Spinner = () => ( 6 | 7 | 8 | 9 | ); 10 | 11 | export default Spinner; 12 | -------------------------------------------------------------------------------- /client/src/components/spinner/spinner.styles.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const SpinnerOverlay = styled.div` 4 | height: 60vh; 5 | width: 100%; 6 | display: flex; 7 | justify-content: center; 8 | align-items: center; 9 | `; 10 | 11 | export const SpinnerContainer = styled.div` 12 | display: inline-block; 13 | width: 50px; 14 | height: 50px; 15 | border: 3px solid rgba(195, 195, 195, 0.6); 16 | border-radius: 50%; 17 | border-top-color: #636767; 18 | animation: spin 1s ease-in-out infinite; 19 | -webkit-animation: spin 1s ease-in-out infinite; 20 | 21 | @keyframes spin { 22 | to { 23 | -webkit-transform: rotate(360deg); 24 | } 25 | } 26 | @-webkit-keyframes spin { 27 | to { 28 | -webkit-transform: rotate(360deg); 29 | } 30 | } 31 | `; 32 | -------------------------------------------------------------------------------- /client/src/components/stripe-button/stripe-button.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import StripeCheckout from 'react-stripe-checkout'; 3 | import axios from 'axios'; 4 | 5 | const StripeCheckoutButton = ({ price }) => { 6 | const priceForStripe = price * 100; 7 | const publishableKey = 'pk_test_b7a3hFL5nC3qlBCZ6bQACpez00gyMMP52H'; 8 | 9 | const onToken = token => { 10 | axios({ 11 | url: 'payment', 12 | method: 'post', 13 | data: { 14 | amount: priceForStripe, 15 | token: token 16 | } 17 | }) 18 | .then(response => { 19 | alert('succesful payment'); 20 | }) 21 | .catch(error => { 22 | console.log('Payment Error: ', JSON.parse(error)); 23 | alert( 24 | 'There was an issue with your payment! Please make sure you use the provided credit card.' 25 | ); 26 | }); 27 | }; 28 | 29 | return ( 30 | 42 | ); 43 | }; 44 | 45 | export default StripeCheckoutButton; 46 | -------------------------------------------------------------------------------- /client/src/components/with-spinner/with-spinner.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Spinner from '../spinner/spinner.component'; 4 | 5 | const WithSpinner = WrappedComponent => ({ isLoading, ...otherProps }) => { 6 | return isLoading ? : ; 7 | }; 8 | 9 | export default WithSpinner; 10 | -------------------------------------------------------------------------------- /client/src/components/with-spinner/with-spinner.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | 4 | import WithSpinner from './with-spinner.component'; 5 | import Spinner from '../spinner/spinner.component'; 6 | 7 | describe('WithSpinner HOC', () => { 8 | const TestComponent = () =>
; 9 | const WrappedComponent = WithSpinner(TestComponent); 10 | 11 | describe('if loading is true', () => { 12 | it('should render Spinner component', () => { 13 | const wrapper = shallow(); 14 | 15 | expect(wrapper.exists(Spinner)).toBe(true); 16 | }); 17 | 18 | it('should not render component', () => { 19 | const wrapper = shallow(); 20 | 21 | expect(wrapper.exists(TestComponent)).toBe(false); 22 | }); 23 | }); 24 | 25 | describe('if loading is false', () => { 26 | it('should render component', () => { 27 | const wrapper = shallow(); 28 | 29 | expect(wrapper.exists(TestComponent)).toBe(true); 30 | }); 31 | 32 | it('should not render Spinner', () => { 33 | const wrapper = shallow(); 34 | 35 | expect(wrapper.exists(Spinner)).toBe(false); 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /client/src/firebase/firebase.utils.js: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase/app'; 2 | import 'firebase/firestore'; 3 | import 'firebase/auth'; 4 | 5 | const config = { 6 | apiKey: 'AIzaSyCdHT-AYHXjF7wOrfAchX4PIm3cSj5tn14', 7 | authDomain: 'crwn-db.firebaseapp.com', 8 | databaseURL: 'https://crwn-db.firebaseio.com', 9 | projectId: 'crwn-db', 10 | storageBucket: 'crwn-db.appspot.com', 11 | messagingSenderId: '850995411664', 12 | appId: '1:850995411664:web:7ddc01d597846f65' 13 | }; 14 | 15 | firebase.initializeApp(config); 16 | 17 | export const createUserProfileDocument = async (userAuth, additionalData) => { 18 | if (!userAuth) return; 19 | 20 | const userRef = firestore.doc(`users/${userAuth.uid}`); 21 | 22 | const snapShot = await userRef.get(); 23 | 24 | if (!snapShot.exists) { 25 | const { displayName, email } = userAuth; 26 | const createdAt = new Date(); 27 | try { 28 | await userRef.set({ 29 | displayName, 30 | email, 31 | createdAt, 32 | ...additionalData 33 | }); 34 | } catch (error) { 35 | console.log('error creating user', error.message); 36 | } 37 | } 38 | 39 | return userRef; 40 | }; 41 | 42 | export const getUserCartRef = async userId => { 43 | const cartsRef = firestore.collection('carts').where('userId', '==', userId); 44 | const snapShot = await cartsRef.get(); 45 | 46 | if (snapShot.empty) { 47 | const cartDocRef = firestore.collection('carts').doc(); 48 | await cartDocRef.set({ userId, cartItems: [] }); 49 | return cartDocRef; 50 | } else { 51 | return snapShot.docs[0].ref; 52 | } 53 | }; 54 | 55 | export const addCollectionAndDocuments = async ( 56 | collectionKey, 57 | objectsToAdd 58 | ) => { 59 | const collectionRef = firestore.collection(collectionKey); 60 | 61 | const batch = firestore.batch(); 62 | objectsToAdd.forEach(obj => { 63 | const newDocRef = collectionRef.doc(); 64 | batch.set(newDocRef, obj); 65 | }); 66 | 67 | return await batch.commit(); 68 | }; 69 | 70 | export const convertCollectionsSnapshotToMap = collections => { 71 | const transformedCollection = collections.docs.map(doc => { 72 | const { title, items } = doc.data(); 73 | 74 | return { 75 | routeName: encodeURI(title.toLowerCase()), 76 | id: doc.id, 77 | title, 78 | items 79 | }; 80 | }); 81 | 82 | return transformedCollection.reduce((accumulator, collection) => { 83 | accumulator[collection.title.toLowerCase()] = collection; 84 | return accumulator; 85 | }, {}); 86 | }; 87 | 88 | export const getCurrentUser = () => { 89 | return new Promise((resolve, reject) => { 90 | const unsubscribe = auth.onAuthStateChanged(userAuth => { 91 | unsubscribe(); 92 | resolve(userAuth); 93 | }, reject); 94 | }); 95 | }; 96 | 97 | export const auth = firebase.auth(); 98 | export const firestore = firebase.firestore(); 99 | 100 | export const googleProvider = new firebase.auth.GoogleAuthProvider(); 101 | googleProvider.setCustomParameters({ prompt: 'select_account' }); 102 | export const signInWithGoogle = () => auth.signInWithPopup(googleProvider); 103 | 104 | export default firebase; 105 | -------------------------------------------------------------------------------- /client/src/global.styles.js: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from 'styled-components'; 2 | 3 | export const GlobalStyle = createGlobalStyle` 4 | body { 5 | font-family: 'Open Sans Condensed'; 6 | padding: 20px 40px; 7 | 8 | @media screen and (max-width: 800px) { 9 | padding: 10px; 10 | } 11 | } 12 | 13 | a { 14 | text-decoration: none; 15 | color: black; 16 | } 17 | 18 | * { 19 | box-sizing: border-box; 20 | } 21 | `; 22 | -------------------------------------------------------------------------------- /client/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 5 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 6 | sans-serif; 7 | -webkit-font-smoothing: antialiased; 8 | -moz-osx-font-smoothing: grayscale; 9 | } 10 | 11 | code { 12 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 13 | monospace; 14 | } 15 | -------------------------------------------------------------------------------- /client/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { BrowserRouter } from 'react-router-dom'; 4 | import { Provider } from 'react-redux'; 5 | import { PersistGate } from 'redux-persist/integration/react'; 6 | 7 | import { store, persistor } from './redux/store'; 8 | import * as serviceWorker from './serviceWorker'; 9 | 10 | import './index.css'; 11 | import App from './App'; 12 | 13 | ReactDOM.render( 14 | 15 | 16 | 17 | 18 | 19 | 20 | , 21 | document.getElementById('root') 22 | ); 23 | 24 | serviceWorker.register(); 25 | -------------------------------------------------------------------------------- /client/src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /client/src/pages/checkout/__snapshots__/checkout.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`should render CheckoutPage component 1`] = `ShallowWrapper {}`; 4 | -------------------------------------------------------------------------------- /client/src/pages/checkout/checkout.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { createStructuredSelector } from 'reselect'; 4 | 5 | import StripeCheckoutButton from '../../components/stripe-button/stripe-button.component'; 6 | import CheckoutItem from '../../components/checkout-item/checkout-item.component'; 7 | 8 | import { 9 | selectCartItems, 10 | selectCartTotal 11 | } from '../../redux/cart/cart.selectors'; 12 | 13 | import { 14 | CheckoutPageContainer, 15 | CheckoutHeaderContainer, 16 | HeaderBlockContainer, 17 | TotalContainer, 18 | WarningContainer 19 | } from './checkout.styles'; 20 | 21 | export const CheckoutPage = ({ cartItems, total }) => ( 22 | 23 | 24 | 25 | Product 26 | 27 | 28 | Description 29 | 30 | 31 | Quantity 32 | 33 | 34 | Price 35 | 36 | 37 | Remove 38 | 39 | 40 | {cartItems.map(cartItem => ( 41 | 42 | ))} 43 | TOTAL: ${total} 44 | 45 | *Please use the following test credit card for payments* 46 |
47 | 4242 4242 4242 4242 - Exp: 01/20 - CVV: 123 48 |
49 | 50 |
51 | ); 52 | 53 | const mapStateToProps = createStructuredSelector({ 54 | cartItems: selectCartItems, 55 | total: selectCartTotal 56 | }); 57 | 58 | export default connect(mapStateToProps)(CheckoutPage); 59 | -------------------------------------------------------------------------------- /client/src/pages/checkout/checkout.styles.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const CheckoutPageContainer = styled.div` 4 | width: 55%; 5 | min-height: 90vh; 6 | display: flex; 7 | flex-direction: column; 8 | align-items: center; 9 | margin: 50px auto 0; 10 | 11 | button { 12 | margin-left: auto; 13 | margin-top: 50px; 14 | } 15 | 16 | @media screen and (max-width: 800px) { 17 | width: 90%; 18 | } 19 | `; 20 | 21 | export const CheckoutHeaderContainer = styled.div` 22 | width: 100%; 23 | height: 40px; 24 | display: flex; 25 | justify-content: space-between; 26 | border-bottom: 1px solid darkgrey; 27 | `; 28 | 29 | export const HeaderBlockContainer = styled.div` 30 | text-transform: capitalize; 31 | width: 23%; 32 | 33 | &:last-child { 34 | width: 8%; 35 | } 36 | 37 | @media screen and (max-width: 800px) { 38 | width: 22% 39 | 40 | &:last-child { 41 | width: 12%; 42 | } 43 | } 44 | `; 45 | 46 | export const TotalContainer = styled.div` 47 | margin-top: 30px; 48 | margin-left: auto; 49 | font-size: 36px; 50 | `; 51 | 52 | export const WarningContainer = styled.div` 53 | text-align: center; 54 | margin-top: 40px; 55 | font-size: 24px; 56 | color: red; 57 | `; 58 | -------------------------------------------------------------------------------- /client/src/pages/checkout/checkout.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import { CheckoutPage } from './checkout.component'; 4 | 5 | let wrapper; 6 | beforeEach(() => { 7 | const mockProps = { 8 | cartItems: [], 9 | total: 100 10 | }; 11 | 12 | wrapper = shallow(); 13 | }); 14 | 15 | it('should render CheckoutPage component', () => { 16 | expect(wrapper).toMatchSnapshot(); 17 | }); 18 | -------------------------------------------------------------------------------- /client/src/pages/collection/__snapshots__/collection.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`CollectionPage should render the CollectionPage component 1`] = `ShallowWrapper {}`; 4 | -------------------------------------------------------------------------------- /client/src/pages/collection/collection.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | 4 | import CollectionItem from '../../components/collection-item/collection-item.component'; 5 | 6 | import { selectCollection } from '../../redux/shop/shop.selectors'; 7 | 8 | import { 9 | CollectionPageContainer, 10 | CollectionTitle, 11 | CollectionItemsContainer 12 | } from './collection.styles'; 13 | 14 | export const CollectionPage = ({ collection }) => { 15 | const { title, items } = collection; 16 | return ( 17 | 18 | {title} 19 | 20 | {items.map(item => ( 21 | 22 | ))} 23 | 24 | 25 | ); 26 | }; 27 | 28 | const mapStateToProps = (state, ownProps) => ({ 29 | collection: selectCollection(ownProps.match.params.collectionId)(state) 30 | }); 31 | 32 | export default connect(mapStateToProps)(CollectionPage); 33 | -------------------------------------------------------------------------------- /client/src/pages/collection/collection.container.jsx: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { compose } from 'redux'; 3 | import { createStructuredSelector } from 'reselect'; 4 | 5 | import { selectIsCollectionsLoaded } from '../../redux/shop/shop.selectors'; 6 | import WithSpinner from '../../components/with-spinner/with-spinner.component'; 7 | import CollectionPage from './collection.component'; 8 | 9 | const mapStateToProps = createStructuredSelector({ 10 | isLoading: state => !selectIsCollectionsLoaded(state) 11 | }); 12 | 13 | const CollectionPageContainer = compose( 14 | connect(mapStateToProps), 15 | WithSpinner 16 | )(CollectionPage); 17 | 18 | export default CollectionPageContainer; 19 | -------------------------------------------------------------------------------- /client/src/pages/collection/collection.styles.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const CollectionPageContainer = styled.div` 4 | display: flex; 5 | flex-direction: column; 6 | align-items: center; 7 | `; 8 | 9 | export const CollectionTitle = styled.h2` 10 | font-size: 38px; 11 | margin: 0 auto 30px; 12 | `; 13 | 14 | export const CollectionItemsContainer = styled.div` 15 | display: grid; 16 | grid-template-columns: 1fr 1fr 1fr 1fr; 17 | grid-gap: 10px; 18 | 19 | & > div { 20 | margin-bottom: 30px; 21 | } 22 | 23 | @media screen and (max-width: 800px) { 24 | grid-template-columns: 1fr 1fr; 25 | grid-gap: 15px; 26 | } 27 | `; 28 | 29 | CollectionItemsContainer.displayName = 'CollectionItemsContainer'; 30 | -------------------------------------------------------------------------------- /client/src/pages/collection/collection.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | 4 | import { CollectionPage } from './collection.component'; 5 | import CollectionItem from '../../components/collection-item/collection-item.component'; 6 | 7 | describe('CollectionPage', () => { 8 | let wrapper; 9 | let mockItems = [{ id: 1 }, { id: 2 }, { id: 3 }]; 10 | beforeEach(() => { 11 | const mockCollection = { 12 | items: mockItems, 13 | title: 'Test' 14 | }; 15 | 16 | wrapper = shallow(); 17 | }); 18 | 19 | it('should render the CollectionPage component', () => { 20 | expect(wrapper).toMatchSnapshot(); 21 | }); 22 | 23 | it('should render the same number of CollectionItems as collection array', () => { 24 | expect(wrapper.find(CollectionItem).length).toBe(mockItems.length); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /client/src/pages/homepage/__snapshots__/homepage.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`should render Homepage component 1`] = `ShallowWrapper {}`; 4 | -------------------------------------------------------------------------------- /client/src/pages/homepage/homepage.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Directory from '../../components/directory/directory.component'; 4 | 5 | import { HomePageContainer } from './homepage.styles'; 6 | 7 | const HomePage = () => ( 8 | 9 | 10 | 11 | ); 12 | 13 | export default HomePage; 14 | -------------------------------------------------------------------------------- /client/src/pages/homepage/homepage.styles.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const HomePageContainer = styled.div` 4 | display: flex; 5 | flex-direction: column; 6 | align-items: center; 7 | `; 8 | -------------------------------------------------------------------------------- /client/src/pages/homepage/homepage.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import Homepage from './homepage.component'; 4 | 5 | it('should render Homepage component', () => { 6 | expect(shallow()).toMatchSnapshot(); 7 | }); 8 | -------------------------------------------------------------------------------- /client/src/pages/shop/__snapshots__/shop.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`ShopPage should render ShopPage component 1`] = `ReactWrapper {}`; 4 | -------------------------------------------------------------------------------- /client/src/pages/shop/shop.component.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, lazy, Suspense } from 'react'; 2 | import { Route } from 'react-router-dom'; 3 | import { connect } from 'react-redux'; 4 | 5 | import { fetchCollectionsStart } from '../../redux/shop/shop.actions'; 6 | 7 | import Spinner from '../../components/spinner/spinner.component'; 8 | 9 | import { ShopPageContainer } from './shop.styles'; 10 | 11 | const CollectionsOverviewContainer = lazy(() => 12 | import('../../components/collections-overview/collections-overview.container') 13 | ); 14 | 15 | const CollectionPageContainer = lazy(() => 16 | import('../collection/collection.container') 17 | ); 18 | 19 | export const ShopPage = ({ fetchCollectionsStart, match }) => { 20 | useEffect(() => { 21 | fetchCollectionsStart(); 22 | }, [fetchCollectionsStart]); 23 | 24 | return ( 25 | 26 | }> 27 | 32 | 36 | 37 | 38 | ); 39 | }; 40 | 41 | const mapDispatchToProps = dispatch => ({ 42 | fetchCollectionsStart: () => dispatch(fetchCollectionsStart()) 43 | }); 44 | 45 | export default connect( 46 | null, 47 | mapDispatchToProps 48 | )(ShopPage); 49 | -------------------------------------------------------------------------------- /client/src/pages/shop/shop.styles.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const ShopPageContainer = styled.div` 4 | width: 100%; 5 | `; 6 | -------------------------------------------------------------------------------- /client/src/pages/shop/shop.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mount } from 'enzyme'; 3 | import { combineReducers, createStore } from 'redux'; 4 | import { BrowserRouter } from 'react-router-dom'; 5 | import { Provider } from 'react-redux'; 6 | 7 | import { ShopPage } from './shop.component'; 8 | 9 | export const createMockStore = ({ state, reducers }) => { 10 | const store = createStore(combineReducers(reducers), state); 11 | return { 12 | ...store, 13 | persistor: { 14 | persist: () => null 15 | } 16 | }; 17 | }; 18 | 19 | describe('ShopPage', () => { 20 | let wrapper; 21 | let mockFetchCollectionsStart; 22 | let store; 23 | 24 | beforeEach(() => { 25 | const mockReducer = ( 26 | state = { 27 | isFetching: true 28 | }, 29 | action 30 | ) => state; 31 | 32 | const mockState = { 33 | shop: { 34 | isFetching: true 35 | } 36 | }; 37 | 38 | mockFetchCollectionsStart = jest.fn(); 39 | 40 | store = createMockStore({ 41 | state: mockState, 42 | reducers: { shop: mockReducer } 43 | }); 44 | 45 | const mockMatch = { 46 | path: '' 47 | }; 48 | 49 | const mockProps = { 50 | match: mockMatch, 51 | fetchCollectionsStart: mockFetchCollectionsStart 52 | }; 53 | 54 | wrapper = mount( 55 | 56 | 57 | 58 | 59 | 60 | ); 61 | }); 62 | 63 | it('should render ShopPage component', () => { 64 | expect(wrapper).toMatchSnapshot(); 65 | }); 66 | 67 | it('should render ShopPage component', () => { 68 | expect(mockFetchCollectionsStart).toHaveBeenCalled(); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /client/src/pages/sign-in-and-sign-up/__snapshots__/sign-in-and-sign-up.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`should render SignInAndSignUpPage component 1`] = `ShallowWrapper {}`; 4 | -------------------------------------------------------------------------------- /client/src/pages/sign-in-and-sign-up/sign-in-and-sign-up.component.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import SignIn from '../../components/sign-in/sign-in.component'; 4 | import SignUp from '../../components/sign-up/sign-up.component'; 5 | 6 | import { SignInAndSignUpContainer } from './sign-in-and-sign-up.styles'; 7 | 8 | const SignInAndSignUpPage = () => ( 9 | 10 | 11 | 12 | 13 | ); 14 | 15 | export default SignInAndSignUpPage; 16 | -------------------------------------------------------------------------------- /client/src/pages/sign-in-and-sign-up/sign-in-and-sign-up.styles.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const SignInAndSignUpContainer = styled.div` 4 | width: 850px; 5 | display: flex; 6 | justify-content: space-between; 7 | margin: 30px auto; 8 | 9 | @media screen and (max-width: 800px) { 10 | flex-direction: column; 11 | width: unset; 12 | align-items: center; 13 | 14 | > *:first-child { 15 | margin-bottom: 50px; 16 | } 17 | } 18 | `; 19 | -------------------------------------------------------------------------------- /client/src/pages/sign-in-and-sign-up/sign-in-and-sign-up.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme'; 3 | import SignInAndSignUpPage from './sign-in-and-sign-up.component'; 4 | 5 | it('should render SignInAndSignUpPage component', () => { 6 | expect(shallow()).toMatchSnapshot(); 7 | }); 8 | -------------------------------------------------------------------------------- /client/src/redux/cart/cart.actions.js: -------------------------------------------------------------------------------- 1 | import CartActionTypes from './cart.types'; 2 | 3 | export const toggleCartHidden = () => ({ 4 | type: CartActionTypes.TOGGLE_CART_HIDDEN 5 | }); 6 | 7 | export const addItem = item => ({ 8 | type: CartActionTypes.ADD_ITEM, 9 | payload: item 10 | }); 11 | 12 | export const removeItem = item => ({ 13 | type: CartActionTypes.REMOVE_ITEM, 14 | payload: item 15 | }); 16 | 17 | export const clearItemFromCart = item => ({ 18 | type: CartActionTypes.CLEAR_ITEM_FROM_CART, 19 | payload: item 20 | }); 21 | 22 | export const clearCart = () => ({ 23 | type: CartActionTypes.CLEAR_CART 24 | }); 25 | 26 | export const updateCartInFirebase = () => ({ 27 | type: CartActionTypes.UPDATE_CART_IN_FIREBASE 28 | }); 29 | 30 | export const setCartFromFirebase = cartItems => ({ 31 | type: CartActionTypes.SET_CART_FROM_FIREBASE, 32 | payload: cartItems 33 | }); 34 | -------------------------------------------------------------------------------- /client/src/redux/cart/cart.actions.test.js: -------------------------------------------------------------------------------- 1 | import CartActionTypes from './cart.types'; 2 | import { 3 | toggleCartHidden, 4 | addItem, 5 | removeItem, 6 | clearItemFromCart, 7 | clearCart 8 | } from './cart.actions'; 9 | 10 | describe('toggleCartHidden action', () => { 11 | it('should create the toggleHidden action', () => { 12 | expect(toggleCartHidden().type).toEqual(CartActionTypes.TOGGLE_CART_HIDDEN); 13 | }); 14 | }); 15 | 16 | describe('addItem action', () => { 17 | it('should create the addItem action', () => { 18 | const mockItem = { 19 | id: 1 20 | }; 21 | 22 | const action = addItem(mockItem); 23 | 24 | expect(action.type).toEqual(CartActionTypes.ADD_ITEM); 25 | expect(action.payload).toEqual(mockItem); 26 | }); 27 | }); 28 | 29 | describe('removeItem action', () => { 30 | it('should create the removeItem action', () => { 31 | const mockItem = { 32 | id: 1 33 | }; 34 | 35 | const action = removeItem(mockItem); 36 | 37 | expect(action.type).toEqual(CartActionTypes.REMOVE_ITEM); 38 | expect(action.payload).toEqual(mockItem); 39 | }); 40 | }); 41 | 42 | describe('clearItemFromCart action', () => { 43 | it('should create the clearItemFromCart action', () => { 44 | const mockItem = { 45 | id: 1 46 | }; 47 | 48 | const action = clearItemFromCart(mockItem); 49 | 50 | expect(action.type).toEqual(CartActionTypes.CLEAR_ITEM_FROM_CART); 51 | expect(action.payload).toEqual(mockItem); 52 | }); 53 | }); 54 | 55 | describe('clearCart action', () => { 56 | it('should create the clearCart action', () => { 57 | expect(clearCart().type).toEqual(CartActionTypes.CLEAR_CART); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /client/src/redux/cart/cart.reducer.js: -------------------------------------------------------------------------------- 1 | import CartActionTypes from './cart.types'; 2 | import { addItemToCart, removeItemFromCart } from './cart.utils'; 3 | 4 | const INITIAL_STATE = { 5 | hidden: true, 6 | cartItems: [] 7 | }; 8 | 9 | const cartReducer = (state = INITIAL_STATE, action) => { 10 | switch (action.type) { 11 | case CartActionTypes.TOGGLE_CART_HIDDEN: 12 | return { 13 | ...state, 14 | hidden: !state.hidden 15 | }; 16 | case CartActionTypes.ADD_ITEM: 17 | return { 18 | ...state, 19 | cartItems: addItemToCart(state.cartItems, action.payload) 20 | }; 21 | case CartActionTypes.REMOVE_ITEM: 22 | return { 23 | ...state, 24 | cartItems: removeItemFromCart(state.cartItems, action.payload) 25 | }; 26 | case CartActionTypes.CLEAR_ITEM_FROM_CART: 27 | return { 28 | ...state, 29 | cartItems: state.cartItems.filter( 30 | cartItem => cartItem.id !== action.payload.id 31 | ) 32 | }; 33 | case CartActionTypes.CLEAR_CART: 34 | return { 35 | ...state, 36 | cartItems: [] 37 | }; 38 | case CartActionTypes.SET_CART_FROM_FIREBASE: 39 | return { 40 | ...state, 41 | cartItems: action.payload 42 | }; 43 | default: 44 | return state; 45 | } 46 | }; 47 | 48 | export default cartReducer; 49 | -------------------------------------------------------------------------------- /client/src/redux/cart/cart.reducer.test.js: -------------------------------------------------------------------------------- 1 | import CartActionTypes from './cart.types'; 2 | import cartReducer from './cart.reducer'; 3 | 4 | const initialState = { 5 | hidden: true, 6 | cartItems: [] 7 | }; 8 | 9 | describe('cartReducer', () => { 10 | it('should return initial state', () => { 11 | expect(cartReducer(undefined, {})).toEqual(initialState); 12 | }); 13 | 14 | it('should toggle hidden with toggleHidden action', () => { 15 | expect( 16 | cartReducer(initialState, { type: CartActionTypes.TOGGLE_CART_HIDDEN }) 17 | .hidden 18 | ).toBe(false); 19 | }); 20 | 21 | it('should increase quantity of matching item by 1 if addItem action fired with same item as payload', () => { 22 | const mockItem = { 23 | id: 1, 24 | quantity: 3 25 | }; 26 | 27 | const mockPrevState = { 28 | hidden: true, 29 | cartItems: [mockItem, { id: 2, quantity: 1 }] 30 | }; 31 | 32 | expect( 33 | cartReducer(mockPrevState, { 34 | type: CartActionTypes.ADD_ITEM, 35 | payload: mockItem 36 | }).cartItems[0].quantity 37 | ).toBe(4); 38 | }); 39 | 40 | it('should decrease quantity of matching item by 1 if removeItem action fired with same item as payload', () => { 41 | const mockItem = { 42 | id: 1, 43 | quantity: 3 44 | }; 45 | 46 | const mockPrevState = { 47 | hidden: true, 48 | cartItems: [mockItem, { id: 2, quantity: 1 }] 49 | }; 50 | 51 | expect( 52 | cartReducer(mockPrevState, { 53 | type: CartActionTypes.REMOVE_ITEM, 54 | payload: mockItem 55 | }).cartItems[0].quantity 56 | ).toBe(2); 57 | }); 58 | 59 | it('should remove item from cart if clearItemFromCart action fired with payload of existing item', () => { 60 | const mockItem = { 61 | id: 1, 62 | quantity: 3 63 | }; 64 | 65 | const mockPrevState = { 66 | hidden: true, 67 | cartItems: [mockItem, { id: 2, quantity: 1 }] 68 | }; 69 | 70 | expect( 71 | cartReducer(mockPrevState, { 72 | type: CartActionTypes.CLEAR_ITEM_FROM_CART, 73 | payload: mockItem 74 | }).cartItems.includes(item => item.id === 1) 75 | ).toBe(false); 76 | }); 77 | 78 | it('should clear cart if clearCart action fired', () => { 79 | const mockPrevState = { 80 | hidden: true, 81 | cartItems: [{ id: 1, quantity: 3 }, { id: 2, quantity: 1 }] 82 | }; 83 | 84 | expect( 85 | cartReducer(mockPrevState, { 86 | type: CartActionTypes.CLEAR_CART 87 | }).cartItems.length 88 | ).toBe(0); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /client/src/redux/cart/cart.sagas.js: -------------------------------------------------------------------------------- 1 | import { all, call, takeLatest, put, select } from 'redux-saga/effects'; 2 | 3 | import { getUserCartRef } from '../../firebase/firebase.utils'; 4 | import UserActionTypes from '../user/user.types'; 5 | import { selectCurrentUser } from '../user/user.selectors'; 6 | import { clearCart, setCartFromFirebase } from './cart.actions'; 7 | import { selectCartItems } from './cart.selectors'; 8 | import CartActionTypes from './cart.types'; 9 | 10 | export function* clearCartOnSignOut() { 11 | yield put(clearCart()); 12 | } 13 | 14 | export function* updateCartInFirebase() { 15 | const currentUser = yield select(selectCurrentUser); 16 | if (currentUser) { 17 | try { 18 | const cartRef = yield getUserCartRef(currentUser.id); 19 | const cartItems = yield select(selectCartItems); 20 | yield cartRef.update({ cartItems }); 21 | } catch (error) { 22 | console.log(error); 23 | } 24 | } 25 | } 26 | 27 | export function* checkCartFromFirebase({ payload: user }) { 28 | const cartRef = yield getUserCartRef(user.id); 29 | const cartSnapshot = yield cartRef.get(); 30 | yield put(setCartFromFirebase(cartSnapshot.data().cartItems)); 31 | } 32 | 33 | export function* onSignOutSuccess() { 34 | yield takeLatest(UserActionTypes.SIGN_OUT_SUCCESS, clearCartOnSignOut); 35 | } 36 | 37 | export function* onUserSignIn() { 38 | yield takeLatest(UserActionTypes.SIGN_IN_SUCCESS, checkCartFromFirebase); 39 | } 40 | 41 | export function* onCartChange() { 42 | yield takeLatest( 43 | [ 44 | CartActionTypes.ADD_ITEM, 45 | CartActionTypes.REMOVE_ITEM, 46 | CartActionTypes.CLEAR_ITEM_FROM_CART 47 | ], 48 | updateCartInFirebase 49 | ); 50 | } 51 | 52 | export function* cartSagas() { 53 | yield all([call(onSignOutSuccess), call(onCartChange), call(onUserSignIn)]); 54 | } 55 | -------------------------------------------------------------------------------- /client/src/redux/cart/cart.sagas.test.js: -------------------------------------------------------------------------------- 1 | import { takeLatest, put } from 'redux-saga/effects'; 2 | 3 | import UserActionTypes from '../user/user.types'; 4 | import { clearCart } from './cart.actions'; 5 | import { clearCartOnSignOut, onSignOutSuccess } from './cart.sagas'; 6 | 7 | describe('on signout success saga', () => { 8 | it('should trigger on SIGN_OUT_SUCCESS', async () => { 9 | const generator = onSignOutSuccess(); 10 | expect(generator.next().value).toEqual( 11 | takeLatest(UserActionTypes.SIGN_OUT_SUCCESS, clearCartOnSignOut) 12 | ); 13 | }); 14 | }); 15 | 16 | describe('clear cart on signout saga', () => { 17 | it('should fire clearCart', () => { 18 | const generator = clearCartOnSignOut(); 19 | expect(generator.next().value).toEqual(put(clearCart())); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /client/src/redux/cart/cart.selectors.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | 3 | const selectCart = state => state.cart; 4 | 5 | export const selectCartItems = createSelector( 6 | [selectCart], 7 | cart => cart.cartItems 8 | ); 9 | 10 | export const selectCartHidden = createSelector( 11 | [selectCart], 12 | cart => cart.hidden 13 | ); 14 | 15 | export const selectCartItemsCount = createSelector( 16 | [selectCartItems], 17 | cartItems => 18 | cartItems.reduce( 19 | (accumalatedQuantity, cartItem) => 20 | accumalatedQuantity + cartItem.quantity, 21 | 0 22 | ) 23 | ); 24 | 25 | export const selectCartTotal = createSelector( 26 | [selectCartItems], 27 | cartItems => 28 | cartItems.reduce( 29 | (accumalatedQuantity, cartItem) => 30 | accumalatedQuantity + cartItem.quantity * cartItem.price, 31 | 0 32 | ) 33 | ); 34 | -------------------------------------------------------------------------------- /client/src/redux/cart/cart.types.js: -------------------------------------------------------------------------------- 1 | const CartActionTypes = { 2 | TOGGLE_CART_HIDDEN: 'TOGGLE_CART_HIDDEN', 3 | ADD_ITEM: 'ADD_ITEM', 4 | REMOVE_ITEM: 'REMOVE_ITEM', 5 | CLEAR_ITEM_FROM_CART: 'CLEAR_ITEM_FROM_CART', 6 | CLEAR_CART: 'CLEAR_CART', 7 | SET_CART_FROM_FIREBASE: 'SET_CART_FROM_FIREBASE', 8 | UPDATE_CART_IN_FIREBASE: 'UPDATE_CART_IN_FIREBASE' 9 | }; 10 | 11 | export default CartActionTypes; 12 | -------------------------------------------------------------------------------- /client/src/redux/cart/cart.utils.js: -------------------------------------------------------------------------------- 1 | export const addItemToCart = (cartItems, cartItemToAdd) => { 2 | const existingCartItem = cartItems.find( 3 | cartItem => cartItem.id === cartItemToAdd.id 4 | ); 5 | 6 | if (existingCartItem) { 7 | return cartItems.map(cartItem => 8 | cartItem.id === cartItemToAdd.id 9 | ? { ...cartItem, quantity: cartItem.quantity + 1 } 10 | : cartItem 11 | ); 12 | } 13 | 14 | return [...cartItems, { ...cartItemToAdd, quantity: 1 }]; 15 | }; 16 | 17 | export const removeItemFromCart = (cartItems, cartItemToRemove) => { 18 | const existingCartItem = cartItems.find( 19 | cartItem => cartItem.id === cartItemToRemove.id 20 | ); 21 | 22 | if (existingCartItem.quantity === 1) { 23 | return cartItems.filter(cartItem => cartItem.id !== cartItemToRemove.id); 24 | } 25 | 26 | return cartItems.map(cartItem => 27 | cartItem.id === cartItemToRemove.id 28 | ? { ...cartItem, quantity: cartItem.quantity - 1 } 29 | : cartItem 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /client/src/redux/directory/directory.reducer.js: -------------------------------------------------------------------------------- 1 | export const INITIAL_STATE = { 2 | sections: [ 3 | { 4 | title: 'hats', 5 | imageUrl: 'https://i.ibb.co/cvpntL1/hats.png', 6 | id: 1, 7 | linkUrl: 'shop/hats' 8 | }, 9 | { 10 | title: 'jackets', 11 | imageUrl: 'https://i.ibb.co/px2tCc3/jackets.png', 12 | id: 2, 13 | linkUrl: 'shop/jackets' 14 | }, 15 | { 16 | title: 'sneakers', 17 | imageUrl: 'https://i.ibb.co/0jqHpnp/sneakers.png', 18 | id: 3, 19 | linkUrl: 'shop/sneakers' 20 | }, 21 | { 22 | title: 'womens', 23 | imageUrl: 'https://i.ibb.co/GCCdy8t/womens.png', 24 | size: 'large', 25 | id: 4, 26 | linkUrl: 'shop/womens' 27 | }, 28 | { 29 | title: 'mens', 30 | imageUrl: 'https://i.ibb.co/R70vBrQ/men.png', 31 | size: 'large', 32 | id: 5, 33 | linkUrl: 'shop/mens' 34 | } 35 | ] 36 | }; 37 | 38 | const directoryReducer = (state = INITIAL_STATE, action) => { 39 | switch (action.type) { 40 | default: 41 | return state; 42 | } 43 | }; 44 | 45 | export default directoryReducer; 46 | -------------------------------------------------------------------------------- /client/src/redux/directory/directory.reducer.test.js: -------------------------------------------------------------------------------- 1 | import directoryReducer, { INITIAL_STATE } from './directory.reducer'; 2 | 3 | describe('directoryReducer', () => { 4 | it('should return initial state', () => { 5 | expect(directoryReducer(undefined, {})).toEqual(INITIAL_STATE); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /client/src/redux/directory/directory.selectors.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | 3 | const selectDirectory = state => state.directory; 4 | 5 | export const selectDirectorySections = createSelector( 6 | [selectDirectory], 7 | directory => directory.sections 8 | ); 9 | -------------------------------------------------------------------------------- /client/src/redux/root-reducer.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import { persistReducer } from 'redux-persist'; 3 | import storage from 'redux-persist/lib/storage'; 4 | 5 | import userReducer from './user/user.reducer'; 6 | import cartReducer from './cart/cart.reducer'; 7 | import directoryReducer from './directory/directory.reducer'; 8 | import shopReducer from './shop/shop.reducer'; 9 | 10 | const persistConfig = { 11 | key: 'root', 12 | storage, 13 | whitelist: ['cart'] 14 | }; 15 | 16 | const rootReducer = combineReducers({ 17 | user: userReducer, 18 | cart: cartReducer, 19 | directory: directoryReducer, 20 | shop: shopReducer 21 | }); 22 | 23 | export default persistReducer(persistConfig, rootReducer); 24 | -------------------------------------------------------------------------------- /client/src/redux/root-saga.js: -------------------------------------------------------------------------------- 1 | import { all, call } from 'redux-saga/effects'; 2 | 3 | import { shopSagas } from './shop/shop.sagas'; 4 | import { userSagas } from './user/user.sagas'; 5 | import { cartSagas } from './cart/cart.sagas'; 6 | 7 | export default function* rootSaga() { 8 | yield all([call(shopSagas), call(userSagas), call(cartSagas)]); 9 | } 10 | -------------------------------------------------------------------------------- /client/src/redux/saga-testing.utils.js: -------------------------------------------------------------------------------- 1 | import { runSaga } from 'redux-saga'; 2 | 3 | export async function recordSaga(saga, initialAction) { 4 | const dispatched = []; 5 | 6 | await runSaga( 7 | { 8 | dispatch: action => dispatched.push(action) 9 | }, 10 | saga, 11 | initialAction 12 | ).done; 13 | 14 | return dispatched; 15 | } 16 | -------------------------------------------------------------------------------- /client/src/redux/shop/shop.actions.js: -------------------------------------------------------------------------------- 1 | import ShopActionTypes from './shop.types'; 2 | 3 | import { 4 | firestore, 5 | convertCollectionsSnapshotToMap 6 | } from '../../firebase/firebase.utils'; 7 | 8 | export const fetchCollectionsStart = () => ({ 9 | type: ShopActionTypes.FETCH_COLLECTIONS_START 10 | }); 11 | 12 | export const fetchCollectionsSuccess = collectionsMap => ({ 13 | type: ShopActionTypes.FETCH_COLLECTIONS_SUCCESS, 14 | payload: collectionsMap 15 | }); 16 | 17 | export const fetchCollectionsFailure = errorMessage => ({ 18 | type: ShopActionTypes.FETCH_COLLECTIONS_FAILURE, 19 | payload: errorMessage 20 | }); 21 | 22 | export const fetchCollectionsStartAsync = () => { 23 | return dispatch => { 24 | const collectionRef = firestore.collection('collections'); 25 | dispatch(fetchCollectionsStart()); 26 | 27 | collectionRef 28 | .get() 29 | .then(snapshot => { 30 | const collectionsMap = convertCollectionsSnapshotToMap(snapshot); 31 | dispatch(fetchCollectionsSuccess(collectionsMap)); 32 | }) 33 | .catch(error => dispatch(fetchCollectionsFailure(error.message))); 34 | }; 35 | }; 36 | -------------------------------------------------------------------------------- /client/src/redux/shop/shop.actions.test.js: -------------------------------------------------------------------------------- 1 | import ShopActionTypes from './shop.types'; 2 | import { 3 | fetchCollectionsStart, 4 | fetchCollectionsSuccess, 5 | fetchCollectionsFailure, 6 | fetchCollectionsStartAsync 7 | } from './shop.actions'; 8 | 9 | describe('fetchCollectionsStart action', () => { 10 | it('should create the fetchCollectionsStart action', () => { 11 | expect(fetchCollectionsStart().type).toEqual( 12 | ShopActionTypes.FETCH_COLLECTIONS_START 13 | ); 14 | }); 15 | }); 16 | 17 | describe('fetchCollectionsSuccess action', () => { 18 | it('should create the fetchCollectionsSuccess action', () => { 19 | const mockCollectionsMap = { 20 | hats: { 21 | id: 1 22 | } 23 | }; 24 | 25 | const action = fetchCollectionsSuccess(mockCollectionsMap); 26 | 27 | expect(action.type).toEqual(ShopActionTypes.FETCH_COLLECTIONS_SUCCESS); 28 | expect(action.payload).toEqual(mockCollectionsMap); 29 | }); 30 | }); 31 | 32 | describe('fetchCollectionsFailure action', () => { 33 | it('should create the fetchCollectionsFailure action', () => { 34 | const action = fetchCollectionsFailure('errored'); 35 | 36 | expect(action.type).toEqual(ShopActionTypes.FETCH_COLLECTIONS_FAILURE); 37 | expect(action.payload).toEqual('errored'); 38 | }); 39 | }); 40 | 41 | describe('fetchCollectionsStartAsync action', () => { 42 | it('should create the fetchCollectionsStartAsync action', () => { 43 | const mockActionCreator = fetchCollectionsStartAsync(); 44 | const mockDispatch = jest.fn(); 45 | mockActionCreator(mockDispatch); 46 | 47 | expect(mockDispatch).toHaveBeenCalledWith(fetchCollectionsStart()); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /client/src/redux/shop/shop.reducer.js: -------------------------------------------------------------------------------- 1 | import ShopActionTypes from './shop.types'; 2 | 3 | const INITIAL_STATE = { 4 | collections: null, 5 | isFetching: false, 6 | errorMessage: undefined 7 | }; 8 | 9 | const shopReducer = (state = INITIAL_STATE, action) => { 10 | switch (action.type) { 11 | case ShopActionTypes.FETCH_COLLECTIONS_START: 12 | return { 13 | ...state, 14 | isFetching: true 15 | }; 16 | case ShopActionTypes.FETCH_COLLECTIONS_SUCCESS: 17 | return { 18 | ...state, 19 | isFetching: false, 20 | collections: action.payload 21 | }; 22 | case ShopActionTypes.FETCH_COLLECTIONS_FAILURE: 23 | return { 24 | ...state, 25 | isFetching: false, 26 | errorMessage: action.payload 27 | }; 28 | default: 29 | return state; 30 | } 31 | }; 32 | 33 | export default shopReducer; 34 | -------------------------------------------------------------------------------- /client/src/redux/shop/shop.reducer.test.js: -------------------------------------------------------------------------------- 1 | import ShopActionTypes from './shop.types'; 2 | import shopReducer from './shop.reducer'; 3 | 4 | const initialState = { 5 | collections: null, 6 | isFetching: false, 7 | errorMessage: undefined 8 | }; 9 | 10 | describe('shopReducer', () => { 11 | it('should return initial state', () => { 12 | expect(shopReducer(undefined, {})).toEqual(initialState); 13 | }); 14 | 15 | it('should set isFetching to true if fetchingCollectionsStart action', () => { 16 | expect( 17 | shopReducer(initialState, { 18 | type: ShopActionTypes.FETCH_COLLECTIONS_START 19 | }).isFetching 20 | ).toBe(true); 21 | }); 22 | 23 | it('should set isFetching to false and collections to payload if fetchingCollectionsSuccess', () => { 24 | const mockItems = [{ id: 1 }, { id: 2 }]; 25 | expect( 26 | shopReducer(initialState, { 27 | type: ShopActionTypes.FETCH_COLLECTIONS_SUCCESS, 28 | payload: mockItems 29 | }) 30 | ).toEqual({ 31 | ...initialState, 32 | isFetching: false, 33 | collections: mockItems 34 | }); 35 | }); 36 | 37 | it('should set isFetching to false and errorMessage to payload if fetchingCollectionsFailure', () => { 38 | expect( 39 | shopReducer(initialState, { 40 | type: ShopActionTypes.FETCH_COLLECTIONS_FAILURE, 41 | payload: 'error' 42 | }) 43 | ).toEqual({ 44 | ...initialState, 45 | isFetching: false, 46 | errorMessage: 'error' 47 | }); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /client/src/redux/shop/shop.sagas.js: -------------------------------------------------------------------------------- 1 | import { takeLatest, call, put, all } from 'redux-saga/effects'; 2 | 3 | import { 4 | firestore, 5 | convertCollectionsSnapshotToMap 6 | } from '../../firebase/firebase.utils'; 7 | 8 | import { 9 | fetchCollectionsSuccess, 10 | fetchCollectionsFailure 11 | } from './shop.actions'; 12 | 13 | import ShopActionTypes from './shop.types'; 14 | 15 | export function* fetchCollectionsAsync() { 16 | try { 17 | const collectionRef = firestore.collection('collections'); 18 | const snapshot = yield collectionRef.get(); 19 | const collectionsMap = yield call( 20 | convertCollectionsSnapshotToMap, 21 | snapshot 22 | ); 23 | yield put(fetchCollectionsSuccess(collectionsMap)); 24 | } catch (error) { 25 | yield put(fetchCollectionsFailure(error.message)); 26 | } 27 | } 28 | 29 | export function* fetchCollectionsStart() { 30 | yield takeLatest( 31 | ShopActionTypes.FETCH_COLLECTIONS_START, 32 | fetchCollectionsAsync 33 | ); 34 | } 35 | 36 | export function* shopSagas() { 37 | yield all([call(fetchCollectionsStart)]); 38 | } 39 | -------------------------------------------------------------------------------- /client/src/redux/shop/shop.sagas.test.js: -------------------------------------------------------------------------------- 1 | import { takeLatest, call, put } from 'redux-saga/effects'; 2 | 3 | import { 4 | firestore, 5 | convertCollectionsSnapshotToMap 6 | } from '../../firebase/firebase.utils'; 7 | 8 | import { 9 | fetchCollectionsSuccess, 10 | fetchCollectionsFailure 11 | } from './shop.actions'; 12 | 13 | import ShopActionTypes from './shop.types'; 14 | 15 | import { fetchCollectionsAsync, fetchCollectionsStart } from './shop.sagas'; 16 | 17 | describe('fetch collections start saga', () => { 18 | it('should trigger on FETCH_COLLECTIONS_START', () => { 19 | const generator = fetchCollectionsStart(); 20 | expect(generator.next().value).toEqual( 21 | takeLatest(ShopActionTypes.FETCH_COLLECTIONS_START, fetchCollectionsAsync) 22 | ); 23 | }); 24 | }); 25 | 26 | describe('fetch collections async saga', () => { 27 | const generator = fetchCollectionsAsync(); 28 | 29 | it('should call firestore collection ', () => { 30 | const getCollection = jest.spyOn(firestore, 'collection'); 31 | generator.next(); 32 | expect(getCollection).toHaveBeenCalled(); 33 | }); 34 | 35 | it('should call convertCollectionsSnapshot saga ', () => { 36 | const mockSnapshot = {}; 37 | expect(generator.next(mockSnapshot).value).toEqual( 38 | call(convertCollectionsSnapshotToMap, mockSnapshot) 39 | ); 40 | }); 41 | 42 | it('should fire fetchCollectionsSuccess if collectionsMap is succesful', () => { 43 | const mockCollectionsMap = { 44 | hats: { id: 1 } 45 | }; 46 | 47 | expect(generator.next(mockCollectionsMap).value).toEqual( 48 | put(fetchCollectionsSuccess(mockCollectionsMap)) 49 | ); 50 | }); 51 | 52 | it('should fire fetchCollectionsFailure if get collection fails at any point', () => { 53 | const newGenerator = fetchCollectionsAsync(); 54 | newGenerator.next(); 55 | expect(newGenerator.throw({ message: 'error' }).value).toEqual( 56 | put(fetchCollectionsFailure('error')) 57 | ); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /client/src/redux/shop/shop.selectors.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | 3 | const selectShop = state => state.shop; 4 | 5 | export const selectCollections = createSelector( 6 | [selectShop], 7 | shop => shop.collections 8 | ); 9 | 10 | export const selectCollectionsForPreview = createSelector( 11 | [selectCollections], 12 | collections => 13 | collections ? Object.keys(collections).map(key => collections[key]) : [] 14 | ); 15 | 16 | export const selectCollection = collectionUrlParam => 17 | createSelector( 18 | [selectCollections], 19 | collections => (collections ? collections[collectionUrlParam] : null) 20 | ); 21 | 22 | export const selectIsCollectionFetching = createSelector( 23 | [selectShop], 24 | shop => shop.isFetching 25 | ); 26 | 27 | export const selectIsCollectionsLoaded = createSelector( 28 | [selectShop], 29 | shop => !!shop.collections 30 | ); 31 | -------------------------------------------------------------------------------- /client/src/redux/shop/shop.types.js: -------------------------------------------------------------------------------- 1 | const ShopActionTypes = { 2 | FETCH_COLLECTIONS_START: 'FETCH_COLLECTIONS_START', 3 | FETCH_COLLECTIONS_SUCCESS: 'FETCH_COLLECTIONS_SUCCESS', 4 | FETCH_COLLECTIONS_FAILURE: 'FETCH_COLLECTIONS_FAILURE' 5 | }; 6 | 7 | export default ShopActionTypes; 8 | -------------------------------------------------------------------------------- /client/src/redux/store.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from 'redux'; 2 | import { persistStore } from 'redux-persist'; 3 | import logger from 'redux-logger'; 4 | import createSagaMiddleware from 'redux-saga'; 5 | 6 | import rootReducer from './root-reducer'; 7 | import rootSaga from './root-saga'; 8 | 9 | const sagaMiddleware = createSagaMiddleware(); 10 | 11 | const middlewares = [sagaMiddleware]; 12 | 13 | if (process.env.NODE_ENV === 'development') { 14 | middlewares.push(logger); 15 | } 16 | 17 | export const store = createStore(rootReducer, applyMiddleware(...middlewares)); 18 | 19 | sagaMiddleware.run(rootSaga); 20 | 21 | export const persistor = persistStore(store); 22 | 23 | export default { store, persistStore }; 24 | -------------------------------------------------------------------------------- /client/src/redux/user/user.actions.js: -------------------------------------------------------------------------------- 1 | import UserActionTypes from './user.types'; 2 | 3 | export const googleSignInStart = () => ({ 4 | type: UserActionTypes.GOOGLE_SIGN_IN_START 5 | }); 6 | 7 | export const signInSuccess = user => ({ 8 | type: UserActionTypes.SIGN_IN_SUCCESS, 9 | payload: user 10 | }); 11 | 12 | export const signInFailure = error => ({ 13 | type: UserActionTypes.SIGN_IN_FAILURE, 14 | payload: error 15 | }); 16 | 17 | export const emailSignInStart = emailAndPassword => ({ 18 | type: UserActionTypes.EMAIL_SIGN_IN_START, 19 | payload: emailAndPassword 20 | }); 21 | 22 | export const checkUserSession = () => ({ 23 | type: UserActionTypes.CHECK_USER_SESSION 24 | }); 25 | 26 | export const signOutStart = () => ({ 27 | type: UserActionTypes.SIGN_OUT_START 28 | }); 29 | 30 | export const signOutSuccess = () => ({ 31 | type: UserActionTypes.SIGN_OUT_SUCCESS 32 | }); 33 | 34 | export const signOutFailure = error => ({ 35 | type: UserActionTypes.SIGN_OUT_FAILURE, 36 | payload: error 37 | }); 38 | 39 | export const signUpStart = userCredentials => ({ 40 | type: UserActionTypes.SIGN_UP_START, 41 | payload: userCredentials 42 | }); 43 | 44 | export const signUpSuccess = ({ user, additionalData }) => ({ 45 | type: UserActionTypes.SIGN_UP_SUCCESS, 46 | payload: { user, additionalData } 47 | }); 48 | 49 | export const signUpFailure = error => ({ 50 | type: UserActionTypes.SIGN_UP_FAILURE, 51 | payload: error 52 | }); 53 | -------------------------------------------------------------------------------- /client/src/redux/user/user.reducer.js: -------------------------------------------------------------------------------- 1 | import UserActionTypes from './user.types'; 2 | 3 | const INITIAL_STATE = { 4 | currentUser: null, 5 | error: null 6 | }; 7 | 8 | const userReducer = (state = INITIAL_STATE, action) => { 9 | switch (action.type) { 10 | case UserActionTypes.SIGN_IN_SUCCESS: 11 | return { 12 | ...state, 13 | currentUser: action.payload, 14 | error: null 15 | }; 16 | case UserActionTypes.SIGN_OUT_SUCCESS: 17 | return { 18 | ...state, 19 | currentUser: null, 20 | error: null 21 | }; 22 | case UserActionTypes.SIGN_IN_FAILURE: 23 | case UserActionTypes.SIGN_OUT_FAILURE: 24 | case UserActionTypes.SIGN_UP_FAILURE: 25 | return { 26 | ...state, 27 | error: action.payload 28 | }; 29 | default: 30 | return state; 31 | } 32 | }; 33 | 34 | export default userReducer; 35 | -------------------------------------------------------------------------------- /client/src/redux/user/user.reducer.test.js: -------------------------------------------------------------------------------- 1 | import UserActionTypes from './user.types'; 2 | import userReducer from './user.reducer'; 3 | 4 | const initialState = { 5 | currentUser: null, 6 | error: null 7 | }; 8 | 9 | describe('userReducer', () => { 10 | it('should return initial state', () => { 11 | expect(userReducer(undefined, {})).toEqual(initialState); 12 | }); 13 | 14 | it('should set currentUser to payload on signInSuccess action', () => { 15 | const mockUser = { id: 1, displayName: 'Yihua' }; 16 | 17 | expect( 18 | userReducer(initialState, { 19 | type: UserActionTypes.SIGN_IN_SUCCESS, 20 | payload: mockUser 21 | }).currentUser 22 | ).toEqual(mockUser); 23 | }); 24 | 25 | it('should set currentUser to null on signOutSuccess action', () => { 26 | expect( 27 | userReducer(initialState, { 28 | type: UserActionTypes.SIGN_OUT_SUCCESS 29 | }).currentUser 30 | ).toBe(null); 31 | }); 32 | 33 | it('should set errorMessage to payload on signInFailure, signUpFailure, signOutFailure action', () => { 34 | const mockError = { 35 | message: 'errored', 36 | code: 404 37 | }; 38 | 39 | expect( 40 | userReducer(initialState, { 41 | type: UserActionTypes.SIGN_IN_FAILURE, 42 | payload: mockError 43 | }).error 44 | ).toBe(mockError); 45 | 46 | expect( 47 | userReducer(initialState, { 48 | type: UserActionTypes.SIGN_UP_FAILURE, 49 | payload: mockError 50 | }).error 51 | ).toBe(mockError); 52 | 53 | expect( 54 | userReducer(initialState, { 55 | type: UserActionTypes.SIGN_OUT_FAILURE, 56 | payload: mockError 57 | }).error 58 | ).toBe(mockError); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /client/src/redux/user/user.sagas.js: -------------------------------------------------------------------------------- 1 | import { takeLatest, put, all, call } from 'redux-saga/effects'; 2 | 3 | import UserActionTypes from './user.types'; 4 | 5 | import { 6 | signInSuccess, 7 | signInFailure, 8 | signOutSuccess, 9 | signOutFailure, 10 | signUpSuccess, 11 | signUpFailure 12 | } from './user.actions'; 13 | 14 | import { 15 | auth, 16 | googleProvider, 17 | createUserProfileDocument, 18 | getCurrentUser 19 | } from '../../firebase/firebase.utils'; 20 | 21 | export function* getSnapshotFromUserAuth(userAuth, additionalData) { 22 | try { 23 | const userRef = yield call( 24 | createUserProfileDocument, 25 | userAuth, 26 | additionalData 27 | ); 28 | const userSnapshot = yield userRef.get(); 29 | yield put(signInSuccess({ id: userSnapshot.id, ...userSnapshot.data() })); 30 | } catch (error) { 31 | yield put(signInFailure(error)); 32 | } 33 | } 34 | 35 | export function* signInWithGoogle() { 36 | try { 37 | const { user } = yield auth.signInWithPopup(googleProvider); 38 | yield getSnapshotFromUserAuth(user); 39 | } catch (error) { 40 | yield put(signInFailure(error)); 41 | } 42 | } 43 | 44 | export function* signInWithEmail({ payload: { email, password } }) { 45 | try { 46 | const { user } = yield auth.signInWithEmailAndPassword(email, password); 47 | yield getSnapshotFromUserAuth(user); 48 | } catch (error) { 49 | yield put(signInFailure(error)); 50 | } 51 | } 52 | 53 | export function* isUserAuthenticated() { 54 | try { 55 | const userAuth = yield getCurrentUser(); 56 | if (!userAuth) return; 57 | yield getSnapshotFromUserAuth(userAuth); 58 | } catch (error) { 59 | yield put(signInFailure(error)); 60 | } 61 | } 62 | 63 | export function* signOut() { 64 | try { 65 | yield auth.signOut(); 66 | yield put(signOutSuccess()); 67 | } catch (error) { 68 | yield put(signOutFailure(error)); 69 | } 70 | } 71 | 72 | export function* signUp({ payload: { email, password, displayName } }) { 73 | try { 74 | const { user } = yield auth.createUserWithEmailAndPassword(email, password); 75 | yield put(signUpSuccess({ user, additionalData: { displayName } })); 76 | } catch (error) { 77 | yield put(signUpFailure(error)); 78 | } 79 | } 80 | 81 | export function* signInAfterSignUp({ payload: { user, additionalData } }) { 82 | yield getSnapshotFromUserAuth(user, additionalData); 83 | } 84 | 85 | export function* onGoogleSignInStart() { 86 | yield takeLatest(UserActionTypes.GOOGLE_SIGN_IN_START, signInWithGoogle); 87 | } 88 | 89 | export function* onEmailSignInStart() { 90 | yield takeLatest(UserActionTypes.EMAIL_SIGN_IN_START, signInWithEmail); 91 | } 92 | 93 | export function* onCheckUserSession() { 94 | yield takeLatest(UserActionTypes.CHECK_USER_SESSION, isUserAuthenticated); 95 | } 96 | 97 | export function* onSignOutStart() { 98 | yield takeLatest(UserActionTypes.SIGN_OUT_START, signOut); 99 | } 100 | 101 | export function* onSignUpStart() { 102 | yield takeLatest(UserActionTypes.SIGN_UP_START, signUp); 103 | } 104 | 105 | export function* onSignUpSuccess() { 106 | yield takeLatest(UserActionTypes.SIGN_UP_SUCCESS, signInAfterSignUp); 107 | } 108 | 109 | export function* userSagas() { 110 | yield all([ 111 | call(onGoogleSignInStart), 112 | call(onEmailSignInStart), 113 | call(isUserAuthenticated), 114 | call(onSignOutStart), 115 | call(onSignUpStart), 116 | call(onSignUpSuccess) 117 | ]); 118 | } 119 | -------------------------------------------------------------------------------- /client/src/redux/user/user.sagas.test.js: -------------------------------------------------------------------------------- 1 | import { takeLatest, put, call } from 'redux-saga/effects'; 2 | 3 | import UserActionTypes from './user.types'; 4 | 5 | import { 6 | signInSuccess, 7 | signInFailure, 8 | signOutSuccess, 9 | signOutFailure, 10 | signUpSuccess, 11 | signUpFailure 12 | } from './user.actions'; 13 | 14 | import { 15 | auth, 16 | googleProvider, 17 | createUserProfileDocument, 18 | getCurrentUser 19 | } from '../../firebase/firebase.utils'; 20 | 21 | import { 22 | getSnapshotFromUserAuth, 23 | signInWithGoogle, 24 | signInWithEmail, 25 | isUserAuthenticated, 26 | signOut, 27 | signUp, 28 | signInAfterSignUp, 29 | onGoogleSignInStart, 30 | onEmailSignInStart, 31 | onCheckUserSession, 32 | onSignOutStart, 33 | onSignUpStart, 34 | onSignUpSuccess 35 | } from './user.sagas'; 36 | 37 | describe('on signup success saga', () => { 38 | it('should trigger on SIGN_UP_SUCCESS', () => { 39 | const generator = onSignUpSuccess(); 40 | expect(generator.next().value).toEqual( 41 | takeLatest(UserActionTypes.SIGN_UP_SUCCESS, signInAfterSignUp) 42 | ); 43 | }); 44 | }); 45 | 46 | describe('on signup start saga', () => { 47 | it('should trigger on SIGN_UP_START', () => { 48 | const generator = onSignUpStart(); 49 | expect(generator.next().value).toEqual( 50 | takeLatest(UserActionTypes.SIGN_UP_START, signUp) 51 | ); 52 | }); 53 | }); 54 | 55 | describe('on signout start saga', () => { 56 | it('should trigger on SIGN_UP_START', () => { 57 | const generator = onSignOutStart(); 58 | expect(generator.next().value).toEqual( 59 | takeLatest(UserActionTypes.SIGN_OUT_START, signOut) 60 | ); 61 | }); 62 | }); 63 | 64 | describe('on check user session saga', () => { 65 | it('should trigger on CHECK_USER_SESSION', () => { 66 | const generator = onCheckUserSession(); 67 | expect(generator.next().value).toEqual( 68 | takeLatest(UserActionTypes.CHECK_USER_SESSION, isUserAuthenticated) 69 | ); 70 | }); 71 | }); 72 | 73 | describe('on email sign in start saga', () => { 74 | it('should trigger on EMAIL_SIGN_IN_START', () => { 75 | const generator = onEmailSignInStart(); 76 | expect(generator.next().value).toEqual( 77 | takeLatest(UserActionTypes.EMAIL_SIGN_IN_START, signInWithEmail) 78 | ); 79 | }); 80 | }); 81 | 82 | describe('on google sign in start saga', () => { 83 | it('should trigger on GOOGLE_SIGN_IN_START', () => { 84 | const generator = onGoogleSignInStart(); 85 | expect(generator.next().value).toEqual( 86 | takeLatest(UserActionTypes.GOOGLE_SIGN_IN_START, signInWithGoogle) 87 | ); 88 | }); 89 | }); 90 | 91 | describe('on sign in after sign up saga', () => { 92 | it('should fire getSnapshotFromUserAuth', () => { 93 | const mockUser = {}; 94 | const mockAdditionalData = {}; 95 | const mockAction = { 96 | payload: { 97 | user: mockUser, 98 | additionalData: mockAdditionalData 99 | } 100 | }; 101 | 102 | const generator = signInAfterSignUp(mockAction); 103 | expect(generator.next().value).toEqual( 104 | getSnapshotFromUserAuth(mockUser, mockAdditionalData) 105 | ); 106 | }); 107 | }); 108 | 109 | describe('on sign up saga', () => { 110 | const mockEmail = 'cindy@gmail.com'; 111 | const mockPassword = 'test123'; 112 | const mockDisplayName = 'cindy'; 113 | 114 | const mockAction = { 115 | payload: { 116 | email: mockEmail, 117 | password: mockPassword, 118 | displayName: mockDisplayName 119 | } 120 | }; 121 | 122 | const generator = signUp(mockAction); 123 | 124 | it('should call auth.createUserWithEmailAndPassword', () => { 125 | const createUserWithEmailAndPassword = jest.spyOn( 126 | auth, 127 | 'createUserWithEmailAndPassword' 128 | ); 129 | generator.next(); 130 | expect(createUserWithEmailAndPassword).toHaveBeenCalled(); 131 | }); 132 | }); 133 | 134 | describe('on sign out saga', () => { 135 | const generator = signOut(); 136 | 137 | it('should call auth.signOut', () => { 138 | const expectSignOut = jest.spyOn(auth, 'signOut'); 139 | generator.next(); 140 | expect(expectSignOut).toHaveBeenCalled(); 141 | }); 142 | 143 | it('should call signOutSuccess', () => { 144 | expect(generator.next().value).toEqual(put(signOutSuccess())); 145 | }); 146 | 147 | it('should call signOutFailure on error', () => { 148 | const newGenerator = signOut(); 149 | newGenerator.next(); 150 | expect(newGenerator.throw('error').value).toEqual( 151 | put(signOutFailure('error')) 152 | ); 153 | }); 154 | }); 155 | 156 | describe('is user authenticated saga', () => { 157 | const generator = isUserAuthenticated(); 158 | 159 | it('should call getCurrentUser', () => { 160 | expect(generator.next().value).toEqual(getCurrentUser()); 161 | }); 162 | 163 | it('should call getSnapshotFromUserAuth if userAuth exists', () => { 164 | const mockUserAuth = { uid: '123da' }; 165 | expect(generator.next(mockUserAuth).value).toEqual( 166 | getSnapshotFromUserAuth(mockUserAuth) 167 | ); 168 | }); 169 | 170 | it('should call signInFailure on error', () => { 171 | const newGenerator = isUserAuthenticated(); 172 | newGenerator.next(); 173 | expect(newGenerator.throw('error').value).toEqual( 174 | put(signInFailure('error')) 175 | ); 176 | }); 177 | }); 178 | 179 | describe('get snapshot from userAuth', () => { 180 | const mockUserAuth = {}; 181 | const mockAdditionalData = {}; 182 | const generator = getSnapshotFromUserAuth(mockUserAuth, mockAdditionalData); 183 | 184 | expect(generator.next().value).toEqual( 185 | call(createUserProfileDocument, mockUserAuth, mockAdditionalData) 186 | ); 187 | }); 188 | -------------------------------------------------------------------------------- /client/src/redux/user/user.selectors.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | 3 | const selectUser = state => state.user; 4 | 5 | export const selectCurrentUser = createSelector( 6 | [selectUser], 7 | user => user.currentUser 8 | ); 9 | -------------------------------------------------------------------------------- /client/src/redux/user/user.types.js: -------------------------------------------------------------------------------- 1 | const UserActionTypes = { 2 | SET_CURRENT_USER: 'SET_CURRENT_USER', 3 | GOOGLE_SIGN_IN_START: 'GOOGLE_SIGN_IN_START', 4 | EMAIL_SIGN_IN_START: 'EMAIL_SIGN_IN_START', 5 | SIGN_IN_SUCCESS: 'SIGN_IN_SUCCESS', 6 | SIGN_IN_FAILURE: 'SIGN_IN_FAILURE', 7 | CHECK_USER_SESSION: 'CHECK_USER_SESSION', 8 | SIGN_OUT_START: 'SIGN_OUT_START', 9 | SIGN_OUT_SUCCESS: 'SIGN_OUT_SUCCESS', 10 | SIGN_OUT_FAILURE: 'SIGN_OUT_FAILURE', 11 | SIGN_UP_START: 'SIGN_UP_START', 12 | SIGN_UP_SUCCESS: 'SIGN_UP_SUCCESS', 13 | SIGN_UP_FAILURE: 'SIGN_UP_FAILURE' 14 | }; 15 | 16 | export default UserActionTypes; 17 | -------------------------------------------------------------------------------- /client/src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl) 104 | .then(response => { 105 | // Ensure service worker exists, and that we really are getting a JS file. 106 | const contentType = response.headers.get('content-type'); 107 | if ( 108 | response.status === 404 || 109 | (contentType != null && contentType.indexOf('javascript') === -1) 110 | ) { 111 | // No service worker found. Probably a different app. Reload the page. 112 | navigator.serviceWorker.ready.then(registration => { 113 | registration.unregister().then(() => { 114 | window.location.reload(); 115 | }); 116 | }); 117 | } else { 118 | // Service worker found. Proceed as normal. 119 | registerValidSW(swUrl, config); 120 | } 121 | }) 122 | .catch(() => { 123 | console.log( 124 | 'No internet connection found. App is running in offline mode.' 125 | ); 126 | }); 127 | } 128 | 129 | export function unregister() { 130 | if ('serviceWorker' in navigator) { 131 | navigator.serviceWorker.ready.then(registration => { 132 | registration.unregister(); 133 | }); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /client/src/setupTests.js: -------------------------------------------------------------------------------- 1 | import { configure } from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | 4 | configure({ adapter: new Adapter() }); 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "crwn-clothing-server", 3 | "version": "1.0.0", 4 | "engines": { 5 | "node": "10.16.0", 6 | "npm": "6.4.1" 7 | }, 8 | "scripts": { 9 | "client": "cd client && yarn start", 10 | "server": "nodemon server.js", 11 | "build": "cd client && npm run build", 12 | "dev": "concurrently --kill-others-on-fail \"yarn server\" \"yarn client\"", 13 | "start": "node server.js", 14 | "heroku-postbuild": "cd client && npm install && npm install --only=dev --no-shrinkwrap && npm run build", 15 | "test-client": "cd client && yarn test" 16 | }, 17 | "dependencies": { 18 | "body-parser": "^1.18.3", 19 | "compression": "1.7.4", 20 | "cors": "2.8.5", 21 | "dotenv": "7.0.0", 22 | "express": "^4.16.4", 23 | "express-sslify": "1.2.0", 24 | "stripe": "6.28.0" 25 | }, 26 | "devDependencies": { 27 | "concurrently": "^4.0.1" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const cors = require('cors'); 3 | const bodyParser = require('body-parser'); 4 | const path = require('path'); 5 | const enforce = require('express-sslify'); 6 | 7 | if (process.env.NODE_ENV !== 'production') require('dotenv').config(); 8 | 9 | const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); 10 | 11 | const app = express(); 12 | const port = process.env.PORT || 5000; 13 | 14 | app.use(bodyParser.json()); 15 | app.use(bodyParser.urlencoded({ extended: true })); 16 | 17 | app.use(cors()); 18 | 19 | if (process.env.NODE_ENV === 'production') { 20 | app.use(enforce.HTTPS({ trustProtoHeader: true })); 21 | app.use(express.static(path.join(__dirname, 'client/build'))); 22 | 23 | app.get('*', function(req, res) { 24 | res.sendFile(path.join(__dirname, 'client/build', 'index.html')); 25 | }); 26 | } 27 | 28 | app.listen(port, error => { 29 | if (error) throw error; 30 | console.log('Server running on port ' + port); 31 | }); 32 | 33 | app.get('/service-worker.js', (req, res) => { 34 | res.sendFile(path.resolve(__dirname, '..', 'build', 'service-worker.js')); 35 | }); 36 | 37 | app.post('/payment', (req, res) => { 38 | const body = { 39 | source: req.body.token.id, 40 | amount: req.body.amount, 41 | currency: 'usd' 42 | }; 43 | 44 | stripe.charges.create(body, (stripeErr, stripeRes) => { 45 | if (stripeErr) { 46 | res.status(500).send({ error: stripeErr }); 47 | } else { 48 | res.status(200).send({ success: stripeRes }); 49 | } 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | accepts@~1.3.5, accepts@~1.3.7: 6 | version "1.3.7" 7 | resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd" 8 | integrity sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA== 9 | dependencies: 10 | mime-types "~2.1.24" 11 | negotiator "0.6.2" 12 | 13 | ansi-regex@^2.0.0: 14 | version "2.1.1" 15 | resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" 16 | integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8= 17 | 18 | ansi-regex@^3.0.0: 19 | version "3.0.0" 20 | resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998" 21 | integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg= 22 | 23 | ansi-styles@^3.2.1: 24 | version "3.2.1" 25 | resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" 26 | integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== 27 | dependencies: 28 | color-convert "^1.9.0" 29 | 30 | array-flatten@1.1.1: 31 | version "1.1.1" 32 | resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" 33 | integrity sha1-ml9pkFGx5wczKPKgCJaLZOopVdI= 34 | 35 | body-parser@1.19.0, body-parser@^1.18.3: 36 | version "1.19.0" 37 | resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a" 38 | integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw== 39 | dependencies: 40 | bytes "3.1.0" 41 | content-type "~1.0.4" 42 | debug "2.6.9" 43 | depd "~1.1.2" 44 | http-errors "1.7.2" 45 | iconv-lite "0.4.24" 46 | on-finished "~2.3.0" 47 | qs "6.7.0" 48 | raw-body "2.4.0" 49 | type-is "~1.6.17" 50 | 51 | bytes@3.0.0: 52 | version "3.0.0" 53 | resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048" 54 | integrity sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg= 55 | 56 | bytes@3.1.0: 57 | version "3.1.0" 58 | resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6" 59 | integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg== 60 | 61 | camelcase@^5.0.0: 62 | version "5.3.1" 63 | resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" 64 | integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== 65 | 66 | chalk@^2.4.1: 67 | version "2.4.2" 68 | resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" 69 | integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== 70 | dependencies: 71 | ansi-styles "^3.2.1" 72 | escape-string-regexp "^1.0.5" 73 | supports-color "^5.3.0" 74 | 75 | cliui@^4.0.0: 76 | version "4.1.0" 77 | resolved "https://registry.yarnpkg.com/cliui/-/cliui-4.1.0.tgz#348422dbe82d800b3022eef4f6ac10bf2e4d1b49" 78 | integrity sha512-4FG+RSG9DL7uEwRUZXZn3SS34DiDPfzP0VOiEwtUWlE+AR2EIg+hSyvrIgUUfhdgR/UkAeW2QHgeP+hWrXs7jQ== 79 | dependencies: 80 | string-width "^2.1.1" 81 | strip-ansi "^4.0.0" 82 | wrap-ansi "^2.0.0" 83 | 84 | code-point-at@^1.0.0: 85 | version "1.1.0" 86 | resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" 87 | integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c= 88 | 89 | color-convert@^1.9.0: 90 | version "1.9.3" 91 | resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" 92 | integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== 93 | dependencies: 94 | color-name "1.1.3" 95 | 96 | color-name@1.1.3: 97 | version "1.1.3" 98 | resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" 99 | integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= 100 | 101 | compressible@~2.0.16: 102 | version "2.0.17" 103 | resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.17.tgz#6e8c108a16ad58384a977f3a482ca20bff2f38c1" 104 | integrity sha512-BGHeLCK1GV7j1bSmQQAi26X+GgWcTjLr/0tzSvMCl3LH1w1IJ4PFSPoV5316b30cneTziC+B1a+3OjoSUcQYmw== 105 | dependencies: 106 | mime-db ">= 1.40.0 < 2" 107 | 108 | compression@1.7.4: 109 | version "1.7.4" 110 | resolved "https://registry.yarnpkg.com/compression/-/compression-1.7.4.tgz#95523eff170ca57c29a0ca41e6fe131f41e5bb8f" 111 | integrity sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ== 112 | dependencies: 113 | accepts "~1.3.5" 114 | bytes "3.0.0" 115 | compressible "~2.0.16" 116 | debug "2.6.9" 117 | on-headers "~1.0.2" 118 | safe-buffer "5.1.2" 119 | vary "~1.1.2" 120 | 121 | concurrently@^4.0.1: 122 | version "4.1.0" 123 | resolved "https://registry.yarnpkg.com/concurrently/-/concurrently-4.1.0.tgz#17fdf067da71210685d9ea554423ef239da30d33" 124 | integrity sha512-pwzXCE7qtOB346LyO9eFWpkFJVO3JQZ/qU/feGeaAHiX1M3Rw3zgXKc5cZ8vSH5DGygkjzLFDzA/pwoQDkRNGg== 125 | dependencies: 126 | chalk "^2.4.1" 127 | date-fns "^1.23.0" 128 | lodash "^4.17.10" 129 | read-pkg "^4.0.1" 130 | rxjs "^6.3.3" 131 | spawn-command "^0.0.2-1" 132 | supports-color "^4.5.0" 133 | tree-kill "^1.1.0" 134 | yargs "^12.0.1" 135 | 136 | content-disposition@0.5.3: 137 | version "0.5.3" 138 | resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.3.tgz#e130caf7e7279087c5616c2007d0485698984fbd" 139 | integrity sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g== 140 | dependencies: 141 | safe-buffer "5.1.2" 142 | 143 | content-type@~1.0.4: 144 | version "1.0.4" 145 | resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" 146 | integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== 147 | 148 | cookie-signature@1.0.6: 149 | version "1.0.6" 150 | resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" 151 | integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw= 152 | 153 | cookie@0.4.0: 154 | version "0.4.0" 155 | resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba" 156 | integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg== 157 | 158 | cors@2.8.5: 159 | version "2.8.5" 160 | resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" 161 | integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== 162 | dependencies: 163 | object-assign "^4" 164 | vary "^1" 165 | 166 | cross-spawn@^6.0.0: 167 | version "6.0.5" 168 | resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" 169 | integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ== 170 | dependencies: 171 | nice-try "^1.0.4" 172 | path-key "^2.0.1" 173 | semver "^5.5.0" 174 | shebang-command "^1.2.0" 175 | which "^1.2.9" 176 | 177 | date-fns@^1.23.0: 178 | version "1.30.1" 179 | resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.30.1.tgz#2e71bf0b119153dbb4cc4e88d9ea5acfb50dc05c" 180 | integrity sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw== 181 | 182 | debug@2.6.9: 183 | version "2.6.9" 184 | resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" 185 | integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== 186 | dependencies: 187 | ms "2.0.0" 188 | 189 | decamelize@^1.2.0: 190 | version "1.2.0" 191 | resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" 192 | integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= 193 | 194 | depd@~1.1.2: 195 | version "1.1.2" 196 | resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" 197 | integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak= 198 | 199 | destroy@~1.0.4: 200 | version "1.0.4" 201 | resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80" 202 | integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA= 203 | 204 | dotenv@7.0.0: 205 | version "7.0.0" 206 | resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-7.0.0.tgz#a2be3cd52736673206e8a85fb5210eea29628e7c" 207 | integrity sha512-M3NhsLbV1i6HuGzBUH8vXrtxOk+tWmzWKDMbAVSUp3Zsjm7ywFeuwrUXhmhQyRK1q5B5GGy7hcXPbj3bnfZg2g== 208 | 209 | ee-first@1.1.1: 210 | version "1.1.1" 211 | resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" 212 | integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= 213 | 214 | encodeurl@~1.0.2: 215 | version "1.0.2" 216 | resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" 217 | integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= 218 | 219 | end-of-stream@^1.1.0: 220 | version "1.4.1" 221 | resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.1.tgz#ed29634d19baba463b6ce6b80a37213eab71ec43" 222 | integrity sha512-1MkrZNvWTKCaigbn+W15elq2BB/L22nqrSY5DKlo3X6+vclJm8Bb5djXJBmEX6fS3+zCh/F4VBK5Z2KxJt4s2Q== 223 | dependencies: 224 | once "^1.4.0" 225 | 226 | error-ex@^1.3.1: 227 | version "1.3.2" 228 | resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" 229 | integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== 230 | dependencies: 231 | is-arrayish "^0.2.1" 232 | 233 | escape-html@~1.0.3: 234 | version "1.0.3" 235 | resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" 236 | integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= 237 | 238 | escape-string-regexp@^1.0.5: 239 | version "1.0.5" 240 | resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" 241 | integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= 242 | 243 | etag@~1.8.1: 244 | version "1.8.1" 245 | resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" 246 | integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= 247 | 248 | execa@^1.0.0: 249 | version "1.0.0" 250 | resolved "https://registry.yarnpkg.com/execa/-/execa-1.0.0.tgz#c6236a5bb4df6d6f15e88e7f017798216749ddd8" 251 | integrity sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA== 252 | dependencies: 253 | cross-spawn "^6.0.0" 254 | get-stream "^4.0.0" 255 | is-stream "^1.1.0" 256 | npm-run-path "^2.0.0" 257 | p-finally "^1.0.0" 258 | signal-exit "^3.0.0" 259 | strip-eof "^1.0.0" 260 | 261 | express-sslify@1.2.0: 262 | version "1.2.0" 263 | resolved "https://registry.yarnpkg.com/express-sslify/-/express-sslify-1.2.0.tgz#30e84bceed1557eb187672bbe1430a0a2a100d9c" 264 | integrity sha1-MOhLzu0VV+sYdnK74UMKCioQDZw= 265 | 266 | express@^4.16.4: 267 | version "4.17.1" 268 | resolved "https://registry.yarnpkg.com/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134" 269 | integrity sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g== 270 | dependencies: 271 | accepts "~1.3.7" 272 | array-flatten "1.1.1" 273 | body-parser "1.19.0" 274 | content-disposition "0.5.3" 275 | content-type "~1.0.4" 276 | cookie "0.4.0" 277 | cookie-signature "1.0.6" 278 | debug "2.6.9" 279 | depd "~1.1.2" 280 | encodeurl "~1.0.2" 281 | escape-html "~1.0.3" 282 | etag "~1.8.1" 283 | finalhandler "~1.1.2" 284 | fresh "0.5.2" 285 | merge-descriptors "1.0.1" 286 | methods "~1.1.2" 287 | on-finished "~2.3.0" 288 | parseurl "~1.3.3" 289 | path-to-regexp "0.1.7" 290 | proxy-addr "~2.0.5" 291 | qs "6.7.0" 292 | range-parser "~1.2.1" 293 | safe-buffer "5.1.2" 294 | send "0.17.1" 295 | serve-static "1.14.1" 296 | setprototypeof "1.1.1" 297 | statuses "~1.5.0" 298 | type-is "~1.6.18" 299 | utils-merge "1.0.1" 300 | vary "~1.1.2" 301 | 302 | finalhandler@~1.1.2: 303 | version "1.1.2" 304 | resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d" 305 | integrity sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA== 306 | dependencies: 307 | debug "2.6.9" 308 | encodeurl "~1.0.2" 309 | escape-html "~1.0.3" 310 | on-finished "~2.3.0" 311 | parseurl "~1.3.3" 312 | statuses "~1.5.0" 313 | unpipe "~1.0.0" 314 | 315 | find-up@^3.0.0: 316 | version "3.0.0" 317 | resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73" 318 | integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg== 319 | dependencies: 320 | locate-path "^3.0.0" 321 | 322 | forwarded@~0.1.2: 323 | version "0.1.2" 324 | resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84" 325 | integrity sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ= 326 | 327 | fresh@0.5.2: 328 | version "0.5.2" 329 | resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" 330 | integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac= 331 | 332 | get-caller-file@^1.0.1: 333 | version "1.0.3" 334 | resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.3.tgz#f978fa4c90d1dfe7ff2d6beda2a515e713bdcf4a" 335 | integrity sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w== 336 | 337 | get-stream@^4.0.0: 338 | version "4.1.0" 339 | resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5" 340 | integrity sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w== 341 | dependencies: 342 | pump "^3.0.0" 343 | 344 | has-flag@^2.0.0: 345 | version "2.0.0" 346 | resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-2.0.0.tgz#e8207af1cc7b30d446cc70b734b5e8be18f88d51" 347 | integrity sha1-6CB68cx7MNRGzHC3NLXovhj4jVE= 348 | 349 | has-flag@^3.0.0: 350 | version "3.0.0" 351 | resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" 352 | integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= 353 | 354 | hosted-git-info@^2.1.4: 355 | version "2.7.1" 356 | resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.7.1.tgz#97f236977bd6e125408930ff6de3eec6281ec047" 357 | integrity sha512-7T/BxH19zbcCTa8XkMlbK5lTo1WtgkFi3GvdWEyNuc4Vex7/9Dqbnpsf4JMydcfj9HCg4zUWFTL3Za6lapg5/w== 358 | 359 | http-errors@1.7.2, http-errors@~1.7.2: 360 | version "1.7.2" 361 | resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.2.tgz#4f5029cf13239f31036e5b2e55292bcfbcc85c8f" 362 | integrity sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg== 363 | dependencies: 364 | depd "~1.1.2" 365 | inherits "2.0.3" 366 | setprototypeof "1.1.1" 367 | statuses ">= 1.5.0 < 2" 368 | toidentifier "1.0.0" 369 | 370 | iconv-lite@0.4.24: 371 | version "0.4.24" 372 | resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" 373 | integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== 374 | dependencies: 375 | safer-buffer ">= 2.1.2 < 3" 376 | 377 | inherits@2.0.3: 378 | version "2.0.3" 379 | resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" 380 | integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= 381 | 382 | invert-kv@^2.0.0: 383 | version "2.0.0" 384 | resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-2.0.0.tgz#7393f5afa59ec9ff5f67a27620d11c226e3eec02" 385 | integrity sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA== 386 | 387 | ipaddr.js@1.9.0: 388 | version "1.9.0" 389 | resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.0.tgz#37df74e430a0e47550fe54a2defe30d8acd95f65" 390 | integrity sha512-M4Sjn6N/+O6/IXSJseKqHoFc+5FdGJ22sXqnjTpdZweHK64MzEPAyQZyEU3R/KRv2GLoa7nNtg/C2Ev6m7z+eA== 391 | 392 | is-arrayish@^0.2.1: 393 | version "0.2.1" 394 | resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" 395 | integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0= 396 | 397 | is-fullwidth-code-point@^1.0.0: 398 | version "1.0.0" 399 | resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb" 400 | integrity sha1-754xOG8DGn8NZDr4L95QxFfvAMs= 401 | dependencies: 402 | number-is-nan "^1.0.0" 403 | 404 | is-fullwidth-code-point@^2.0.0: 405 | version "2.0.0" 406 | resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" 407 | integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8= 408 | 409 | is-stream@^1.1.0: 410 | version "1.1.0" 411 | resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" 412 | integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ= 413 | 414 | isexe@^2.0.0: 415 | version "2.0.0" 416 | resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" 417 | integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= 418 | 419 | json-parse-better-errors@^1.0.1: 420 | version "1.0.2" 421 | resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" 422 | integrity sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw== 423 | 424 | lcid@^2.0.0: 425 | version "2.0.0" 426 | resolved "https://registry.yarnpkg.com/lcid/-/lcid-2.0.0.tgz#6ef5d2df60e52f82eb228a4c373e8d1f397253cf" 427 | integrity sha512-avPEb8P8EGnwXKClwsNUgryVjllcRqtMYa49NTsbQagYuT1DcXnl1915oxWjoyGrXR6zH/Y0Zc96xWsPcoDKeA== 428 | dependencies: 429 | invert-kv "^2.0.0" 430 | 431 | locate-path@^3.0.0: 432 | version "3.0.0" 433 | resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e" 434 | integrity sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A== 435 | dependencies: 436 | p-locate "^3.0.0" 437 | path-exists "^3.0.0" 438 | 439 | lodash.isplainobject@^4.0.6: 440 | version "4.0.6" 441 | resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" 442 | integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs= 443 | 444 | lodash@^4.17.10: 445 | version "4.17.11" 446 | resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d" 447 | integrity sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg== 448 | 449 | map-age-cleaner@^0.1.1: 450 | version "0.1.3" 451 | resolved "https://registry.yarnpkg.com/map-age-cleaner/-/map-age-cleaner-0.1.3.tgz#7d583a7306434c055fe474b0f45078e6e1b4b92a" 452 | integrity sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w== 453 | dependencies: 454 | p-defer "^1.0.0" 455 | 456 | media-typer@0.3.0: 457 | version "0.3.0" 458 | resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" 459 | integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= 460 | 461 | mem@^4.0.0: 462 | version "4.3.0" 463 | resolved "https://registry.yarnpkg.com/mem/-/mem-4.3.0.tgz#461af497bc4ae09608cdb2e60eefb69bff744178" 464 | integrity sha512-qX2bG48pTqYRVmDB37rn/6PT7LcR8T7oAX3bf99u1Tt1nzxYfxkgqDwUwolPlXweM0XzBOBFzSx4kfp7KP1s/w== 465 | dependencies: 466 | map-age-cleaner "^0.1.1" 467 | mimic-fn "^2.0.0" 468 | p-is-promise "^2.0.0" 469 | 470 | merge-descriptors@1.0.1: 471 | version "1.0.1" 472 | resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" 473 | integrity sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E= 474 | 475 | methods@~1.1.2: 476 | version "1.1.2" 477 | resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" 478 | integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= 479 | 480 | mime-db@1.40.0, "mime-db@>= 1.40.0 < 2": 481 | version "1.40.0" 482 | resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.40.0.tgz#a65057e998db090f732a68f6c276d387d4126c32" 483 | integrity sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA== 484 | 485 | mime-types@~2.1.24: 486 | version "2.1.24" 487 | resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.24.tgz#b6f8d0b3e951efb77dedeca194cff6d16f676f81" 488 | integrity sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ== 489 | dependencies: 490 | mime-db "1.40.0" 491 | 492 | mime@1.6.0: 493 | version "1.6.0" 494 | resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" 495 | integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== 496 | 497 | mimic-fn@^2.0.0: 498 | version "2.1.0" 499 | resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" 500 | integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== 501 | 502 | ms@2.0.0: 503 | version "2.0.0" 504 | resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" 505 | integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= 506 | 507 | ms@2.1.1: 508 | version "2.1.1" 509 | resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" 510 | integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg== 511 | 512 | negotiator@0.6.2: 513 | version "0.6.2" 514 | resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" 515 | integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw== 516 | 517 | nice-try@^1.0.4: 518 | version "1.0.5" 519 | resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" 520 | integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== 521 | 522 | normalize-package-data@^2.3.2: 523 | version "2.5.0" 524 | resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8" 525 | integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA== 526 | dependencies: 527 | hosted-git-info "^2.1.4" 528 | resolve "^1.10.0" 529 | semver "2 || 3 || 4 || 5" 530 | validate-npm-package-license "^3.0.1" 531 | 532 | npm-run-path@^2.0.0: 533 | version "2.0.2" 534 | resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f" 535 | integrity sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8= 536 | dependencies: 537 | path-key "^2.0.0" 538 | 539 | number-is-nan@^1.0.0: 540 | version "1.0.1" 541 | resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" 542 | integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0= 543 | 544 | object-assign@^4: 545 | version "4.1.1" 546 | resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" 547 | integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= 548 | 549 | on-finished@~2.3.0: 550 | version "2.3.0" 551 | resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" 552 | integrity sha1-IPEzZIGwg811M3mSoWlxqi2QaUc= 553 | dependencies: 554 | ee-first "1.1.1" 555 | 556 | on-headers@~1.0.2: 557 | version "1.0.2" 558 | resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f" 559 | integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA== 560 | 561 | once@^1.3.1, once@^1.4.0: 562 | version "1.4.0" 563 | resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" 564 | integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= 565 | dependencies: 566 | wrappy "1" 567 | 568 | os-locale@^3.0.0: 569 | version "3.1.0" 570 | resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-3.1.0.tgz#a802a6ee17f24c10483ab9935719cef4ed16bf1a" 571 | integrity sha512-Z8l3R4wYWM40/52Z+S265okfFj8Kt2cC2MKY+xNi3kFs+XGI7WXu/I309QQQYbRW4ijiZ+yxs9pqEhJh0DqW3Q== 572 | dependencies: 573 | execa "^1.0.0" 574 | lcid "^2.0.0" 575 | mem "^4.0.0" 576 | 577 | p-defer@^1.0.0: 578 | version "1.0.0" 579 | resolved "https://registry.yarnpkg.com/p-defer/-/p-defer-1.0.0.tgz#9f6eb182f6c9aa8cd743004a7d4f96b196b0fb0c" 580 | integrity sha1-n26xgvbJqozXQwBKfU+WsZaw+ww= 581 | 582 | p-finally@^1.0.0: 583 | version "1.0.0" 584 | resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" 585 | integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4= 586 | 587 | p-is-promise@^2.0.0: 588 | version "2.1.0" 589 | resolved "https://registry.yarnpkg.com/p-is-promise/-/p-is-promise-2.1.0.tgz#918cebaea248a62cf7ffab8e3bca8c5f882fc42e" 590 | integrity sha512-Y3W0wlRPK8ZMRbNq97l4M5otioeA5lm1z7bkNkxCka8HSPjR0xRWmpCmc9utiaLP9Jb1eD8BgeIxTW4AIF45Pg== 591 | 592 | p-limit@^2.0.0: 593 | version "2.2.0" 594 | resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.2.0.tgz#417c9941e6027a9abcba5092dd2904e255b5fbc2" 595 | integrity sha512-pZbTJpoUsCzV48Mc9Nh51VbwO0X9cuPFE8gYwx9BTCt9SF8/b7Zljd2fVgOxhIF/HDTKgpVzs+GPhyKfjLLFRQ== 596 | dependencies: 597 | p-try "^2.0.0" 598 | 599 | p-locate@^3.0.0: 600 | version "3.0.0" 601 | resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4" 602 | integrity sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ== 603 | dependencies: 604 | p-limit "^2.0.0" 605 | 606 | p-try@^2.0.0: 607 | version "2.2.0" 608 | resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" 609 | integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== 610 | 611 | parse-json@^4.0.0: 612 | version "4.0.0" 613 | resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0" 614 | integrity sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA= 615 | dependencies: 616 | error-ex "^1.3.1" 617 | json-parse-better-errors "^1.0.1" 618 | 619 | parseurl@~1.3.3: 620 | version "1.3.3" 621 | resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" 622 | integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== 623 | 624 | path-exists@^3.0.0: 625 | version "3.0.0" 626 | resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" 627 | integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU= 628 | 629 | path-key@^2.0.0, path-key@^2.0.1: 630 | version "2.0.1" 631 | resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40" 632 | integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A= 633 | 634 | path-parse@^1.0.6: 635 | version "1.0.6" 636 | resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c" 637 | integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw== 638 | 639 | path-to-regexp@0.1.7: 640 | version "0.1.7" 641 | resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" 642 | integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= 643 | 644 | pify@^3.0.0: 645 | version "3.0.0" 646 | resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176" 647 | integrity sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY= 648 | 649 | proxy-addr@~2.0.5: 650 | version "2.0.5" 651 | resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.5.tgz#34cbd64a2d81f4b1fd21e76f9f06c8a45299ee34" 652 | integrity sha512-t/7RxHXPH6cJtP0pRG6smSr9QJidhB+3kXu0KgXnbGYMgzEnUxRQ4/LDdfOwZEMyIh3/xHb8PX3t+lfL9z+YVQ== 653 | dependencies: 654 | forwarded "~0.1.2" 655 | ipaddr.js "1.9.0" 656 | 657 | pump@^3.0.0: 658 | version "3.0.0" 659 | resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" 660 | integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== 661 | dependencies: 662 | end-of-stream "^1.1.0" 663 | once "^1.3.1" 664 | 665 | qs@6.7.0, qs@^6.6.0: 666 | version "6.7.0" 667 | resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc" 668 | integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ== 669 | 670 | range-parser@~1.2.1: 671 | version "1.2.1" 672 | resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" 673 | integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== 674 | 675 | raw-body@2.4.0: 676 | version "2.4.0" 677 | resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.0.tgz#a1ce6fb9c9bc356ca52e89256ab59059e13d0332" 678 | integrity sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q== 679 | dependencies: 680 | bytes "3.1.0" 681 | http-errors "1.7.2" 682 | iconv-lite "0.4.24" 683 | unpipe "1.0.0" 684 | 685 | read-pkg@^4.0.1: 686 | version "4.0.1" 687 | resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-4.0.1.tgz#963625378f3e1c4d48c85872b5a6ec7d5d093237" 688 | integrity sha1-ljYlN48+HE1IyFhytabsfV0JMjc= 689 | dependencies: 690 | normalize-package-data "^2.3.2" 691 | parse-json "^4.0.0" 692 | pify "^3.0.0" 693 | 694 | require-directory@^2.1.1: 695 | version "2.1.1" 696 | resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" 697 | integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= 698 | 699 | require-main-filename@^1.0.1: 700 | version "1.0.1" 701 | resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-1.0.1.tgz#97f717b69d48784f5f526a6c5aa8ffdda055a4d1" 702 | integrity sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE= 703 | 704 | resolve@^1.10.0: 705 | version "1.11.0" 706 | resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.11.0.tgz#4014870ba296176b86343d50b60f3b50609ce232" 707 | integrity sha512-WL2pBDjqT6pGUNSUzMw00o4T7If+z4H2x3Gz893WoUQ5KW8Vr9txp00ykiP16VBaZF5+j/OcXJHZ9+PCvdiDKw== 708 | dependencies: 709 | path-parse "^1.0.6" 710 | 711 | rxjs@^6.3.3: 712 | version "6.5.2" 713 | resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.5.2.tgz#2e35ce815cd46d84d02a209fb4e5921e051dbec7" 714 | integrity sha512-HUb7j3kvb7p7eCUHE3FqjoDsC1xfZQ4AHFWfTKSpZ+sAhhz5X1WX0ZuUqWbzB2QhSLp3DoLUG+hMdEDKqWo2Zg== 715 | dependencies: 716 | tslib "^1.9.0" 717 | 718 | safe-buffer@5.1.2, safe-buffer@^5.1.1: 719 | version "5.1.2" 720 | resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" 721 | integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== 722 | 723 | "safer-buffer@>= 2.1.2 < 3": 724 | version "2.1.2" 725 | resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" 726 | integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== 727 | 728 | "semver@2 || 3 || 4 || 5", semver@^5.5.0: 729 | version "5.7.0" 730 | resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.0.tgz#790a7cf6fea5459bac96110b29b60412dc8ff96b" 731 | integrity sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA== 732 | 733 | send@0.17.1: 734 | version "0.17.1" 735 | resolved "https://registry.yarnpkg.com/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8" 736 | integrity sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg== 737 | dependencies: 738 | debug "2.6.9" 739 | depd "~1.1.2" 740 | destroy "~1.0.4" 741 | encodeurl "~1.0.2" 742 | escape-html "~1.0.3" 743 | etag "~1.8.1" 744 | fresh "0.5.2" 745 | http-errors "~1.7.2" 746 | mime "1.6.0" 747 | ms "2.1.1" 748 | on-finished "~2.3.0" 749 | range-parser "~1.2.1" 750 | statuses "~1.5.0" 751 | 752 | serve-static@1.14.1: 753 | version "1.14.1" 754 | resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.14.1.tgz#666e636dc4f010f7ef29970a88a674320898b2f9" 755 | integrity sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg== 756 | dependencies: 757 | encodeurl "~1.0.2" 758 | escape-html "~1.0.3" 759 | parseurl "~1.3.3" 760 | send "0.17.1" 761 | 762 | set-blocking@^2.0.0: 763 | version "2.0.0" 764 | resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" 765 | integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= 766 | 767 | setprototypeof@1.1.1: 768 | version "1.1.1" 769 | resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683" 770 | integrity sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw== 771 | 772 | shebang-command@^1.2.0: 773 | version "1.2.0" 774 | resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" 775 | integrity sha1-RKrGW2lbAzmJaMOfNj/uXer98eo= 776 | dependencies: 777 | shebang-regex "^1.0.0" 778 | 779 | shebang-regex@^1.0.0: 780 | version "1.0.0" 781 | resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" 782 | integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM= 783 | 784 | signal-exit@^3.0.0: 785 | version "3.0.2" 786 | resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" 787 | integrity sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0= 788 | 789 | spawn-command@^0.0.2-1: 790 | version "0.0.2-1" 791 | resolved "https://registry.yarnpkg.com/spawn-command/-/spawn-command-0.0.2-1.tgz#62f5e9466981c1b796dc5929937e11c9c6921bd0" 792 | integrity sha1-YvXpRmmBwbeW3Fkpk34RycaSG9A= 793 | 794 | spdx-correct@^3.0.0: 795 | version "3.1.0" 796 | resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.0.tgz#fb83e504445268f154b074e218c87c003cd31df4" 797 | integrity sha512-lr2EZCctC2BNR7j7WzJ2FpDznxky1sjfxvvYEyzxNyb6lZXHODmEoJeFu4JupYlkfha1KZpJyoqiJ7pgA1qq8Q== 798 | dependencies: 799 | spdx-expression-parse "^3.0.0" 800 | spdx-license-ids "^3.0.0" 801 | 802 | spdx-exceptions@^2.1.0: 803 | version "2.2.0" 804 | resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.2.0.tgz#2ea450aee74f2a89bfb94519c07fcd6f41322977" 805 | integrity sha512-2XQACfElKi9SlVb1CYadKDXvoajPgBVPn/gOQLrTvHdElaVhr7ZEbqJaRnJLVNeaI4cMEAgVCeBMKF6MWRDCRA== 806 | 807 | spdx-expression-parse@^3.0.0: 808 | version "3.0.0" 809 | resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.0.tgz#99e119b7a5da00e05491c9fa338b7904823b41d0" 810 | integrity sha512-Yg6D3XpRD4kkOmTpdgbUiEJFKghJH03fiC1OPll5h/0sO6neh2jqRDVHOQ4o/LMea0tgCkbMgea5ip/e+MkWyg== 811 | dependencies: 812 | spdx-exceptions "^2.1.0" 813 | spdx-license-ids "^3.0.0" 814 | 815 | spdx-license-ids@^3.0.0: 816 | version "3.0.4" 817 | resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.4.tgz#75ecd1a88de8c184ef015eafb51b5b48bfd11bb1" 818 | integrity sha512-7j8LYJLeY/Yb6ACbQ7F76qy5jHkp0U6jgBfJsk97bwWlVUnUWsAgpyaCvo17h0/RQGnQ036tVDomiwoI4pDkQA== 819 | 820 | "statuses@>= 1.5.0 < 2", statuses@~1.5.0: 821 | version "1.5.0" 822 | resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" 823 | integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow= 824 | 825 | string-width@^1.0.1: 826 | version "1.0.2" 827 | resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" 828 | integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M= 829 | dependencies: 830 | code-point-at "^1.0.0" 831 | is-fullwidth-code-point "^1.0.0" 832 | strip-ansi "^3.0.0" 833 | 834 | string-width@^2.0.0, string-width@^2.1.1: 835 | version "2.1.1" 836 | resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" 837 | integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== 838 | dependencies: 839 | is-fullwidth-code-point "^2.0.0" 840 | strip-ansi "^4.0.0" 841 | 842 | strip-ansi@^3.0.0, strip-ansi@^3.0.1: 843 | version "3.0.1" 844 | resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" 845 | integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8= 846 | dependencies: 847 | ansi-regex "^2.0.0" 848 | 849 | strip-ansi@^4.0.0: 850 | version "4.0.0" 851 | resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f" 852 | integrity sha1-qEeQIusaw2iocTibY1JixQXuNo8= 853 | dependencies: 854 | ansi-regex "^3.0.0" 855 | 856 | strip-eof@^1.0.0: 857 | version "1.0.0" 858 | resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf" 859 | integrity sha1-u0P/VZim6wXYm1n80SnJgzE2Br8= 860 | 861 | stripe@6.28.0: 862 | version "6.28.0" 863 | resolved "https://registry.yarnpkg.com/stripe/-/stripe-6.28.0.tgz#81f2bf174efe9503b50f24930a7e1d195c60f810" 864 | integrity sha512-4taF37geIr9DqvWEm3G9VCz2iJSV/DFc3PcElCQdQK5GUMI/MOj6XE0oJRYMOAHz0Oq8pT+4yDQmkh3SDI3nQA== 865 | dependencies: 866 | lodash.isplainobject "^4.0.6" 867 | qs "^6.6.0" 868 | safe-buffer "^5.1.1" 869 | uuid "^3.3.2" 870 | 871 | supports-color@^4.5.0: 872 | version "4.5.0" 873 | resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-4.5.0.tgz#be7a0de484dec5c5cddf8b3d59125044912f635b" 874 | integrity sha1-vnoN5ITexcXN34s9WRJQRJEvY1s= 875 | dependencies: 876 | has-flag "^2.0.0" 877 | 878 | supports-color@^5.3.0: 879 | version "5.5.0" 880 | resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" 881 | integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== 882 | dependencies: 883 | has-flag "^3.0.0" 884 | 885 | toidentifier@1.0.0: 886 | version "1.0.0" 887 | resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553" 888 | integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw== 889 | 890 | tree-kill@^1.1.0: 891 | version "1.2.1" 892 | resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.1.tgz#5398f374e2f292b9dcc7b2e71e30a5c3bb6c743a" 893 | integrity sha512-4hjqbObwlh2dLyW4tcz0Ymw0ggoaVDMveUB9w8kFSQScdRLo0gxO9J7WFcUBo+W3C1TLdFIEwNOWebgZZ0RH9Q== 894 | 895 | tslib@^1.9.0: 896 | version "1.10.0" 897 | resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a" 898 | integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ== 899 | 900 | type-is@~1.6.17, type-is@~1.6.18: 901 | version "1.6.18" 902 | resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" 903 | integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== 904 | dependencies: 905 | media-typer "0.3.0" 906 | mime-types "~2.1.24" 907 | 908 | unpipe@1.0.0, unpipe@~1.0.0: 909 | version "1.0.0" 910 | resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" 911 | integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= 912 | 913 | utils-merge@1.0.1: 914 | version "1.0.1" 915 | resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" 916 | integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= 917 | 918 | uuid@^3.3.2: 919 | version "3.3.2" 920 | resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131" 921 | integrity sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA== 922 | 923 | validate-npm-package-license@^3.0.1: 924 | version "3.0.4" 925 | resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" 926 | integrity sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew== 927 | dependencies: 928 | spdx-correct "^3.0.0" 929 | spdx-expression-parse "^3.0.0" 930 | 931 | vary@^1, vary@~1.1.2: 932 | version "1.1.2" 933 | resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" 934 | integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= 935 | 936 | which-module@^2.0.0: 937 | version "2.0.0" 938 | resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" 939 | integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho= 940 | 941 | which@^1.2.9: 942 | version "1.3.1" 943 | resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" 944 | integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== 945 | dependencies: 946 | isexe "^2.0.0" 947 | 948 | wrap-ansi@^2.0.0: 949 | version "2.1.0" 950 | resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85" 951 | integrity sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU= 952 | dependencies: 953 | string-width "^1.0.1" 954 | strip-ansi "^3.0.1" 955 | 956 | wrappy@1: 957 | version "1.0.2" 958 | resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" 959 | integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= 960 | 961 | "y18n@^3.2.1 || ^4.0.0": 962 | version "4.0.0" 963 | resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b" 964 | integrity sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w== 965 | 966 | yargs-parser@^11.1.1: 967 | version "11.1.1" 968 | resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-11.1.1.tgz#879a0865973bca9f6bab5cbdf3b1c67ec7d3bcf4" 969 | integrity sha512-C6kB/WJDiaxONLJQnF8ccx9SEeoTTLek8RVbaOIsrAUS8VrBEXfmeSnCZxygc+XC2sNMBIwOOnfcxiynjHsVSQ== 970 | dependencies: 971 | camelcase "^5.0.0" 972 | decamelize "^1.2.0" 973 | 974 | yargs@^12.0.1: 975 | version "12.0.5" 976 | resolved "https://registry.yarnpkg.com/yargs/-/yargs-12.0.5.tgz#05f5997b609647b64f66b81e3b4b10a368e7ad13" 977 | integrity sha512-Lhz8TLaYnxq/2ObqHDql8dX8CJi97oHxrjUcYtzKbbykPtVW9WB+poxI+NM2UIzsMgNCZTIf0AQwsjK5yMAqZw== 978 | dependencies: 979 | cliui "^4.0.0" 980 | decamelize "^1.2.0" 981 | find-up "^3.0.0" 982 | get-caller-file "^1.0.1" 983 | os-locale "^3.0.0" 984 | require-directory "^2.1.1" 985 | require-main-filename "^1.0.1" 986 | set-blocking "^2.0.0" 987 | string-width "^2.0.0" 988 | which-module "^2.0.0" 989 | y18n "^3.2.1 || ^4.0.0" 990 | yargs-parser "^11.1.1" 991 | --------------------------------------------------------------------------------