├── .babelrc ├── .gitignore ├── .prettierrc ├── .size-snapshot.json ├── .travis.yml ├── __tests__ └── use-cart.test.js ├── license ├── package.json ├── readme.md ├── rollup.config.js ├── src ├── reducer.js └── use-cart.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-react", 4 | [ 5 | "@babel/preset-env", 6 | { 7 | "modules": false 8 | } 9 | ] 10 | ], 11 | "env": { 12 | "test": { 13 | "presets": [["@babel/preset-env"]] 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .DS_Store 4 | *.log 5 | dist -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { "semi": false } 2 | -------------------------------------------------------------------------------- /.size-snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "dist/use-cart.umd.js": { 3 | "bundled": 6854, 4 | "minified": 3085, 5 | "gzipped": 1228 6 | }, 7 | "dist/use-cart.cjs.js": { 8 | "bundled": 5982, 9 | "minified": 3494, 10 | "gzipped": 1273 11 | }, 12 | "dist/use-cart.esm.js": { 13 | "bundled": 5725, 14 | "minified": 3279, 15 | "gzipped": 1199, 16 | "treeshaked": { 17 | "rollup": { 18 | "code": 42, 19 | "import_statements": 38 20 | }, 21 | "webpack": { 22 | "code": 1030 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - v8 4 | cache: yarn 5 | script: 6 | - yarn test 7 | - yarn coveralls 8 | -------------------------------------------------------------------------------- /__tests__/use-cart.test.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { renderHook, act, cleanup } from "react-hooks-testing-library" 3 | 4 | import { CartProvider, useCart } from "../src/use-cart" 5 | 6 | afterEach(cleanup) 7 | 8 | describe("", () => { 9 | test("given a default state will use that instead of empty array", () => { 10 | const initialCart = [{ sku: "TEST_SKU_0", quantity: 2 }] 11 | const StatefulCartProvider = ({ children }) => ( 12 | {children} 13 | ) 14 | const { result } = renderHook(() => useCart(), { 15 | wrapper: StatefulCartProvider 16 | }) 17 | expect(result.current.items).toEqual(initialCart) 18 | }) 19 | }) 20 | 21 | describe("useCart()", () => { 22 | describe("items", () => { 23 | test("default cart is an empty array", () => { 24 | const { result } = renderHook(() => useCart(), { wrapper: CartProvider }) 25 | expect(result.current.items).toBeInstanceOf(Array) 26 | expect(result.current.items).toHaveLength(0) 27 | }) 28 | }) 29 | 30 | describe("addItem", () => { 31 | test("adds SKU to cart object", () => { 32 | const { result } = renderHook(() => useCart(), { wrapper: CartProvider }) 33 | act(() => result.current.addItem("TEST_SKU")) 34 | expect(result.current.items).toContainEqual( 35 | expect.objectContaining({ sku: "TEST_SKU" }) 36 | ) 37 | expect(result.current.items).toContainEqual( 38 | expect.objectContaining({ quantity: 1 }) 39 | ) 40 | }) 41 | 42 | test("adding SKU to cart increments quantity to 1 by default", () => { 43 | const { result } = renderHook(() => useCart(), { wrapper: CartProvider }) 44 | act(() => result.current.addItem("TEST_SKU")) 45 | 46 | expect(result.current.items).toContainEqual( 47 | expect.objectContaining({ quantity: 1 }) 48 | ) 49 | }) 50 | 51 | test("adding a custom quantity will set that to the amount in the line item", () => { 52 | const { result } = renderHook(() => useCart(), { wrapper: CartProvider }) 53 | act(() => result.current.addItem("TEST_SKU", 20)) 54 | 55 | expect(result.current.items).toContainEqual( 56 | expect.objectContaining({ quantity: 20 }) 57 | ) 58 | }) 59 | 60 | test("adding the same SKU a second time will increment the quantity and not add a new line item", () => { 61 | const { result } = renderHook(() => useCart(), { wrapper: CartProvider }) 62 | act(() => result.current.addItem("TEST_SKU")) 63 | act(() => result.current.addItem("TEST_SKU")) 64 | 65 | expect(result.current.items).toHaveLength(1) 66 | expect(result.current.items).toContainEqual( 67 | expect.objectContaining({ quantity: 2 }) 68 | ) 69 | }) 70 | 71 | test("adding a new SKU will create a new line item in the cart object", () => { 72 | const { result } = renderHook(() => useCart(), { wrapper: CartProvider }) 73 | act(() => result.current.addItem("TEST_SKU")) 74 | act(() => result.current.addItem("TEST_SKU_2")) 75 | 76 | expect(result.current.items).toHaveLength(2) 77 | expect(result.current.items).toContainEqual( 78 | expect.objectContaining({ sku: "TEST_SKU", quantity: 1 }) 79 | ) 80 | expect(result.current.items).toContainEqual( 81 | expect.objectContaining({ sku: "TEST_SKU_2", quantity: 1 }) 82 | ) 83 | }) 84 | }) 85 | 86 | describe("removeItem", () => { 87 | test("will remove 1 quantity by default", () => { 88 | const { result } = renderHook(() => useCart(), { wrapper: CartProvider }) 89 | act(() => result.current.addItem("TEST_SKU", 2)) 90 | act(() => result.current.removeItem("TEST_SKU")) 91 | 92 | expect(result.current.items).toHaveLength(1) 93 | expect(result.current.items).toContainEqual( 94 | expect.objectContaining({ quantity: 1 }) 95 | ) 96 | }) 97 | 98 | test("will maintain position in array when item is removed", () => { 99 | const { result } = renderHook(() => useCart(), { wrapper: CartProvider }) 100 | act(() => result.current.addItem("TEST_SKU_0", 2)) 101 | act(() => result.current.addItem("TEST_SKU_1", 2)) 102 | act(() => result.current.addItem("TEST_SKU_2", 2)) 103 | act(() => result.current.removeItem("TEST_SKU_1")) 104 | 105 | expect(result.current.items).toHaveLength(3) 106 | expect(result.current.items[1]).toEqual({ 107 | quantity: 1, 108 | sku: "TEST_SKU_1" 109 | }) 110 | }) 111 | 112 | test("will remove line item when quantity removed is greater than quantity in cart object", () => { 113 | const { result } = renderHook(() => useCart(), { wrapper: CartProvider }) 114 | act(() => result.current.addItem("TEST_SKU_0", 2)) 115 | act(() => result.current.addItem("TEST_SKU_1", 2)) 116 | act(() => result.current.addItem("TEST_SKU_2", 2)) 117 | act(() => result.current.removeItem("TEST_SKU_1", 2)) 118 | 119 | expect(result.current.items).toHaveLength(2) 120 | }) 121 | 122 | test("won't change anything when a non existent SKU is attempted to be removed", () => { 123 | const { result } = renderHook(() => useCart(), { wrapper: CartProvider }) 124 | act(() => result.current.addItem("TEST_SKU_0", 2)) 125 | act(() => result.current.addItem("TEST_SKU_2", 2)) 126 | act(() => result.current.removeItem("TEST_SKU_1", 2)) 127 | 128 | expect(result.current.items).toHaveLength(2) 129 | }) 130 | }) 131 | 132 | describe("lineItemsCount", () => { 133 | test("default is 0 with empty cart", () => { 134 | const { result } = renderHook(() => useCart(), { wrapper: CartProvider }) 135 | 136 | expect(result.current.lineItemsCount).toBe(0) 137 | }) 138 | 139 | test("is 1 after item is added", () => { 140 | const { result } = renderHook(() => useCart(), { wrapper: CartProvider }) 141 | act(() => result.current.addItem("TEST_SKU_0", 2)) 142 | 143 | expect(result.current.lineItemsCount).toBe(1) 144 | }) 145 | }) 146 | 147 | describe("itemsCount", () => { 148 | test("default is 0 with empty cart", () => { 149 | const { result } = renderHook(() => useCart(), { wrapper: CartProvider }) 150 | 151 | expect(result.current.itemsCount).toBe(0) 152 | }) 153 | 154 | test("is 2 after item is added with quantity of 2 is added", () => { 155 | const { result } = renderHook(() => useCart(), { wrapper: CartProvider }) 156 | act(() => result.current.addItem("TEST_SKU_0", 2)) 157 | 158 | expect(result.current.itemsCount).toBe(2) 159 | }) 160 | 161 | test("is 0 after items are added and then removed", () => { 162 | const { result } = renderHook(() => useCart(), { wrapper: CartProvider }) 163 | act(() => result.current.addItem("TEST_SKU_0", 2)) 164 | expect(result.current.itemsCount).toBe(2) 165 | 166 | act(() => result.current.removeItem("TEST_SKU_0", 2)) 167 | expect(result.current.itemsCount).toBe(0) 168 | }) 169 | }) 170 | 171 | describe("removeLineItem", () => { 172 | test("removes entire line item", () => { 173 | const { result } = renderHook(() => useCart(), { wrapper: CartProvider }) 174 | 175 | act(() => result.current.addItem("TEST_SKU_0", 2)) 176 | act(() => result.current.removeLineItem("TEST_SKU_0")) 177 | 178 | expect(result.current.lineItemsCount).toBe(0) 179 | 180 | act(() => result.current.addItem("TEST_SKU_0", 2)) 181 | act(() => result.current.addItem("TEST_SKU_1", 2)) 182 | 183 | act(() => result.current.removeLineItem("TEST_SKU_0")) 184 | 185 | expect(result.current.lineItemsCount).toBe(1) 186 | }) 187 | }) 188 | 189 | describe("clearCart", () => { 190 | test("clears cart to empty array", () => { 191 | const { result } = renderHook(() => useCart(), { wrapper: CartProvider }) 192 | 193 | act(() => result.current.addItem("TEST_SKU_0", 2)) 194 | act(() => result.current.addItem("TEST_SKU_1", 2)) 195 | 196 | expect(result.current.lineItemsCount).toBe(2) 197 | 198 | act(() => result.current.clearCart()) 199 | 200 | expect(result.current.lineItemsCount).toBe(0) 201 | }) 202 | }) 203 | 204 | describe("isInCart", () => { 205 | test("returns true if sku is found in cart object", () => { 206 | const { result } = renderHook(() => useCart(), { wrapper: CartProvider }) 207 | 208 | act(() => result.current.addItem("TEST_SKU_0", 1)) 209 | 210 | expect(result.current.isInCart("TEST_SKU_0")).toBe(true) 211 | }) 212 | }) 213 | }) 214 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Sam Mason de Caires https://masondecair.es 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. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "use-cart", 3 | "version": "1.1.0", 4 | "description": "A tiny react library for integrating an e-commerce cart into your app.", 5 | "repository": "samjbmason/use-cart", 6 | "homepage": "https://github.com/samjbmason/use-cart#readme", 7 | "author": "Sam Mason de Caires ", 8 | "license": "MIT", 9 | "keywords": [ 10 | "react", 11 | "hooks", 12 | "react-dom", 13 | "use-cart", 14 | "ecommerce", 15 | "cart", 16 | "state" 17 | ], 18 | "main": "dist/use-cart.cjs.js", 19 | "module": "dist/use-cart.esm.js", 20 | "files": [ 21 | "dist" 22 | ], 23 | "scripts": { 24 | "build": "rollup -c", 25 | "prepare": "npm run build", 26 | "dev": "rollup -c -w", 27 | "test": "jest", 28 | "test:watch": "jest --watch", 29 | "coveralls": "jest --coverage && cat coverage/lcov.info | coveralls" 30 | }, 31 | "peerDependencies": { 32 | "react": ">=16.8", 33 | "react-dom": ">=16.8" 34 | }, 35 | "devDependencies": { 36 | "@babel/core": "^7.4.3", 37 | "@babel/preset-env": "^7.4.3", 38 | "@babel/preset-react": "^7.0.0", 39 | "coveralls": "^3.0.3", 40 | "jest": "^24.7.1", 41 | "react": "^16.8.6", 42 | "react-dom": "^16.8.6", 43 | "react-hooks-testing-library": "^0.4.0", 44 | "rollup": "^1.9.0", 45 | "rollup-plugin-babel": "^4.3.2", 46 | "rollup-plugin-size-snapshot": "^0.8.0", 47 | "rollup-plugin-sourcemaps": "^0.4.2", 48 | "rollup-plugin-terser": "^4.0.4" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # `use-cart` 2 | 3 | > A tiny react hook for creating a e-commerce cart in your app 4 | 5 | npm version Build Status Coverage Status dependencies package size 6 | 7 | Using e-commerce carts in React are sometimes way to bloated and come with strong opinions on styling, `use-cart` gets out your way and gives you the building blocks to build in shopping cart functionality into your react app. 8 | 9 | ## Installation 10 | 11 | > Note: please ensure you install versions >= 16.8.0 for both react and react-dom, as this library depends on the new hooks feature 12 | 13 | ## NPM 14 | 15 | ``` 16 | npm i use-cart --save 17 | ``` 18 | 19 | ## Yarn 20 | 21 | ``` 22 | yarn add use-cart 23 | ``` 24 | 25 | ## Quick Start 26 | 27 | ```js 28 | import { CartProvider, useCart } from "use-cart" 29 | 30 | // Wrap your app to expose the store 31 | const App = () => ( 32 | 33 | <> 34 | 35 | 36 | 37 | 38 | ) 39 | 40 | // Add the hook in any child component to get access to functions 41 | const Item = () => { 42 | const { addItem } = useCart() 43 | return ( 44 |
45 |

