├── client ├── README.md ├── public │ ├── favicon.ico │ ├── crwn-192x192.png │ ├── crwn-512x512.png │ ├── manifest.json │ └── index.html ├── src │ ├── pages │ │ ├── shop │ │ │ ├── shop.styles.jsx │ │ │ ├── __snapshots__ │ │ │ │ └── shop.test.js.snap │ │ │ ├── shop.component.jsx │ │ │ └── shop.test.js │ │ ├── homepage │ │ │ ├── __snapshots__ │ │ │ │ └── homepage.test.js.snap │ │ │ ├── homepage.styles.jsx │ │ │ ├── homepage.test.js │ │ │ └── homepage.component.jsx │ │ ├── checkout │ │ │ ├── __snapshots__ │ │ │ │ └── checkout.test.js.snap │ │ │ ├── checkout.test.js │ │ │ ├── checkout.styles.jsx │ │ │ └── checkout.component.jsx │ │ ├── collection │ │ │ ├── __snapshots__ │ │ │ │ └── collection.test.js.snap │ │ │ ├── collection.container.jsx │ │ │ ├── collection.styles.jsx │ │ │ ├── collection.test.js │ │ │ └── collection.component.jsx │ │ └── sign-in-and-sign-up │ │ │ ├── __snapshots__ │ │ │ └── sign-in-and-sign-up.test.js.snap │ │ │ ├── sign-in-and-sign-up.test.js │ │ │ ├── sign-in-and-sign-up.styles.jsx │ │ │ └── sign-in-and-sign-up.component.jsx │ ├── setupTests.js │ ├── components │ │ ├── cart-item │ │ │ ├── __snapshots__ │ │ │ │ └── cart-item.test.js.snap │ │ │ ├── cart-item.test.js │ │ │ ├── cart-item.styles.jsx │ │ │ └── cart-item.component.jsx │ │ ├── directory │ │ │ ├── __snapshots__ │ │ │ │ └── directory.test.js.snap │ │ │ ├── directory.styles.jsx │ │ │ ├── directory.test.js │ │ │ └── directory.component.jsx │ │ ├── custom-button │ │ │ ├── __snapshots__ │ │ │ │ └── custom-button.test.js.snap │ │ │ ├── custom-button.test.js │ │ │ ├── custom-button.component.jsx │ │ │ └── custom-button.styles.jsx │ │ ├── cart-icon │ │ │ ├── __snapshots__ │ │ │ │ └── cart-icon.test.js.snap │ │ │ ├── cart-icon.styles.jsx │ │ │ ├── cart-icon.component.jsx │ │ │ └── cart-icon.test.js │ │ ├── menu-item │ │ │ ├── __snapshots__ │ │ │ │ └── menu-item.test.js.snap │ │ │ ├── menu-item.component.jsx │ │ │ ├── menu-item.test.js │ │ │ └── menu-item.styles.jsx │ │ ├── form-input │ │ │ ├── __snapshots__ │ │ │ │ └── form-input.test.js.snap │ │ │ ├── form-input.component.jsx │ │ │ ├── form-input.styles.jsx │ │ │ └── form-input.test.js │ │ ├── cart-dropdown │ │ │ ├── __snapshots__ │ │ │ │ └── cart-dropdown.test.js.snap │ │ │ ├── cart-dropdown.styles.jsx │ │ │ ├── cart-dropdown.component.jsx │ │ │ └── cart-dropdown.test.js │ │ ├── checkout-item │ │ │ ├── __snapshots__ │ │ │ │ └── checkout-item.test.js.snap │ │ │ ├── checkout-item.styles.jsx │ │ │ ├── checkout-item.component.jsx │ │ │ └── checkout-item.test.js │ │ ├── collections-overview │ │ │ ├── __snapshots__ │ │ │ │ └── collections-overview.test.js.snap │ │ │ ├── collections-overview.styles.jsx │ │ │ ├── collections-overview.test.js │ │ │ ├── collections-overview.container.jsx │ │ │ └── collections-overview.component.jsx │ │ ├── collection-item │ │ │ ├── __snapshots__ │ │ │ │ └── collection-item.test.js.snap │ │ │ ├── collection-item.component.jsx │ │ │ ├── collection-item.test.js │ │ │ └── collection-item.styles.jsx │ │ ├── collection-preview │ │ │ ├── __snapshots__ │ │ │ │ └── collection-preview.test.js.snap │ │ │ ├── collection-preview.styles.jsx │ │ │ ├── collection-preview.component.jsx │ │ │ └── collection-preview.test.js │ │ ├── header │ │ │ ├── __snapshots__ │ │ │ │ └── header.test.js.snap │ │ │ ├── header.styles.jsx │ │ │ ├── header.component.jsx │ │ │ └── header.test.js │ │ ├── sign-up │ │ │ ├── sign-up.styles.jsx │ │ │ └── sign-up.component.jsx │ │ ├── spinner │ │ │ ├── spinner.component.jsx │ │ │ └── spinner.styles.jsx │ │ ├── with-spinner │ │ │ ├── with-spinner.component.jsx │ │ │ └── with-spinner.test.js │ │ ├── sign-in │ │ │ ├── sign-in.styles.jsx │ │ │ └── sign-in.component.jsx │ │ ├── error-boundary │ │ │ ├── error-boundary.styles.jsx │ │ │ └── error-boundary.component.jsx │ │ └── stripe-button │ │ │ └── stripe-button.component.jsx │ ├── redux │ │ ├── user │ │ │ ├── user.selectors.js │ │ │ ├── user.types.js │ │ │ ├── user.reducer.js │ │ │ ├── user.actions.js │ │ │ ├── user.reducer.test.js │ │ │ ├── user.sagas.js │ │ │ └── user.sagas.test.js │ │ ├── directory │ │ │ ├── directory.selectors.js │ │ │ ├── directory.reducer.test.js │ │ │ └── directory.reducer.js │ │ ├── shop │ │ │ ├── shop.types.js │ │ │ ├── shop.reducer.js │ │ │ ├── shop.selectors.js │ │ │ ├── shop.sagas.js │ │ │ ├── shop.actions.js │ │ │ ├── shop.reducer.test.js │ │ │ ├── shop.actions.test.js │ │ │ └── shop.sagas.test.js │ │ ├── cart │ │ │ ├── cart.types.js │ │ │ ├── cart.sagas.js │ │ │ ├── cart.actions.js │ │ │ ├── cart.sagas.test.js │ │ │ ├── cart.selectors.js │ │ │ ├── cart.utils.js │ │ │ ├── cart.reducer.js │ │ │ ├── cart.actions.test.js │ │ │ └── cart.reducer.test.js │ │ ├── root-saga.js │ │ ├── saga-testing.utils.js │ │ ├── root-reducer.js │ │ └── store.js │ ├── global.styles.js │ ├── index.css │ ├── index.js │ ├── assets │ │ ├── crown.svg │ │ └── shopping-bag.svg │ ├── App.js │ ├── logo.svg │ ├── firebase │ │ └── firebase.utils.js │ └── serviceWorker.js └── package.json ├── .gitignore ├── package.json ├── server.js └── yarn.lock /client/README.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AmilAbdullazadeh/TheBestEcommerceReact/HEAD/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/crwn-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AmilAbdullazadeh/TheBestEcommerceReact/HEAD/client/public/crwn-192x192.png -------------------------------------------------------------------------------- /client/public/crwn-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AmilAbdullazadeh/TheBestEcommerceReact/HEAD/client/public/crwn-512x512.png -------------------------------------------------------------------------------- /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/setupTests.js: -------------------------------------------------------------------------------- 1 | import { configure } from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | 4 | configure({ adapter: new Adapter() }); 5 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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 | 5 | exports[`Header component should render Header component 1`] = `ShallowWrapper {}`; 6 | -------------------------------------------------------------------------------- /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/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/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/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/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/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 | }; 8 | 9 | export default CartActionTypes; 10 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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/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/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/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/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/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/redux/cart/cart.sagas.js: -------------------------------------------------------------------------------- 1 | import { all, call, takeLatest, put } from 'redux-saga/effects'; 2 | 3 | import UserActionTypes from '../user/user.types'; 4 | import { clearCart } from './cart.actions'; 5 | 6 | export function* clearCartOnSignOut() { 7 | yield put(clearCart()); 8 | } 9 | 10 | export function* onSignOutSuccess() { 11 | yield takeLatest(UserActionTypes.SIGN_OUT_SUCCESS, clearCartOnSignOut); 12 | } 13 | 14 | export function* cartSagas() { 15 | yield all([call(onSignOutSuccess)]); 16 | } 17 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /.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* -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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/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/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/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/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/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/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.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/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/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/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/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 | -------------------------------------------------------------------------------- /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 | "nodemon": "^1.19.1" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /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/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-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/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/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/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/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/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/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/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/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/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/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-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/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/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 | default: 39 | return state; 40 | } 41 | }; 42 | 43 | export default cartReducer; 44 | -------------------------------------------------------------------------------- /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/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/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: ', 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/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/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/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": "^17.0.1", 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/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/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/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/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/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/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/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 | -------------------------------------------------------------------------------- /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 | app.use(enforce.HTTPS({ trustProtoHeader: true })); 17 | app.use(cors()); 18 | 19 | if (process.env.NODE_ENV === 'production') { 20 | app.use(express.static(path.join(__dirname, 'client/build'))); 21 | 22 | app.get('*', function(req, res) { 23 | res.sendFile(path.join(__dirname, 'client/build', 'index.html')); 24 | }); 25 | } 26 | 27 | app.listen(port, error => { 28 | if (error) throw error; 29 | console.log('Server running on port ' + port); 30 | }); 31 | 32 | app.get('/service-worker.js', (req, res) => { 33 | res.sendFile(path.resolve(__dirname, '..', 'build', 'service-worker.js')); 34 | }); 35 | 36 | app.post('/payment', (req, res) => { 37 | const body = { 38 | source: req.body.token.id, 39 | amount: req.body.amount, 40 | currency: 'usd' 41 | }; 42 | 43 | stripe.charges.create(body, (stripeErr, stripeRes) => { 44 | if (stripeErr) { 45 | res.status(500).send({ error: stripeErr }); 46 | } else { 47 | res.status(200).send({ success: stripeRes }); 48 | } 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /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 removeItem 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/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/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/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/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/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/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/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/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/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/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/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/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/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 Header 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/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/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/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/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /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/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 addCollectionAndDocuments = async ( 43 | collectionKey, 44 | objectsToAdd 45 | ) => { 46 | const collectionRef = firestore.collection(collectionKey); 47 | 48 | const batch = firestore.batch(); 49 | objectsToAdd.forEach(obj => { 50 | const newDocRef = collectionRef.doc(); 51 | batch.set(newDocRef, obj); 52 | }); 53 | 54 | return await batch.commit(); 55 | }; 56 | 57 | export const convertCollectionsSnapshotToMap = collections => { 58 | const transformedCollection = collections.docs.map(doc => { 59 | const { title, items } = doc.data(); 60 | 61 | return { 62 | routeName: encodeURI(title.toLowerCase()), 63 | id: doc.id, 64 | title, 65 | items 66 | }; 67 | }); 68 | 69 | return transformedCollection.reduce((accumulator, collection) => { 70 | accumulator[collection.title.toLowerCase()] = collection; 71 | return accumulator; 72 | }, {}); 73 | }; 74 | 75 | export const getCurrentUser = () => { 76 | return new Promise((resolve, reject) => { 77 | const unsubscribe = auth.onAuthStateChanged(userAuth => { 78 | unsubscribe(); 79 | resolve(userAuth); 80 | }, reject); 81 | }); 82 | }; 83 | 84 | export const auth = firebase.auth(); 85 | export const firestore = firebase.firestore(); 86 | 87 | export const googleProvider = new firebase.auth.GoogleAuthProvider(); 88 | googleProvider.setCustomParameters({ prompt: 'select_account' }); 89 | export const signInWithGoogle = () => auth.signInWithPopup(googleProvider); 90 | 91 | export default firebase; 92 | -------------------------------------------------------------------------------- /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(onCheckUserSession), 114 | call(onSignOutStart), 115 | call(onSignUpStart), 116 | call(onSignUpSuccess) 117 | ]); 118 | } 119 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------