├── .gitignore ├── LICENSE ├── README.md ├── package.json ├── public ├── favicon.ico ├── index.html ├── manifest.json └── robots.txt ├── src ├── data │ ├── cart │ │ └── CartInMemoryRepository.ts │ └── products │ │ └── ProductInMemoryRepository.ts ├── di │ └── DependenciesProvider.ts ├── domain │ ├── cart │ │ ├── Cart.ts │ │ ├── CartItem.ts │ │ ├── CartRepository.ts │ │ ├── __test__ │ │ │ └── Cart.test.ts │ │ └── usecases │ │ │ ├── AddProductToCartUseCase.ts │ │ │ ├── EditQuantityOfCartItemUseCase.ts │ │ │ ├── GetCartUseCase.ts │ │ │ └── RemoveItemFromCartUseCase.ts │ └── products │ │ ├── GetProductsUseCase.ts │ │ ├── Product.ts │ │ └── ProductRepository.ts ├── index.tsx ├── presentation │ ├── app │ │ ├── App.tsx │ │ └── __test__ │ │ │ └── App.test.tsx │ ├── appbar │ │ ├── Logo.png │ │ └── MyAppBar.tsx │ ├── cart │ │ ├── CartBloc.ts │ │ ├── CartState.ts │ │ └── components │ │ │ ├── CartContent.tsx │ │ │ ├── CartContentItem.tsx │ │ │ └── CartDrawer.tsx │ ├── common │ │ └── bloc │ │ │ ├── Bloc.ts │ │ │ ├── BlocBuilder.tsx │ │ │ ├── Context.tsx │ │ │ └── index.ts │ ├── products │ │ ├── ProductsBloc.ts │ │ ├── ProductsState.ts │ │ └── components │ │ │ ├── ProductItem.tsx │ │ │ └── ProductList.tsx │ ├── react-app-env.d.ts │ └── theme.tsx └── react-app-env.d.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | .netlify 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Jorge Sánchez Fernández 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Header](https://user-images.githubusercontent.com/5593590/68601208-2af3f480-04a4-11ea-853e-14b251966ca2.png) 2 | 3 | Shopping Cart React App is a shopping cart example using React and TypeScript. Use clean Architecture with MVP and a simple state management using lifting state up and prop drilling. 4 | 5 | This project was bootstrapped with [Create React App](https://create-react-app.dev/). 6 | 7 | [Live Demo](https://shopping-cart-react-lifting-state-up.netlify.com/) 8 | 9 | ## Clean Architecture Course 10 | 11 | * [Curso Clean Architecture](https://xurxodev.com/curso-clean-architecture) 12 | 13 | ## Screenshots 14 | 15 | ![products](https://user-images.githubusercontent.com/5593590/69054813-b0c7f080-0a0c-11ea-99e5-9af5da86d740.png) 16 | ![cart](https://user-images.githubusercontent.com/5593590/69054815-b0c7f080-0a0c-11ea-880d-7f902cfae00d.png) 17 | 18 | ## State managements strategies 19 | 20 | * Branch [Lifting state up](https://github.com/xurxodev/shopping-cart-react/tree/lifting-state-up) 21 | * Branch [BLoC Pattern](https://github.com/xurxodev/shopping-cart-react/tree/bloc-pattern) 22 | 23 | ## Clean Architecture 24 | 25 | All strategies uses Clean architecture 26 | 27 | ![bloc-clean-architecture](https://user-images.githubusercontent.com/5593590/82728951-03ec6a00-9cf4-11ea-8557-011a3dea7804.png) 28 | 29 | ## Available Scripts 30 | 31 | In the project directory, you can run: 32 | 33 | ### Development 34 | 35 | Start development server: 36 | 37 | ``` 38 | $ yarn start-dev 39 | ``` 40 | 41 | Runs the app in the development mode.
42 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 43 | 44 | The page will reload if you make edits.
45 | You will also see any lint errors in the console. 46 | 47 | ### Testing 48 | 49 | Run unit tests: 50 | 51 | ``` 52 | $ yarn test 53 | ``` 54 | 55 | Launches the test runner in the interactive watch mode.
56 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 57 | 58 | ## Production 59 | 60 | Start development server: 61 | ``` 62 | $ yarn build 63 | ``` 64 | 65 | Builds the app for production to the `build` folder.
66 | It correctly bundles React in production mode and optimizes the build for the best performance. 67 | 68 | The build is minified and the filenames include the hashes.
69 | Your app is ready to be deployed! 70 | 71 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 72 | 73 | ### `yarn eject` 74 | 75 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 76 | 77 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 78 | 79 | Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 80 | 81 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 82 | 83 | # Resources 84 | * [Qué es el estado en frameworks declarativos](http://xurxodev.com/estado-en-frameworks-declarativos/). 85 | * [Gestión simple de estado en frameworks declarativos](http://xurxodev.com/gestion-simple-de-estado-en-frameworks-declarativos/). 86 | * [Gestión simple de estado en ReactJS](http://xurxodev.com/gestion-simple-de-estado-en-reactjs/) 87 | * [Introducción al patrón BLoc](http://xurxodev.com/introduccion-al-patron-bloc/) 88 | * [El Patrón Bloc en Clean Architecture](http://xurxodev.com/el-patron-bloc-en-clean-architecture/) 89 | * [El Patrón Bloc junto a Clean Architecture en ReactJS](http://xurxodev.com/el-patron-bloc-junto-a-clean-architecture-en-reactjs) 90 | * [Curso Clean Architecture](http://xurxodev.com/curso-clean-architecture) 91 | 92 | ## Libraries used in this project 93 | * [reactjs](https://reactjs.org/) 94 | * [material-ui](https://material-ui.com/) 95 | * [jest](https://jestjs.io/) 96 | 97 | ## License 98 | 99 | MIT License 100 | 101 | Copyright (c) 2019 Jorge Sánchez Fernández 102 | 103 | Permission is hereby granted, free of charge, to any person obtaining a copy 104 | of this software and associated documentation files (the "Software"), to deal 105 | in the Software without restriction, including without limitation the rights 106 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 107 | copies of the Software, and to permit persons to whom the Software is 108 | furnished to do so, subject to the following conditions: 109 | 110 | The above copyright notice and this permission notice shall be included in all 111 | copies or substantial portions of the Software. 112 | 113 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 114 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 115 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 116 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 117 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 118 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 119 | SOFTWARE. 120 | 121 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lifting-state-up", 3 | "version": "0.1.1", 4 | "private": true, 5 | "dependencies": { 6 | "@material-ui/core": "4.11.0", 7 | "@material-ui/icons": "4.9.1", 8 | "@types/jest": "26.0.4", 9 | "@types/node": "14.0.22", 10 | "@types/react": "16.9.9", 11 | "@types/react-dom": "16.9.2", 12 | "react": "16.13.1", 13 | "react-dom": "16.13.1", 14 | "react-scripts": "3.4.1", 15 | "typescript": "3.9.6" 16 | }, 17 | "scripts": { 18 | "start": "react-scripts start", 19 | "build": "react-scripts build", 20 | "test": "react-scripts test", 21 | "eject": "react-scripts eject" 22 | }, 23 | "eslintConfig": { 24 | "extends": "react-app" 25 | }, 26 | "browserslist": { 27 | "production": [ 28 | ">0.2%", 29 | "not dead", 30 | "not op_mini all" 31 | ], 32 | "development": [ 33 | "last 1 chrome version", 34 | "last 1 firefox version", 35 | "last 1 safari version" 36 | ] 37 | } 38 | } -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xurxodev/shopping-cart-react/143891970fa69feb7117fff9d14425a02ed8180d/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /src/data/cart/CartInMemoryRepository.ts: -------------------------------------------------------------------------------- 1 | import Cart from "../../domain/cart/Cart"; 2 | import CartRepository from "../../domain/cart/CartRepository"; 3 | 4 | export default class CartInMemoryRepository implements CartRepository { 5 | 6 | cart = new Cart([ 7 | { 8 | id: "1", 9 | image: 10 | "https://m.media-amazon.com/images/I/81oKhu2bsgL._AC_UL640_FMwebp_QL65_.jpg", 11 | title: "Element Blazin LS tee Shirt, Hombre", 12 | price: 19.95, 13 | quantity: 3 14 | }, 15 | { 16 | id: "2", 17 | image: 18 | "https://m.media-amazon.com/images/I/81HnHYik58L._AC_UL640_FMwebp_QL65_.jpg", 19 | title: "Element Vertical SS tee Shirt, Hombre", 20 | price: 21.95, 21 | quantity: 1 22 | } 23 | ]); 24 | 25 | 26 | get(): Promise { 27 | return new Promise((resolve, reject) => { 28 | setTimeout(() => { 29 | resolve(this.cart); 30 | }, 100); 31 | }); 32 | } 33 | 34 | save(cart: Cart): Promise { 35 | return new Promise((resolve, reject) => { 36 | setTimeout(() => { 37 | this.cart = cart; 38 | resolve(true); 39 | }, 100); 40 | }); 41 | } 42 | } -------------------------------------------------------------------------------- /src/data/products/ProductInMemoryRepository.ts: -------------------------------------------------------------------------------- 1 | import ProductRepository from "../../domain/products/ProductRepository"; 2 | import Product from "../../domain/products/Product"; 3 | 4 | const products = [ 5 | { 6 | id: "1", 7 | image: 8 | "https://m.media-amazon.com/images/I/81oKhu2bsgL._AC_UL640_FMwebp_QL65_.jpg", 9 | title: "Element Blazin LS tee Shirt, Hombre", 10 | price: 19.95 11 | }, 12 | { 13 | id: "2", 14 | image: 15 | "https://m.media-amazon.com/images/I/81HnHYik58L._AC_UL640_FMwebp_QL65_.jpg", 16 | title: "Element Vertical SS tee Shirt, Hombre", 17 | price: 21.95 18 | }, 19 | { 20 | id: "3", 21 | image: 22 | "https://m.media-amazon.com/images/I/81ZYZ9yl1hL._AC_UL640_FMwebp_QL65_.jpg", 23 | title: 'Element Skater Backpack Mohave 15" Saison ', 24 | price: 52.45 25 | }, 26 | { 27 | id: "4", 28 | image: 29 | "https://m.media-amazon.com/images/I/61-DwEh1zrL._AC_UL640_FMwebp_QL65_.jpg", 30 | title: "Element Indiana Logo N1SSA5ELP9", 31 | price: 18.90 32 | }, 33 | { 34 | id: "5", 35 | image: 36 | "https://m.media-amazon.com/images/I/71MG0EzCU4L._AC_UL640_FMwebp_QL65_.jpg", 37 | title: "Element L1ssa8 Camiseta, Hombre", 38 | price: 27.95 39 | }, 40 | { 41 | id: "6", 42 | image: 43 | "https://m.media-amazon.com/images/I/81giLCXfxIL._AC_UL640_FMwebp_QL65_.jpg", 44 | title: "Element N2ssa2 Camiseta, Niños", 45 | price: 13.90 46 | }, 47 | { 48 | id: "7", 49 | image: 50 | "https://m.media-amazon.com/images/I/81oKhu2bsgL._AC_UL640_FMwebp_QL65_.jpg", 51 | title: "Element Blazin LS tee Shirt, Hombre", 52 | price: 19.95 53 | }, 54 | { 55 | id: "8", 56 | image: 57 | "https://m.media-amazon.com/images/I/7119OAEE+gL._AC_UL640_FMwebp_QL65_.jpg", 58 | title: "Element Alder Light 2 Tones", 59 | price: 68.35 60 | }, 61 | { 62 | id: "9", 63 | image: 64 | "https://m.media-amazon.com/images/I/71dp5f24TbL._AC_UL640_FMwebp_QL65_.jpg", 65 | title: 'Element Skater Backpack Mohave 15" Season', 66 | price: 52.84 67 | }, 68 | { 69 | id: "10", 70 | image: 71 | "https://m.media-amazon.com/images/I/71Kj-jV5v8L._AC_UL640_FMwebp_QL65_.jpg", 72 | title: "Element Vertical SS Camiseta, Niños", 73 | price: 13.90 74 | }, 75 | { 76 | id: "11", 77 | image: 78 | "https://m.media-amazon.com/images/I/71jlppwpjmL._AC_UL640_FMwebp_QL65_.jpg", 79 | title: "Element Alder Heavy Puff TW Chaqueta, Hombre, Verde Oliva, M EU", 80 | price: 168.75 81 | }, 82 | { 83 | id: "12", 84 | image: 85 | "https://m.media-amazon.com/images/I/71BSdq6OzDL._AC_UL640_FMwebp_QL65_.jpg", 86 | title: "Element Hombre Meridian Block Sudadera Mid Grey HTR", 87 | price: 47.50 88 | }, 89 | { 90 | id: "13", 91 | image: 92 | "https://m.media-amazon.com/images/I/81RAeKF-8wL._AC_UL640_FMwebp_QL65_.jpg", 93 | title: "Element Sudadera - para Hombre", 94 | price: 64.94 95 | }, 96 | { 97 | id: "14", 98 | image: 99 | "https://m.media-amazon.com/images/I/717tHbEHDnL._AC_UL640_FMwebp_QL65_.jpg", 100 | title: "Element Hombre Camiseta t-Shirt Signature", 101 | price: 29.84 102 | }, 103 | { 104 | id: "15", 105 | image: 106 | "https://m.media-amazon.com/images/I/81rOs3LA0LL._AC_UL640_FMwebp_QL65_.jpg", 107 | title: "Element Section' Pre-Built Complete - 7.50\"", 108 | price: 99.00 109 | }, 110 | { 111 | id: "16", 112 | image: 113 | "https://m.media-amazon.com/images/I/61-xQZORAKL._AC_UL640_FMwebp_QL65_.jpg", 114 | title: "Element Camiseta - para hombre", 115 | price: 27.06 116 | }, 117 | { 118 | id: "17", 119 | image: 120 | "https://m.media-amazon.com/images/I/71RUdoglJML._AC_UL640_FMwebp_QL65_.jpg", 121 | title: "Element Alder Light", 122 | price: 86.52 123 | }, 124 | { 125 | id: "18", 126 | image: 127 | "https://m.media-amazon.com/images/I/714tTmj4KvL._AC_UL640_FMwebp_QL65_.jpg", 128 | title: "Element Chaqueta Alder Puff TW Negro", 129 | price: 73.50 130 | } 131 | ]; 132 | 133 | export default class ProductInMemoryRepository implements ProductRepository { 134 | get(filter: string): Promise { 135 | return new Promise((resolve, reject) => { 136 | setTimeout(() => { 137 | if (filter) { 138 | const filteredProducts = products.filter((p: Product) => { 139 | return (p.title.toLowerCase().includes(filter.toLowerCase())); 140 | }); 141 | 142 | resolve(filteredProducts); 143 | } else { 144 | resolve(products); 145 | } 146 | }, 100); 147 | }); 148 | } 149 | } -------------------------------------------------------------------------------- /src/di/DependenciesProvider.ts: -------------------------------------------------------------------------------- 1 | import GetProductsUseCase from "../domain/products/GetProductsUseCase"; 2 | import ProductInMemoryRepository from "../data/products/ProductInMemoryRepository"; 3 | import { ProductsBloc } from "../presentation/products/ProductsBloc"; 4 | import GetCartUseCase from "../domain/cart/usecases/GetCartUseCase"; 5 | import CartInMemoryRepository from "../data/cart/CartInMemoryRepository"; 6 | import { CartBloc } from "../presentation/cart/CartBloc"; 7 | import AddProductToCartUseCase from "../domain/cart/usecases/AddProductToCartUseCase"; 8 | import RemoveItemFromCartUseCase from "../domain/cart/usecases/RemoveItemFromCartUseCase"; 9 | import EditQuantityOfCartItemUseCase from "../domain/cart/usecases/EditQuantityOfCartItemUseCase"; 10 | 11 | export function provideProductsBloc(): ProductsBloc { 12 | const productRepository = new ProductInMemoryRepository(); 13 | const getProductsUseCase = new GetProductsUseCase(productRepository); 14 | const productsPresenter = new ProductsBloc(getProductsUseCase); 15 | 16 | return productsPresenter; 17 | } 18 | 19 | export function provideCartBloc(): CartBloc { 20 | const cartRepository = new CartInMemoryRepository(); 21 | const getCartUseCase = new GetCartUseCase(cartRepository); 22 | const addProductToCartUseCase = new AddProductToCartUseCase(cartRepository); 23 | const removeItemFromCartUseCase = new RemoveItemFromCartUseCase(cartRepository); 24 | const editQuantityOfCartItemUseCase = new EditQuantityOfCartItemUseCase(cartRepository); 25 | const cartPresenter = new CartBloc( 26 | getCartUseCase, 27 | addProductToCartUseCase, 28 | removeItemFromCartUseCase, 29 | editQuantityOfCartItemUseCase); 30 | 31 | return cartPresenter; 32 | } -------------------------------------------------------------------------------- /src/domain/cart/Cart.ts: -------------------------------------------------------------------------------- 1 | import CartItem from "./CartItem"; 2 | 3 | export default class Cart { 4 | items: readonly CartItem[]; 5 | readonly totalPrice: number; 6 | readonly totalItems: number; 7 | 8 | constructor(items: CartItem[]) { 9 | this.items = items; 10 | this.totalPrice = this.calculateTotalPrice(items); 11 | this.totalItems = this.calculateTotalItems(items); 12 | } 13 | 14 | static createEmpty(): Cart { 15 | return new Cart([]); 16 | } 17 | 18 | addItem(item: CartItem): Cart { 19 | const existedItem = this.items.find(i => i.id === item.id); 20 | 21 | if (existedItem) { 22 | const newItems = this.items.map((oldItem) => { 23 | if (oldItem.id === item.id) { 24 | return { ...oldItem, quantity: oldItem.quantity + item.quantity }; 25 | } else { 26 | return oldItem; 27 | } 28 | }); 29 | 30 | return new Cart(newItems); 31 | } else { 32 | const newItems = [...this.items, item]; 33 | 34 | return new Cart(newItems); 35 | } 36 | } 37 | 38 | removeItem(itemId: string): Cart { 39 | const newItems = this.items.filter(i => i.id !== itemId); 40 | 41 | return new Cart(newItems); 42 | } 43 | 44 | editItem(itemId: string, quantity: number): Cart { 45 | const newItems = this.items.map((oldItem) => { 46 | if (oldItem.id === itemId) { 47 | return { ...oldItem, quantity: quantity }; 48 | } else { 49 | return oldItem; 50 | } 51 | }); 52 | 53 | return new Cart(newItems); 54 | } 55 | 56 | private calculateTotalPrice(items: CartItem[]): number { 57 | return +items.reduce((accumulator, item) => 58 | accumulator + (item.quantity * item.price), 0).toFixed(2); 59 | } 60 | 61 | private calculateTotalItems(items: CartItem[]): number { 62 | return +items.reduce((accumulator, item) => 63 | accumulator + item.quantity, 0); 64 | } 65 | } 66 | 67 | -------------------------------------------------------------------------------- /src/domain/cart/CartItem.ts: -------------------------------------------------------------------------------- 1 | export default interface CartItem { 2 | readonly id: string, 3 | readonly image: string, 4 | readonly title: string, 5 | readonly price: number, 6 | readonly quantity: number 7 | } 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/domain/cart/CartRepository.ts: -------------------------------------------------------------------------------- 1 | import Cart from "./Cart"; 2 | 3 | export default interface CartRepository { 4 | get():Promise; 5 | save(cart: Cart): Promise 6 | } -------------------------------------------------------------------------------- /src/domain/cart/__test__/Cart.test.ts: -------------------------------------------------------------------------------- 1 | import Cart from "../Cart"; 2 | import CartItem from "../CartItem"; 3 | 4 | describe('shopping cart', () => { 5 | 6 | describe('constructor', () => { 7 | 8 | it('should return totalPrice 0 and empty items if shopping cart is created using constructor with empty items', () => { 9 | const shoppingCart = new Cart([]); 10 | 11 | expect(shoppingCart.items).toEqual([]); 12 | expect(shoppingCart.totalPrice).toEqual(0); 13 | expect(shoppingCart.totalItems).toEqual(0); 14 | }); 15 | 16 | it('should return totalPrice equal to item price and item if shopping cart is created using constructor with 1 item', () => { 17 | const items = [givenAShoppingCartItem(1, 29.99)]; 18 | const shoppingCart = new Cart(items); 19 | 20 | expect(shoppingCart.items).toEqual(items); 21 | expect(shoppingCart.totalPrice).toEqual(29.99); 22 | expect(shoppingCart.totalItems).toEqual(1); 23 | }); 24 | 25 | it('should return expected totalPrice and items if shopping cart is created using constructor with 2 items with quantity = 1', () => { 26 | const items = [givenAShoppingCartItem(1, 29.99), givenAShoppingCartItem(1, 39.94)]; 27 | const shoppingCart = new Cart(items); 28 | 29 | expect(shoppingCart.items).toEqual((items)); 30 | expect(shoppingCart.totalPrice).toEqual(69.93); 31 | expect(shoppingCart.totalItems).toEqual(2); 32 | }); 33 | 34 | it('should return expected totalPrice and items if shopping cart is created using constructor with 2 items witn quantity > 1', () => { 35 | const items = [givenAShoppingCartItem(2, 29.99), givenAShoppingCartItem(5, 39.94)]; 36 | const shoppingCart = new Cart(items); 37 | 38 | expect(shoppingCart.items).toEqual(items); 39 | expect(shoppingCart.totalPrice).toEqual(259.68); 40 | expect(shoppingCart.totalItems).toEqual(7); 41 | }); 42 | 43 | }); 44 | 45 | describe('createEmpty', () => { 46 | it('should return totalPrice 0 and empty items if shopping cart is created using create empty', () => { 47 | const shoppingCart = Cart.createEmpty(); 48 | 49 | expect(shoppingCart.items).toEqual([]); 50 | expect(shoppingCart.totalPrice).toEqual(0); 51 | expect(shoppingCart.totalItems).toEqual(0); 52 | }); 53 | }); 54 | 55 | describe('addItem', () => { 56 | it('should return expected totalPrice and items if item with quantity 1 is added', () => { 57 | const items = [givenAShoppingCartItem(1, 29.99)]; 58 | const shoppingCart = new Cart(items); 59 | const newShoppingCart = shoppingCart.addItem(givenAShoppingCartItem(1, 39.94)); 60 | 61 | expect(newShoppingCart.items).toHaveLength(2); 62 | expect(newShoppingCart.totalPrice).toEqual(69.93); 63 | expect(newShoppingCart.totalItems).toEqual(2); 64 | }); 65 | 66 | it('should return expected totalPrice and items if item with quantity > 1 is added', () => { 67 | const items = [givenAShoppingCartItem(1, 29.99)]; 68 | const shoppingCart = new Cart(items); 69 | const newShoppingCart = shoppingCart.addItem(givenAShoppingCartItem(3, 39.94)); 70 | 71 | expect(newShoppingCart.items).toHaveLength(2); 72 | expect(newShoppingCart.totalPrice).toEqual(149.81); 73 | expect(newShoppingCart.totalItems).toEqual(4); 74 | }); 75 | 76 | it('should increment quantity to existed item and totalPrice if add a existed item again', () => { 77 | const items = [givenAShoppingCartItem(1, 29.99)]; 78 | const shoppingCart = new Cart(items); 79 | const newShoppingCart = shoppingCart.addItem(items[0]); 80 | 81 | expect(newShoppingCart.items).toHaveLength(1); 82 | expect(newShoppingCart.totalPrice).toEqual(59.98); 83 | expect(newShoppingCart.totalItems).toEqual(2); 84 | }); 85 | }); 86 | 87 | describe('removeItem', () => { 88 | it('should return totalPrice 0 and empty items if remove unique item', () => { 89 | const items = [givenAShoppingCartItem(1, 29.99)]; 90 | const shoppingCart = new Cart(items); 91 | const newShoppingCart = shoppingCart.removeItem(items[0]); 92 | 93 | expect(newShoppingCart.items).toEqual([]); 94 | expect(newShoppingCart.totalPrice).toEqual(0); 95 | expect(newShoppingCart.totalItems).toEqual(0); 96 | }); 97 | 98 | 99 | it('should return expected totalPrice and items if remove item', () => { 100 | const items = [givenAShoppingCartItem(1, 29.99), givenAShoppingCartItem(5, 39.94)]; 101 | const shoppingCart = new Cart(items); 102 | const newShoppingCart = shoppingCart.removeItem(items[1]); 103 | 104 | expect(newShoppingCart.items).toHaveLength(1); 105 | expect(newShoppingCart.totalPrice).toEqual(29.99); 106 | expect(newShoppingCart.totalItems).toEqual(1); 107 | }); 108 | }); 109 | 110 | describe('editItem', () => { 111 | it('should return expected totalPrice and items if edit quantity to unique item', () => { 112 | const items = [givenAShoppingCartItem(1, 29.99)]; 113 | const shoppingCart = new Cart(items); 114 | 115 | const newShoppingCart = shoppingCart.editItem(items[0],2); 116 | 117 | expect(newShoppingCart.items).toHaveLength(1) 118 | expect(newShoppingCart.totalPrice).toEqual(59.98); 119 | expect(newShoppingCart.totalItems).toEqual(2); 120 | }); 121 | 122 | 123 | it('should return expected totalPrice and items if edit quantity to a item', () => { 124 | const items = [givenAShoppingCartItem(1, 29.99), givenAShoppingCartItem(5, 39.94)]; 125 | const shoppingCart = new Cart(items); 126 | 127 | const newShoppingCart = shoppingCart.editItem(items[0],2); 128 | 129 | expect(newShoppingCart.items).toHaveLength(2) 130 | expect(newShoppingCart.totalPrice).toEqual(259.68); 131 | expect(newShoppingCart.totalItems).toEqual(7); 132 | }); 133 | }); 134 | }); 135 | 136 | function givenAShoppingCartItem(quantity: number = 1, price: number = 0): CartItem { 137 | return { 138 | id: Math.random().toString(36).substr(2, 9), 139 | image: "Fake image", 140 | title: "Fake title", 141 | price: price, 142 | quantity: quantity 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/domain/cart/usecases/AddProductToCartUseCase.ts: -------------------------------------------------------------------------------- 1 | import CartRepository from "../CartRepository"; 2 | import Cart from "../Cart"; 3 | import Product from "../../products/Product"; 4 | 5 | export default class AddProductToCartUseCase { 6 | private cartRepository: CartRepository; 7 | 8 | constructor(cartRepository: CartRepository) { 9 | this.cartRepository = cartRepository; 10 | } 11 | 12 | async execute(product: Product): Promise { 13 | const cart = await this.cartRepository.get(); 14 | 15 | const cartItem = { 16 | id: product.id, 17 | image: product.image, 18 | title: product.title, 19 | price: product.price, 20 | quantity: 1 21 | }; 22 | 23 | const editedCart = cart.addItem(cartItem); 24 | 25 | await this.cartRepository.save(editedCart); 26 | 27 | return editedCart; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/domain/cart/usecases/EditQuantityOfCartItemUseCase.ts: -------------------------------------------------------------------------------- 1 | import CartRepository from "../CartRepository"; 2 | import Cart from "../Cart"; 3 | 4 | export default class EditQuantityOfCartItemUseCase { 5 | private cartRepository: CartRepository; 6 | 7 | constructor(cartRepository: CartRepository) { 8 | this.cartRepository = cartRepository; 9 | } 10 | 11 | async execute(itemId: string, quantity: number): Promise { 12 | const cart = await this.cartRepository.get(); 13 | 14 | const editedCart = cart.editItem(itemId, quantity); 15 | 16 | await this.cartRepository.save(editedCart); 17 | 18 | return editedCart; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/domain/cart/usecases/GetCartUseCase.ts: -------------------------------------------------------------------------------- 1 | import CartRepository from "../CartRepository"; 2 | import Cart from "../Cart"; 3 | 4 | export default class GetCartUseCase { 5 | private cartRepository: CartRepository; 6 | 7 | constructor(cartRepository: CartRepository) { 8 | this.cartRepository = cartRepository; 9 | } 10 | 11 | execute(): Promise{ 12 | return this.cartRepository.get(); 13 | } 14 | } -------------------------------------------------------------------------------- /src/domain/cart/usecases/RemoveItemFromCartUseCase.ts: -------------------------------------------------------------------------------- 1 | import CartRepository from "../CartRepository"; 2 | import Cart from "../Cart"; 3 | 4 | export default class RemoveItemFromCartUseCase { 5 | private cartRepository: CartRepository; 6 | 7 | constructor(cartRepository: CartRepository) { 8 | this.cartRepository = cartRepository; 9 | } 10 | 11 | async execute(itemId: string): Promise { 12 | const cart = await this.cartRepository.get(); 13 | 14 | const editedCart = cart.removeItem(itemId); 15 | 16 | await this.cartRepository.save(editedCart); 17 | 18 | return editedCart; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/domain/products/GetProductsUseCase.ts: -------------------------------------------------------------------------------- 1 | import ProductRepository from "./ProductRepository"; 2 | import Product from "./Product"; 3 | 4 | 5 | export default class GetProductsUseCase { 6 | private productRepository: ProductRepository; 7 | 8 | constructor(productRepository: ProductRepository) { 9 | this.productRepository = productRepository; 10 | } 11 | 12 | execute(filter:string): Promise>{ 13 | return this.productRepository.get(filter); 14 | } 15 | } -------------------------------------------------------------------------------- /src/domain/products/Product.ts: -------------------------------------------------------------------------------- 1 | export default interface Product { 2 | id:string 3 | image: string, 4 | title: string, 5 | price: number 6 | } -------------------------------------------------------------------------------- /src/domain/products/ProductRepository.ts: -------------------------------------------------------------------------------- 1 | import Product from "./Product"; 2 | 3 | 4 | export default interface ProductRepository { 5 | get(filter: string):Promise> ; 6 | } -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import CssBaseline from "@material-ui/core/CssBaseline"; 4 | import {ThemeProvider} from "@material-ui/core/styles"; 5 | import App from "./presentation/app/App"; 6 | import theme from "./presentation/theme"; 7 | 8 | ReactDOM.render( 9 | 10 | {/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */} 11 | 12 | 13 | , 14 | document.querySelector("#root") 15 | ); 16 | -------------------------------------------------------------------------------- /src/presentation/app/App.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import MyAppBar from "../appbar/MyAppBar"; 3 | import ProductList from "../products/components/ProductList"; 4 | import CartDrawer from "../cart/components/CartDrawer"; 5 | import {createContext} from "../common/bloc/Context"; 6 | import * as DependenciesProvider from "../../di/DependenciesProvider"; 7 | import {CartBloc} from "../cart/CartBloc"; 8 | 9 | const [blocContext, useBloc] = createContext(); 10 | 11 | export const useCartBloc = useBloc; 12 | 13 | const App: React.FC = () => { 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | ); 21 | }; 22 | 23 | export default App; 24 | -------------------------------------------------------------------------------- /src/presentation/app/__test__/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from '../App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /src/presentation/appbar/Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xurxodev/shopping-cart-react/143891970fa69feb7117fff9d14425a02ed8180d/src/presentation/appbar/Logo.png -------------------------------------------------------------------------------- /src/presentation/appbar/MyAppBar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {makeStyles, Theme} from "@material-ui/core/styles"; 3 | import AppBar from "@material-ui/core/AppBar"; 4 | import Toolbar from "@material-ui/core/Toolbar"; 5 | import IconButton from "@material-ui/core/IconButton"; 6 | import Badge from "@material-ui/core/Badge"; 7 | import ShoppingCartIcon from "@material-ui/icons/ShoppingCart"; 8 | import logo from "./Logo.png"; 9 | import {useCartBloc} from "../app/App"; 10 | import {UpdatedCartState, CartState} from "../cart/CartState"; 11 | import {BlocBuilder} from "../common/bloc"; 12 | 13 | const useStyles = makeStyles((theme: Theme) => ({ 14 | toolbar: { 15 | justifyContent: "space-between", 16 | maxWidth: "800", 17 | }, 18 | })); 19 | 20 | const MyAppBar: React.FC = () => { 21 | const classes = useStyles(); 22 | const bloc = useCartBloc(); 23 | 24 | return ( 25 | 26 | 27 | logo 28 | 29 | { 32 | const totalItems = 33 | bloc.state.kind === "UpdatedCartState" 34 | ? (bloc.state as UpdatedCartState).totalItems 35 | : 0; 36 | 37 | return ( 38 | 39 | bloc.openCart()} /> 40 | 41 | ); 42 | }} 43 | /> 44 | 45 | 46 | 47 | ); 48 | }; 49 | 50 | export default MyAppBar; 51 | -------------------------------------------------------------------------------- /src/presentation/cart/CartBloc.ts: -------------------------------------------------------------------------------- 1 | import GetCartUseCase from "../../domain/cart/usecases/GetCartUseCase"; 2 | import Cart from "../../domain/cart/Cart"; 3 | import AddProductToCartUseCase from "../../domain/cart/usecases/AddProductToCartUseCase"; 4 | import Product from "../../domain/products/Product"; 5 | import RemoveItemFromCartUseCase from "../../domain/cart/usecases/RemoveItemFromCartUseCase"; 6 | import EditQuantityOfCartItemUseCase from "../../domain/cart/usecases/EditQuantityOfCartItemUseCase"; 7 | import { CartState, cartInitialState, CartItemState } from "./CartState"; 8 | import { Bloc } from "../common/bloc"; 9 | 10 | export class CartBloc extends Bloc { 11 | constructor( 12 | private getCartUseCase: GetCartUseCase, 13 | private addProductToCartUseCase: AddProductToCartUseCase, 14 | private removeItemFromCartUseCase: RemoveItemFromCartUseCase, 15 | private editQuantityOfCartItemUseCase: EditQuantityOfCartItemUseCase) { 16 | super(cartInitialState); 17 | this.loadCart(); 18 | } 19 | 20 | closeCart() { 21 | this.changeState({ ...this.state, open: false }); 22 | } 23 | 24 | openCart() { 25 | this.changeState({ ...this.state, open: true }); 26 | } 27 | 28 | removeCartItem(item: CartItemState) { 29 | this.removeItemFromCartUseCase.execute(item.id) 30 | .then(cart => this.changeState(this.mapToUpdatedState(cart))) 31 | } 32 | 33 | editQuantityCartItem(item: CartItemState, quantity: number) { 34 | this.editQuantityOfCartItemUseCase.execute(item.id, quantity) 35 | .then(cart => this.changeState(this.mapToUpdatedState(cart))) 36 | } 37 | 38 | addProductToCart(product: Product) { 39 | this.addProductToCartUseCase.execute(product) 40 | .then(cart => this.changeState(this.mapToUpdatedState(cart))) 41 | } 42 | 43 | private loadCart() { 44 | this.getCartUseCase.execute() 45 | .then(cart => this.changeState(this.mapToUpdatedState(cart))) 46 | .catch(() => this.changeState({ 47 | kind: "ErrorCartState", 48 | error: "An error has ocurred loading products", 49 | open: this.state.open 50 | })); 51 | } 52 | 53 | mapToUpdatedState(cart: Cart): CartState { 54 | const formatOptions = { style: "currency", currency: "EUR" }; 55 | 56 | return { 57 | kind: "UpdatedCartState", 58 | open: this.state.open, 59 | totalItems: cart.totalItems, 60 | totalPrice: cart.totalPrice.toLocaleString("es-ES", formatOptions), 61 | items: cart.items.map(cartItem => { 62 | return { 63 | id: cartItem.id, 64 | image: cartItem.image, 65 | title: cartItem.title, 66 | price: cartItem.price.toLocaleString("es-ES", formatOptions), 67 | quantity: cartItem.quantity 68 | } 69 | }) 70 | } 71 | } 72 | } -------------------------------------------------------------------------------- /src/presentation/cart/CartState.ts: -------------------------------------------------------------------------------- 1 | export interface CommonCartState { 2 | open: boolean; 3 | } 4 | 5 | export interface LoadingCartState { 6 | kind: "LoadingCartState" 7 | } 8 | 9 | export interface UpdatedCartState { 10 | kind: "UpdatedCartState"; 11 | items: Array; 12 | totalPrice: string; 13 | totalItems: number; 14 | } 15 | 16 | export interface ErrorCartState { 17 | kind: "ErrorCartState" 18 | error: string; 19 | } 20 | 21 | export type CartState = (LoadingCartState | UpdatedCartState | ErrorCartState) & CommonCartState 22 | 23 | export interface CartItemState { 24 | id: string; 25 | image: string; 26 | title: string; 27 | price: string; 28 | quantity: number; 29 | } 30 | 31 | export const cartInitialState: CartState = { 32 | kind: "LoadingCartState", 33 | open: false 34 | } -------------------------------------------------------------------------------- /src/presentation/cart/components/CartContent.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {makeStyles, Theme} from "@material-ui/core/styles"; 3 | import {List, Divider, Box, Typography, CircularProgress} from "@material-ui/core"; 4 | import CartContentItem from "./CartContentItem"; 5 | import {CartState, CartItemState} from "../CartState"; 6 | import {useCartBloc} from "../../app/App"; 7 | import {BlocBuilder} from "../../common/bloc"; 8 | 9 | const useStyles = makeStyles((theme: Theme) => ({ 10 | totalPriceContainer: { 11 | display: "flex", 12 | alignItems: "center", 13 | padding: theme.spacing(1, 0), 14 | justifyContent: "space-around", 15 | }, 16 | itemsContainer: { 17 | display: "flex", 18 | alignItems: "center", 19 | padding: theme.spacing(1, 0), 20 | justifyContent: "space-around", 21 | minHeight: 150, 22 | }, 23 | itemsList: { 24 | overflow: "scroll", 25 | }, 26 | infoContainer: { 27 | display: "flex", 28 | alignItems: "center", 29 | justifyContent: "center", 30 | height: "100vh", 31 | }, 32 | })); 33 | 34 | const CartContent: React.FC = () => { 35 | const classes = useStyles(); 36 | const bloc = useCartBloc(); 37 | 38 | const cartItems = (items: CartItemState[]) => ( 39 | 40 | {items.map((item, index) => ( 41 | 42 | ))} 43 | 44 | ); 45 | 46 | const emptyCartItems = () => ( 47 | 48 | 49 | Empty Cart :( 50 | 51 | 52 | ); 53 | 54 | return ( 55 | { 58 | switch (state.kind) { 59 | case "LoadingCartState": { 60 | return ( 61 |
62 | 63 |
64 | ); 65 | } 66 | case "ErrorCartState": { 67 | return ( 68 |
69 | 70 | {state.error} 71 | 72 |
73 | ); 74 | } 75 | case "UpdatedCartState": { 76 | return ( 77 | 78 | 79 | {state.items.length > 0 80 | ? cartItems(state.items) 81 | : emptyCartItems()} 82 | 83 | 84 | 85 | 86 | Total Price 87 | 88 | 89 | {state.totalPrice} 90 | 91 | 92 | 93 | ); 94 | } 95 | } 96 | }} 97 | /> 98 | ); 99 | }; 100 | 101 | export default CartContent; 102 | -------------------------------------------------------------------------------- /src/presentation/cart/components/CartContentItem.tsx: -------------------------------------------------------------------------------- 1 | import React, {Key} from "react"; 2 | import {makeStyles, Theme} from "@material-ui/core/styles"; 3 | import { 4 | ListItem, 5 | ListItemText, 6 | ListItemSecondaryAction, 7 | IconButton, 8 | TextField, 9 | Paper, 10 | Box, 11 | Typography, 12 | } from "@material-ui/core"; 13 | import RemoveIcon from "@material-ui/icons/Clear"; 14 | import {useCartBloc} from "../../app/App"; 15 | import {CartItemState} from "../CartState"; 16 | 17 | const useStyles = makeStyles((theme: Theme) => ({ 18 | itemContainer: { 19 | margin: theme.spacing(1), 20 | }, 21 | itemImage: { 22 | padding: theme.spacing(0, 1), 23 | backgroundSize: "auto 100%", 24 | }, 25 | secondContainer: { 26 | display: "flex", 27 | alignItems: "center", 28 | padding: theme.spacing(1, 0), 29 | justifyContent: "space-around", 30 | }, 31 | quantityField: { 32 | marginTop: theme.spacing(1), 33 | width: 60, 34 | }, 35 | })); 36 | 37 | interface CartProps { 38 | key: Key; 39 | cartItem: CartItemState; 40 | } 41 | 42 | const CartContentItem: React.FC = ({key, cartItem}) => { 43 | const classes = useStyles(); 44 | const bloc = useCartBloc(); 45 | 46 | return ( 47 | 48 | 49 | 50 | {cartItem.title} 56 | 60 | 71 | bloc.editQuantityCartItem(cartItem, +event.target.value) 72 | } 73 | /> 74 | {cartItem.price} 75 | 76 | } 77 | /> 78 | 79 | 80 | bloc.removeCartItem(cartItem)} /> 81 | 82 | 83 | 84 | 85 | 86 | ); 87 | }; 88 | 89 | export default CartContentItem; 90 | -------------------------------------------------------------------------------- /src/presentation/cart/components/CartDrawer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {makeStyles, Theme} from "@material-ui/core/styles"; 3 | import {Drawer, IconButton, Divider, Typography, Box} from "@material-ui/core"; 4 | import ChevronLeftIcon from "@material-ui/icons/ChevronLeft"; 5 | import ShoppingCartIcon from "@material-ui/icons/ShoppingCart"; 6 | import CartContent from "./CartContent"; 7 | import {useCartBloc} from "../../app/App"; 8 | import {CartState} from "../CartState"; 9 | import {BlocBuilder} from "../../common/bloc"; 10 | 11 | const drawerWidth = 350; 12 | 13 | const useStyles = makeStyles((theme: Theme) => ({ 14 | drawer: { 15 | width: drawerWidth, 16 | }, 17 | drawerPaper: { 18 | width: drawerWidth, 19 | }, 20 | drawerHeader: { 21 | display: "flex", 22 | alignItems: "center", 23 | padding: theme.spacing(1, 0), 24 | justifyContent: "flex-start", 25 | }, 26 | drawerTitleContainer: { 27 | width: "100%", 28 | display: "flex", 29 | alignItems: "center", 30 | justifyContent: "center", 31 | }, 32 | drawerTitleIcon: { 33 | marginRight: theme.spacing(1), 34 | }, 35 | })); 36 | 37 | const CartDrawer: React.FC = () => { 38 | const classes = useStyles(); 39 | const bloc = useCartBloc(); 40 | 41 | return ( 42 | { 45 | return ( 46 | 54 | 55 | bloc.closeCart()}> 56 | 57 | 58 | 59 | 60 | 61 | Cart 62 | 63 | 64 | 65 | 66 | 67 | 68 | ); 69 | }} 70 | /> 71 | ); 72 | }; 73 | 74 | export default CartDrawer; 75 | -------------------------------------------------------------------------------- /src/presentation/common/bloc/Bloc.ts: -------------------------------------------------------------------------------- 1 | type Subscription = (state: S) => void; 2 | 3 | abstract class Bloc { 4 | private internalState: S; 5 | private listeners: Subscription[] = []; 6 | 7 | constructor(initalState: S) { 8 | this.internalState = initalState; 9 | } 10 | 11 | public get state(): S { 12 | return this.internalState; 13 | } 14 | 15 | changeState(state: S) { 16 | debugger; 17 | this.internalState = state; 18 | 19 | if (this.listeners.length > 0) { 20 | this.listeners.forEach(listener => listener(this.state)); 21 | } 22 | } 23 | 24 | subscribe(listener: Subscription) { 25 | this.listeners.push(listener); 26 | } 27 | 28 | unsubscribe(listener: Subscription) { 29 | const index = this.listeners.indexOf(listener); 30 | if (index > -1) { 31 | this.listeners.splice(index, 1); 32 | } 33 | } 34 | } 35 | 36 | export default Bloc; 37 | -------------------------------------------------------------------------------- /src/presentation/common/bloc/BlocBuilder.tsx: -------------------------------------------------------------------------------- 1 | import {useState, useEffect} from "react"; 2 | import Bloc from "./Bloc"; 3 | 4 | interface BlocBuilderProps, S> { 5 | bloc: B; 6 | builder: (state: S) => JSX.Element; 7 | } 8 | 9 | const BlocBuilder = , S>({bloc, builder}: BlocBuilderProps) => { 10 | const [state, setState] = useState(bloc.state); 11 | 12 | useEffect(() => { 13 | const stateSubscription = (state: S) => { 14 | setState(state); 15 | }; 16 | 17 | bloc.subscribe(stateSubscription); 18 | 19 | return () => bloc.unsubscribe(stateSubscription); 20 | }, [bloc]); 21 | 22 | return builder(state); 23 | }; 24 | 25 | export default BlocBuilder; 26 | -------------------------------------------------------------------------------- /src/presentation/common/bloc/Context.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export function createContext() { 4 | const context = React.createContext(undefined); 5 | 6 | function useContext() { 7 | const ctx = React.useContext(context); 8 | if (!ctx) throw new Error("context must be inside a Provider with a value"); 9 | return ctx; 10 | } 11 | return [context, useContext] as const; 12 | } 13 | -------------------------------------------------------------------------------- /src/presentation/common/bloc/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Bloc"; 2 | 3 | export { default as Bloc } from "./Bloc"; 4 | export { default as BlocBuilder } from "./BlocBuilder"; 5 | -------------------------------------------------------------------------------- /src/presentation/products/ProductsBloc.ts: -------------------------------------------------------------------------------- 1 | import GetProductsUseCase from "../../domain/products/GetProductsUseCase"; 2 | import { Bloc } from "../common/bloc"; 3 | import { ProductsState, productsInitialState } from "./ProductsState"; 4 | 5 | export class ProductsBloc extends Bloc { 6 | constructor(private getProductsUseCase: GetProductsUseCase) { 7 | super(productsInitialState) 8 | } 9 | 10 | search(filter: string) { 11 | this.getProductsUseCase.execute(filter) 12 | .then(products => this.changeState({ 13 | kind: "LoadedProductsState", 14 | products: products, 15 | searchTerm: this.state.searchTerm 16 | })) 17 | .catch(error => this.changeState({ 18 | kind: "ErrorProductsState", 19 | error: "An error has ocurred loading products", 20 | searchTerm: this.state.searchTerm 21 | })); 22 | } 23 | } -------------------------------------------------------------------------------- /src/presentation/products/ProductsState.ts: -------------------------------------------------------------------------------- 1 | import Product from "../../domain/products/Product"; 2 | 3 | export interface CommonProductsState { 4 | searchTerm: string 5 | } 6 | 7 | export interface LoadingProductsState { 8 | kind: "LoadingProductsState" 9 | } 10 | 11 | export interface LoadedProductsState { 12 | kind: "LoadedProductsState" 13 | products: Array; 14 | } 15 | 16 | export interface ErrorProductsState { 17 | kind: "ErrorProductsState" 18 | error: string; 19 | } 20 | 21 | export type ProductsState = (LoadingProductsState | LoadedProductsState | ErrorProductsState) & CommonProductsState 22 | 23 | export const productsInitialState: ProductsState = { 24 | kind: "LoadingProductsState", 25 | searchTerm: "" 26 | } -------------------------------------------------------------------------------- /src/presentation/products/components/ProductItem.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {makeStyles} from "@material-ui/core/styles"; 3 | import { 4 | Grid, 5 | Card, 6 | CardMedia, 7 | CardContent, 8 | Typography, 9 | CardActions, 10 | Button, 11 | } from "@material-ui/core"; 12 | import Product from "../../../domain/products/Product"; 13 | import {useCartBloc} from "../../app/App"; 14 | 15 | const useStyles = makeStyles((theme) => ({ 16 | card: { 17 | height: "100%", 18 | display: "flex", 19 | flexDirection: "column", 20 | }, 21 | cardMedia: { 22 | backgroundSize: "auto 100%", 23 | paddingTop: "100%", // 16:9, 24 | margin: theme.spacing(1), 25 | }, 26 | cardContent: { 27 | flexGrow: 1, 28 | }, 29 | cardActions: { 30 | justifyContent: "center", 31 | }, 32 | productTitle: { 33 | overflow: "hidden", 34 | textOverflow: "ellipsis", 35 | height: 50, 36 | }, 37 | productPrice: { 38 | textAlign: "center", 39 | }, 40 | })); 41 | 42 | interface ProductListProps { 43 | product: Product; 44 | } 45 | 46 | const ProductItem: React.FC = ({product}) => { 47 | const classes = useStyles(); 48 | const bloc = useCartBloc(); 49 | 50 | return ( 51 | 52 | 53 | 58 | 59 | 60 | {product.title} 61 | 62 | 63 | {product.price.toLocaleString("es-ES", { 64 | style: "currency", 65 | currency: "EUR", 66 | })} 67 | 68 | 69 | 70 | 77 | 78 | 79 | 80 | ); 81 | }; 82 | 83 | export default ProductItem; 84 | -------------------------------------------------------------------------------- /src/presentation/products/components/ProductList.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {makeStyles} from "@material-ui/core/styles"; 3 | import {CircularProgress, Grid, Container, Box, Typography} from "@material-ui/core"; 4 | import ProductItem from "./ProductItem"; 5 | import * as DependenciesProvider from "../../../di/DependenciesProvider"; 6 | import {BlocBuilder} from "../../common/bloc"; 7 | import {ProductsState} from "../ProductsState"; 8 | 9 | const useStyles = makeStyles((theme) => ({ 10 | titleContainer: { 11 | marginBottom: theme.spacing(4), 12 | }, 13 | cardGrid: { 14 | paddingTop: theme.spacing(4), 15 | paddingBottom: theme.spacing(4), 16 | }, 17 | infoContainer: { 18 | display: "flex", 19 | alignItems: "center", 20 | justifyContent: "center", 21 | height: "100vh", 22 | }, 23 | })); 24 | 25 | const ProductList: React.FC = () => { 26 | const bloc = DependenciesProvider.provideProductsBloc(); 27 | const classes = useStyles(); 28 | 29 | React.useEffect(() => { 30 | const searchProducts = async (filter: string) => { 31 | bloc.search(filter); 32 | }; 33 | 34 | searchProducts("Element"); 35 | }, [bloc]); 36 | 37 | return ( 38 | { 41 | switch (state.kind) { 42 | case "LoadingProductsState": { 43 | return ( 44 |
45 | 46 |
47 | ); 48 | } 49 | case "ErrorProductsState": { 50 | return ( 51 |
52 | 53 | {state.error} 54 | 55 |
56 | ); 57 | } 58 | case "LoadedProductsState": { 59 | return ( 60 | 61 | 62 | 63 | {"Results for "} 64 | 65 | 71 | "Element" 72 | 73 | 74 | 75 | {state.products.map((product, index) => ( 76 | 77 | ))} 78 | 79 | 80 | ); 81 | } 82 | } 83 | }} 84 | /> 85 | ); 86 | }; 87 | 88 | export default ProductList; 89 | -------------------------------------------------------------------------------- /src/presentation/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/presentation/theme.tsx: -------------------------------------------------------------------------------- 1 | import {red, blue, grey} from '@material-ui/core/colors'; 2 | import { createMuiTheme } from '@material-ui/core/styles'; 3 | 4 | // A custom theme for this app 5 | const theme = createMuiTheme({ 6 | palette: { 7 | primary: { 8 | main: blue.A400, 9 | }, 10 | secondary: { 11 | main: red.A700, 12 | }, 13 | error: { 14 | main: red.A400, 15 | }, 16 | background: { 17 | default: grey[50], 18 | } 19 | }, 20 | }); 21 | 22 | export default theme; -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react" 21 | }, 22 | "include": [ 23 | "src" 24 | ] 25 | } 26 | --------------------------------------------------------------------------------