My item for sale

46 | 47 |
48 | ) 49 | } 50 | 51 | // You can use the hook in as many components as you want and they all share the same cart state 52 | const Cart = () => { 53 | const { items, addItem, removeItem, removeLineItem, clearCart } = useCart() 54 | 55 | return ( 56 |
57 | {items.map(item => ( 58 |
59 | {item.sku} - {item.quantity}{" "} 60 | 61 | 64 | 67 |
68 | ))} 69 | 70 |
71 | ) 72 | } 73 | ``` 74 | 75 | ## Examples 76 | 77 | - [Basic](https://codesandbox.io/s/v1mp6z0l20?fontsize=14) 78 | - [Fetching extra product data](https://codesandbox.io/s/zwl877zzl4?fontsize=14) 79 | - [Initial cart](https://codesandbox.io/s/9zro3wjy0y?fontsize=14) 80 | - [Using local storage to load initial cart](https://codesandbox.io/s/7wm873zq6j?fontsize=14) 81 | 82 | ## API 83 | 84 | ### `` 85 | 86 | Passes the cart object to the `useCart` hook 87 | 88 | #### Props 89 | 90 | `initialCart (Array)`: initial state that the cart will contain on initial render, it must be an array of objects 91 | 92 | `children (React.ReactNode)`: react component, usually containing the rest of your app 93 | 94 | ### `useStore()` 95 | 96 | The main hook must be wrapped with the `CartProvider` component at some point in the ancestor tree 97 | 98 | #### Returns 99 | 100 | Object containing: 101 | 102 | - `addItem(sku, [quantity=1]): Function` - takes a sku an optional quantity (defaults to 1) to add to the cart 103 | - `removeItem(sku, [quantity=1]): Function` - removes an item from the cart defaults to a quantity of 1. 104 | - `removeLineItem(sku): Function` - removes an entire line item from the cart 105 | - `clearCart(): Function` - removes all items from the cart 106 | - `isInCart(sku): Function` - returns `true` if sku is present in the cart otherwise `false` 107 | - `items: Array` - array of objects containing a minimum of `sku` and `quantity` properties on each object 108 | - `lineItemsCount: Number` - returns number of unique line items n the cart 109 | - `totalItemsCount: Number` - returns number of all quantities of line items combined 110 | 111 | ## Detailed API from `useCart` object 112 | 113 | ### `addItem(sku, [quantity=1])` 114 | 115 | This method adds an item to the cart identified by its sku, if you would like to add more quantity you can pass an optional quantity value. 116 | 117 | #### Arguments 118 | 119 | `sku (String)`: The unique item sku that identifies the item in the cart 120 | 121 | `[quantity=1] (Number)`: The quantity added to the cart 122 | 123 | #### Returns 124 | 125 | `(undefined)` 126 | 127 | --- 128 | 129 | ### `removeItem(sku, [quantity=1])` 130 | 131 | Removes a quantity from an item in the cart if this number drops to 0 the line item is removed from the cart. 132 | 133 | #### Arguments 134 | 135 | `sku (String)`: The unique item sku that identifies the item in the cart 136 | 137 | `[quantity=1] (Number)`: The quantity removed from the cart 138 | 139 | #### Returns 140 | 141 | `(undefined)` 142 | 143 | --- 144 | 145 | ### `removeLineItem(sku)` 146 | 147 | A convenience function that removes an entire line item from the cart. This would be the same thing as getting the quantity of a line item then calling `removeItem()` by that quantity. 148 | 149 | #### Arguments 150 | 151 | `sku (String)`: The unique item sku that identifies the item in the cart 152 | 153 | #### Returns 154 | 155 | `(undefined)` 156 | 157 | --- 158 | 159 | ### `isInCart(sku)` 160 | 161 | Allows you to quickly check if a item with the given sku is present in the cart 162 | 163 | #### Arguments 164 | 165 | `sku (String)`: The unique item sku that identifies the item in the cart 166 | 167 | #### Returns 168 | 169 | `(boolean)`: Returns `true` if the sku exists in the cart 170 | 171 | --- 172 | 173 | ### `items` 174 | 175 | `(array)`: An array containing objects with `sku` and `quantity` properties of each item in the cart. 176 | 177 | --- 178 | 179 | ### `lineItemsCount` 180 | 181 | `(number)`: The number of unique skus in the cart 182 | 183 | --- 184 | 185 | ### `totalItemsCount` 186 | 187 | `(number)`: The number of all the quantities from all the sku's in the cart 188 | 189 | --- 190 | 191 | MIT License. 192 | 193 | --- 194 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from "rollup-plugin-babel" 2 | import sourceMaps from "rollup-plugin-sourcemaps" 3 | import { terser } from "rollup-plugin-terser" 4 | import { sizeSnapshot } from "rollup-plugin-size-snapshot" 5 | import pkg from "./package.json" 6 | 7 | export default [ 8 | { 9 | input: "src/use-cart.js", 10 | external: ["react"], 11 | plugins: [ 12 | babel({ 13 | exclude: ["node_modules/**"] 14 | }), 15 | sourceMaps(), 16 | sizeSnapshot(), 17 | terser({ 18 | sourcemap: true, 19 | output: { comments: false }, 20 | compress: { 21 | keep_infinity: true, 22 | pure_getters: true, 23 | passes: 10 24 | }, 25 | warnings: true, 26 | ecma: 5, 27 | toplevel: true 28 | }) 29 | ], 30 | output: [ 31 | { file: pkg.main, format: "cjs", sourcemap: true }, 32 | { file: pkg.module, format: "es", sourcemap: true } 33 | ] 34 | } 35 | ] 36 | -------------------------------------------------------------------------------- /src/reducer.js: -------------------------------------------------------------------------------- 1 | const addItemReducer = (state, action) => { 2 | const existingItemIndex = state.items.findIndex( 3 | item => item.sku === action.payload.sku 4 | ) 5 | 6 | if (existingItemIndex > -1) { 7 | const newState = [...state.items] 8 | newState[existingItemIndex].quantity += action.payload.quantity 9 | return newState 10 | } 11 | return [...state.items, action.payload] 12 | } 13 | 14 | const removeItemReducer = (state, action) => { 15 | return state.items.reduce((acc, item) => { 16 | if (item.sku === action.payload.sku) { 17 | const newQuantity = item.quantity - action.payload.quantity 18 | 19 | return newQuantity > 0 20 | ? [...acc, { ...item, quantity: newQuantity }] 21 | : [...acc] 22 | } 23 | return [...acc, item] 24 | }, []) 25 | } 26 | 27 | const removeLineItemReducer = (state, action) => { 28 | return state.items.filter(item => item.sku !== action.payload.sku) 29 | } 30 | 31 | export const reducer = (state, action) => { 32 | switch (action.type) { 33 | case "ADD_ITEM": 34 | return { items: addItemReducer(state, action) } 35 | case "REMOVE_ITEM": 36 | return { items: removeItemReducer(state, action) } 37 | case "REMOVE_LINE_ITEM": 38 | return { items: removeLineItemReducer(state, action) } 39 | case "CLEAR_CART": 40 | return { items: [] } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/use-cart.js: -------------------------------------------------------------------------------- 1 | import React, { useReducer, useContext, createContext } from "react" 2 | import { reducer } from "./reducer" 3 | const CartContext = createContext() 4 | 5 | export const CartProvider = ({ children, initialCart = [] }) => { 6 | const [state, dispatch] = useReducer(reducer, { items: initialCart }) 7 | 8 | const addItemHandler = (sku, quantity = 1) => { 9 | dispatch({ type: "ADD_ITEM", payload: { sku, quantity } }) 10 | } 11 | 12 | const removeItemHandler = (sku, quantity = 1) => { 13 | dispatch({ type: "REMOVE_ITEM", payload: { sku, quantity } }) 14 | } 15 | 16 | const getItemsCount = state.items.reduce( 17 | (acc, item) => acc + item.quantity, 18 | 0 19 | ) 20 | 21 | const removeLineItemHandler = sku => { 22 | dispatch({ type: "REMOVE_LINE_ITEM", payload: { sku } }) 23 | } 24 | 25 | const clearCartHandler = () => { 26 | dispatch({ type: "CLEAR_CART" }) 27 | } 28 | 29 | const isInCartHandler = sku => { 30 | return state.items.some(item => item.sku === sku) 31 | } 32 | 33 | return ( 34 | 46 | {children} 47 | 48 | ) 49 | } 50 | 51 | export const useCart = () => useContext(CartContext) 52 | --------------------------------------------------------------------------------