├── .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 | 
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 | 
16 | 
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 | 
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 | You need to enable JavaScript to run this app.
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 |
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 |
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 | bloc.addProductToCart(product)}
74 | >
75 | Add to Cart
76 |
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 |
--------------------------------------------------------------------------------