├── .all-contributorsrc ├── .github ├── FUNDING.yml └── workflows │ ├── main.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── example ├── .npmignore ├── index.html ├── index.tsx ├── package.json └── tsconfig.json ├── package-lock.json ├── package.json ├── prettier.config.js ├── release.config.js ├── renovate.json ├── src ├── index.tsx └── useLocalStorage.ts ├── stories └── CartProvider.stories.tsx ├── test └── index.test.tsx └── tsconfig.json /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "badgeTemplate": "Contributors", 7 | "commit": false, 8 | "contributors": [ 9 | { 10 | "login": "getTobiasNielsen", 11 | "name": "Tobias Nielsen", 12 | "avatar_url": "https://avatars.githubusercontent.com/u/54803528?v=4", 13 | "profile": "https://github.com/getTobiasNielsen", 14 | "contributions": [ 15 | "code", 16 | "ideas" 17 | ] 18 | }, 19 | { 20 | "login": "ynnoj", 21 | "name": "Jonathan Steele", 22 | "avatar_url": "https://avatars.githubusercontent.com/u/3578709?v=4", 23 | "profile": "http://jonathan.steele.pro", 24 | "contributions": [ 25 | "code", 26 | "ideas", 27 | "bug" 28 | ] 29 | }, 30 | { 31 | "login": "craigtweedy", 32 | "name": "Craig Tweedy", 33 | "avatar_url": "https://avatars.githubusercontent.com/u/612558?v=4", 34 | "profile": "https://github.com/craigtweedy", 35 | "contributions": [ 36 | "code", 37 | "ideas", 38 | "bug" 39 | ] 40 | }, 41 | { 42 | "login": "spences10", 43 | "name": "Scott Spence", 44 | "avatar_url": "https://avatars.githubusercontent.com/u/234708?v=4", 45 | "profile": "https://scottspence.com", 46 | "contributions": [ 47 | "example" 48 | ] 49 | } 50 | ], 51 | "contributorsPerLine": 7, 52 | "projectName": "react-use-cart", 53 | "projectOwner": "notrab", 54 | "repoType": "github", 55 | "repoHost": "https://github.com", 56 | "skipCi": true 57 | } 58 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [notrab] 2 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }} 6 | 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | node: ["12.x", "14.x"] 11 | os: [ubuntu-latest, windows-latest, macOS-latest] 12 | 13 | steps: 14 | - name: Checkout repo 15 | uses: actions/checkout@v2 16 | 17 | - name: Use Node ${{ matrix.node }} 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: ${{ matrix.node }} 21 | 22 | - name: Install deps and build (with cache) 23 | uses: bahmutov/npm-install@v1 24 | 25 | - name: Lint 26 | run: npm run lint 27 | 28 | - name: Test 29 | run: npm test --ci --coverage --maxWorkers=2 30 | 31 | - name: Build 32 | run: npm run build 33 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - beta 8 | 9 | permissions: 10 | contents: write 11 | issues: write 12 | pull-requests: write 13 | 14 | jobs: 15 | release: 16 | name: Release 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | - name: Setup Node.js 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: "lts/*" 25 | - name: Install dependencies 26 | run: npm ci 27 | - name: Build package 28 | run: npm run build 29 | - name: Release 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 33 | run: npx semantic-release 34 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Build & Test 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | - beta 7 | jobs: 8 | build: 9 | name: Build 10 | runs-on: ubuntu-18.04 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v2 14 | - name: Setup Node.js 15 | uses: actions/setup-node@v1 16 | with: 17 | node-version: 12 18 | - name: Install dependencies 19 | run: npm ci 20 | - name: Build package 21 | run: npm run build 22 | test: 23 | name: Test 24 | needs: build 25 | runs-on: ubuntu-18.04 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v1 29 | - name: Setup Node.js 30 | uses: actions/setup-node@v1 31 | with: 32 | node-version: 12 33 | - name: Install dependencies 34 | run: npm ci 35 | - name: Run tests 36 | run: npm run test 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | dist 6 | yarn.lock -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to `react-use-cart` 2 | 3 | This library was built to be a thin layer between your product inventory + checkout. 4 | 5 | Before you get started writing any code, make sure: 6 | 7 | - You search open PRs and issues, someone else might be working on it 8 | - Adhere to [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) - this helps out our automated release bot 9 | - Update the `README` with any added functionality usage instructions 10 | - Add test coverage 11 | 12 | ## Working in development 13 | 14 | This library uses [tsdx](https://tsdx.io). Execute `npm start` to start the project in development/watch mode. 15 | 16 | The example directory uses the library with parcel, and you'll need to `cd example` and `npm install` to get going. 17 | 18 | ## PR early 19 | 20 | The single most important thing you can do is PR early in `DRAFT` status. This allows maintainers, and other users to see what you're working on, and provide any suggestions early. 21 | 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | react-use-cart 3 |

4 |

5 | 🛒 A lightweight shopping cart hook for React, Next.js, and Gatsby 6 |

7 | 8 |

9 | 10 | Version 11 | 12 | 13 | Downloads/week 14 | 15 | 16 | License 17 | 18 | 19 | Forks on GitHub 20 | 21 | 22 | Forks on GitHub 23 | 24 | minified + gzip size 25 | 26 | Contributors 27 | 28 |

29 | 30 | ## Why? 31 | 32 | - ![Bundle size](https://badgen.net/bundlephobia/minzip/react-use-cart) 33 | - **No dependencies** 34 | - 💳 Not tied to any payment gateway, or checkout - create your own! 35 | - 🔥 Persistent carts with local storage, or your own adapter 36 | - ⭐️ Supports multiples carts per page 37 | - 🛒 Flexible cart item schema 38 | - 🥞 Works with Next, Gatsby, React 39 | - ♻️ Trigger your own side effects with cart handlers (on item add, update, remove) 40 | - 🛠 Built with TypeScript 41 | - ✅ Fully tested 42 | - 🌮 Used by [Dines](https://dines.co.uk/?ref=react-use-cart) 43 | 44 | ## Quick Start 45 | 46 | [Demo](https://codesandbox.io/s/react-use-cart-3c7vm) 47 | 48 | ```js 49 | import { CartProvider, useCart } from "react-use-cart"; 50 | 51 | function Page() { 52 | const { addItem } = useCart(); 53 | 54 | const products = [ 55 | { 56 | id: 1, 57 | name: "Malm", 58 | price: 9900, 59 | quantity: 1 60 | }, 61 | { 62 | id: 2, 63 | name: "Nordli", 64 | price: 16500, 65 | quantity: 5 66 | }, 67 | { 68 | id: 3, 69 | name: "Kullen", 70 | price: 4500, 71 | quantity: 1 72 | }, 73 | ]; 74 | 75 | return ( 76 |
77 | {products.map((p) => ( 78 |
79 | 80 |
81 | ))} 82 |
83 | ); 84 | } 85 | 86 | function Cart() { 87 | const { 88 | isEmpty, 89 | totalUniqueItems, 90 | items, 91 | updateItemQuantity, 92 | removeItem, 93 | } = useCart(); 94 | 95 | if (isEmpty) return

Your cart is empty

; 96 | 97 | return ( 98 | <> 99 |

Cart ({totalUniqueItems})

100 | 101 | 119 | 120 | ); 121 | } 122 | 123 | function App() { 124 | return ( 125 | 126 | 127 | 128 | 129 | ); 130 | } 131 | ``` 132 | 133 | ## Install 134 | 135 | ```bash 136 | npm install react-use-cart # yarn add react-use-cart 137 | ``` 138 | 139 | ## `CartProvider` 140 | 141 | You will need to wrap your application with the `CartProvider` component so that the `useCart` hook can access the cart state. 142 | 143 | Carts are persisted across visits using `localStorage`, unless you specify your own `storage` adapter. 144 | 145 | #### Usage 146 | 147 | ```js 148 | import React from "react"; 149 | import ReactDOM from "react-dom"; 150 | import { CartProvider } from "react-use-cart"; 151 | 152 | ReactDOM.render( 153 | {/* render app/cart here */}, 154 | document.getElementById("root") 155 | ); 156 | ``` 157 | 158 | #### Props 159 | 160 | | Prop | Required | Description | 161 | | -------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | 162 | | `id` | _No_ | `id` for your cart to enable automatic cart retrieval via `window.localStorage`. If you pass a `id` then you can use multiple instances of `CartProvider`. | 163 | | `onSetItems` | _No_ | Triggered only when `setItems` invoked. | 164 | | `onItemAdd` | _No_ | Triggered on items added to your cart, unless the item already exists, then `onItemUpdate` will be invoked. | 165 | | `onItemUpdate` | _No_ | Triggered on items updated in your cart, unless you are setting the quantity to `0`, then `onItemRemove` will be invoked. | 166 | | `onItemRemove` | _No_ | Triggered on items removed from your cart. | 167 | | `onEmptyCart` | _No_ | Triggered on empty cart. | 168 | | `storage` | _No_ | Must return `[getter, setter]`. | 169 | | `metadata` | _No_ | Custom global state on the cart. Stored inside of `metadata`. | 170 | ## `useCart` 171 | 172 | The `useCart` hook exposes all the getter/setters for your cart state. 173 | 174 | ### `setItems(items)` 175 | 176 | The `setItems` method should be used to set all items in the cart. This will overwrite any existing cart items. A `quantity` default of 1 will be set for an item implicitly if no `quantity` is specified. 177 | 178 | 179 | #### Args 180 | 181 | - `items[]` (**Required**): An array of cart item object. You must provide an `id` and `price` value for new items that you add to cart. 182 | 183 | #### Usage 184 | 185 | ```js 186 | import { useCart } from "react-use-cart"; 187 | 188 | const { setItems } = useCart(); 189 | 190 | const products = [ 191 | { 192 | id: "ckb64v21u000001ksgw2s42ku", 193 | name: "Fresh Foam 1080v9", 194 | brand: "New Balance", 195 | color: "Neon Emerald with Dark Neptune", 196 | size: "US 10", 197 | width: "B - Standard", 198 | sku: "W1080LN9", 199 | price: 15000, 200 | }, 201 | { 202 | id: "cjld2cjxh0000qzrmn831i7rn", 203 | name: "Fresh Foam 1080v9", 204 | brand: "New Balance", 205 | color: "Neon Emerald with Dark Neptune", 206 | size: "US 9", 207 | width: "B - Standard", 208 | sku: "W1080LN9", 209 | price: 15000, 210 | }, 211 | ]; 212 | 213 | setItems(products); 214 | ``` 215 | 216 | ### `addItem(item, quantity)` 217 | 218 | The `addItem` method should be used to add items to the cart. 219 | 220 | #### Args 221 | 222 | - `item` (**Required**): An object that represents your cart item. You must provide an `id` and `price` value for new items that you add to cart. 223 | - `quantity` (_optional_, **default**: `1`): The amount of items you want to add. 224 | 225 | #### Usage 226 | 227 | ```js 228 | import { useCart } from "react-use-cart"; 229 | 230 | const { addItem } = useCart(); 231 | 232 | const product = { 233 | id: "cjld2cjxh0000qzrmn831i7rn", 234 | name: "Fresh Foam 1080v9", 235 | brand: "New Balance", 236 | color: "Neon Emerald with Dark Neptune", 237 | size: "US 9", 238 | width: "B - Standard", 239 | sku: "W1080LN9", 240 | price: 15000, 241 | }; 242 | 243 | addItem(product, 2); 244 | ``` 245 | 246 | ### `updateItem(itemId, data)` 247 | 248 | The `updateItem` method should be used to update items in the cart. 249 | 250 | #### Args 251 | 252 | - `itemId` (**Required**): The cart item `id` you want to update. 253 | - `data` (**Required**): The updated cart item object. 254 | 255 | #### Usage 256 | 257 | ```js 258 | import { useCart } from "react-use-cart"; 259 | 260 | const { updateItem } = useCart(); 261 | 262 | updateItem("cjld2cjxh0000qzrmn831i7rn", { 263 | size: "UK 10", 264 | }); 265 | ``` 266 | 267 | ### `updateItemQuantity(itemId, quantity)` 268 | 269 | The `updateItemQuantity` method should be used to update an items `quantity` value. 270 | 271 | #### Args 272 | 273 | - `itemId` (**Required**): The cart item `id` you want to update. 274 | - `quantity` (**Required**): The updated cart item quantity. 275 | 276 | #### Usage 277 | 278 | ```js 279 | import { useCart } from "react-use-cart"; 280 | 281 | const { updateItemQuantity } = useCart(); 282 | 283 | updateItemQuantity("cjld2cjxh0000qzrmn831i7rn", 1); 284 | ``` 285 | 286 | ### `removeItem(itemId)` 287 | 288 | The `removeItem` method should be used to remove an item from the cart. 289 | 290 | #### Args 291 | 292 | - `itemId` (**Required**): The cart item `id` you want to remove. 293 | 294 | #### Usage 295 | 296 | ```js 297 | import { useCart } from "react-use-cart"; 298 | 299 | const { removeItem } = useCart(); 300 | 301 | removeItem("cjld2cjxh0000qzrmn831i7rn"); 302 | ``` 303 | 304 | ### `emptyCart()` 305 | 306 | The `emptyCart()` method should be used to remove all cart items, and resetting cart totals to the default `0` values. 307 | 308 | #### Usage 309 | 310 | ```js 311 | import { useCart } from "react-use-cart"; 312 | 313 | const { emptyCart } = useCart(); 314 | 315 | emptyCart(); 316 | ``` 317 | 318 | ### `clearCartMetadata()` 319 | 320 | The `clearCartMetadata()` will reset the `metadata` to an empty object. 321 | 322 | #### Usage 323 | 324 | ```js 325 | import { useCart } from "react-use-cart"; 326 | 327 | const { clearCartMetadata } = useCart(); 328 | 329 | clearCartMetadata(); 330 | ``` 331 | 332 | ### `setCartMetadata(object)` 333 | 334 | The `setCartMetadata()` will replace the `metadata` object on the cart. You must pass it an object. 335 | 336 | #### Args 337 | 338 | - `object`: A object with key/value pairs. The key being a string. 339 | 340 | #### Usage 341 | 342 | ```js 343 | import { useCart } from "react-use-cart"; 344 | 345 | const { setCartMetadata } = useCart(); 346 | 347 | setCartMetadata({ notes: "This is the only metadata" }); 348 | ``` 349 | 350 | ### `updateCartMetadata(object)` 351 | 352 | The `updateCartMetadata()` will update the `metadata` object on the cart. You must pass it an object. This will merge the passed object with the existing metadata. 353 | 354 | #### Args 355 | 356 | - `object`: A object with key/value pairs. The key being a string. 357 | 358 | #### Usage 359 | 360 | ```js 361 | import { useCart } from "react-use-cart"; 362 | 363 | const { updateCartMetadata } = useCart(); 364 | 365 | updateCartMetadata({ notes: "Leave in shed" }); 366 | ``` 367 | 368 | ### `items = []` 369 | 370 | This will return the current cart items in an array. 371 | 372 | #### Usage 373 | 374 | ```js 375 | import { useCart } from "react-use-cart"; 376 | 377 | const { items } = useCart(); 378 | ``` 379 | 380 | ### `isEmpty = false` 381 | 382 | A quick and easy way to check if the cart is empty. Returned as a boolean. 383 | 384 | #### Usage 385 | 386 | ```js 387 | import { useCart } from "react-use-cart"; 388 | 389 | const { isEmpty } = useCart(); 390 | ``` 391 | 392 | ### `getItem(itemId)` 393 | 394 | Get a specific cart item by `id`. Returns the item object. 395 | 396 | #### Args 397 | 398 | - `itemId` (**Required**): The `id` of the item you're fetching. 399 | 400 | #### Usage 401 | 402 | ```js 403 | import { useCart } from "react-use-cart"; 404 | 405 | const { getItem } = useCart(); 406 | 407 | const myItem = getItem("cjld2cjxh0000qzrmn831i7rn"); 408 | ``` 409 | 410 | ### `inCart(itemId)` 411 | 412 | Quickly check if an item is in the cart. Returned as a boolean. 413 | 414 | #### Args 415 | 416 | - `itemId` (**Required**): The `id` of the item you're looking for. 417 | 418 | #### Usage 419 | 420 | ```js 421 | import { useCart } from "react-use-cart"; 422 | 423 | const { inCart } = useCart(); 424 | 425 | inCart("cjld2cjxh0000qzrmn831i7rn") ? "In cart" : "Not in cart"; 426 | ``` 427 | 428 | ### `totalItems = 0` 429 | 430 | This returns the totaly quantity of items in the cart as an integer. 431 | 432 | #### Usage 433 | 434 | ```js 435 | import { useCart } from "react-use-cart"; 436 | 437 | const { totalItems } = useCart(); 438 | ``` 439 | 440 | ### `totalUniqueItems = 0` 441 | 442 | This returns the total unique items in the cart as an integer. 443 | 444 | #### Usage 445 | 446 | ```js 447 | import { useCart } from "react-use-cart"; 448 | 449 | const { totalUniqueItems } = useCart(); 450 | ``` 451 | 452 | ### `cartTotal = 0` 453 | 454 | This returns the total value of all items in the cart. 455 | 456 | #### Usage 457 | 458 | ```js 459 | import { useCart } from "react-use-cart"; 460 | 461 | const { cartTotal } = useCart(); 462 | ``` 463 | 464 | ### `metadata = {}` 465 | 466 | This returns the metadata set with `updateCartMetadata`. This is useful for storing additional cart, or checkout values. 467 | 468 | #### Usage 469 | 470 | ```js 471 | import { useCart } from "react-use-cart"; 472 | 473 | const { metadata } = useCart(); 474 | ``` 475 | 476 | ## Contributors ✨ 477 | 478 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 479 | 480 | 481 | 482 | 483 | 484 | 485 | 486 | 487 | 488 | 489 | 490 |

Tobias Nielsen

💻

Craig Tweedy

💻

Jonathan Steele

💻

Scott Spence

💡
491 | 492 | 493 | 494 | 495 | 496 | 497 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 498 | -------------------------------------------------------------------------------- /example/.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | dist -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Playground 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /example/index.tsx: -------------------------------------------------------------------------------- 1 | import "react-app-polyfill/ie11"; 2 | import * as React from "react"; 3 | import * as ReactDOM from "react-dom"; 4 | import { CartProvider, useCart } from "../."; 5 | 6 | const App = () => { 7 | return ( 8 | 9 |

Hello

10 |
11 | ); 12 | }; 13 | 14 | ReactDOM.render(, document.getElementById("root")); 15 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "parcel index.html", 8 | "build": "parcel build index.html" 9 | }, 10 | "dependencies": { 11 | "react-app-polyfill": "^1.0.0" 12 | }, 13 | "alias": { 14 | "react": "../node_modules/react", 15 | "react-dom": "../node_modules/react-dom/profiling", 16 | "scheduler/tracing": "../node_modules/scheduler/tracing-profiling" 17 | }, 18 | "devDependencies": { 19 | "@types/react": "^16.9.11", 20 | "@types/react-dom": "^16.8.4", 21 | "parcel": "^1.12.3", 22 | "typescript": "^3.4.5" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": false, 4 | "target": "es5", 5 | "module": "commonjs", 6 | "jsx": "react", 7 | "moduleResolution": "node", 8 | "noImplicitAny": false, 9 | "noUnusedLocals": false, 10 | "noUnusedParameters": false, 11 | "removeComments": true, 12 | "strictNullChecks": true, 13 | "preserveConstEnums": true, 14 | "sourceMap": true, 15 | "lib": ["es2015", "es2016", "dom"], 16 | "types": ["node"] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-use-cart", 3 | "version": "0.0.0-semantic-release", 4 | "description": "React hook library for managing cart state.", 5 | "author": "Jamie Barton (https://twitter.com/notrab)", 6 | "license": "Apache-2.0", 7 | "main": "dist/index.js", 8 | "typings": "dist/index.d.ts", 9 | "module": "dist/react-use-cart.esm.js", 10 | "repository": "notrab/react-use-cart", 11 | "files": [ 12 | "dist", 13 | "src" 14 | ], 15 | "keywords": [ 16 | "react", 17 | "commerce", 18 | "ecommerce", 19 | "cart", 20 | "hooks" 21 | ], 22 | "engines": { 23 | "node": ">=10" 24 | }, 25 | "scripts": { 26 | "start": "tsdx watch", 27 | "build": "tsdx build", 28 | "test": "tsdx test --passWithNoTests", 29 | "lint": "tsdx lint", 30 | "prepare": "tsdx build", 31 | "storybook": "start-storybook -p 6006", 32 | "build-storybook": "build-storybook" 33 | }, 34 | "resolutions": { 35 | "**/@typescript-eslint/eslint-plugin": "4.11.1", 36 | "**/@typescript-eslint/parser": "4.11.1", 37 | "**/typescript": "4.2.3" 38 | }, 39 | "peerDependencies": { 40 | "react": ">=16" 41 | }, 42 | "husky": { 43 | "hooks": { 44 | "pre-commit": "tsdx lint" 45 | } 46 | }, 47 | "devDependencies": { 48 | "@babel/core": "7.13.13", 49 | "@storybook/addon-essentials": "6.4.13", 50 | "@storybook/addon-info": "5.3.21", 51 | "@storybook/addon-links": "6.1.21", 52 | "@storybook/addons": "6.1.21", 53 | "@storybook/react": "6.4.13", 54 | "@testing-library/react-hooks": "5.1.0", 55 | "@types/react": "17.0.3", 56 | "@types/react-dom": "17.0.3", 57 | "babel-jest": "26.6.3", 58 | "babel-loader": "8.2.2", 59 | "husky": "5.2.0", 60 | "react": "17.0.2", 61 | "react-dom": "17.0.2", 62 | "react-is": "17.0.2", 63 | "tsdx": "0.14.1", 64 | "tslib": "2.1.0", 65 | "typescript": "4.2.3" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: "es5", 3 | }; 4 | -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | branches: ["main", { name: "beta", prerelease: true }], 3 | }; 4 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:semverAllMonthly"], 3 | "automerge": true, 4 | "major": { 5 | "automerge": false 6 | }, 7 | "semanticCommits": true, 8 | "ignorePresets": [":semanticPrefixFixDepsChoreOthers"] 9 | } 10 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import useLocalStorage from "./useLocalStorage"; 4 | 5 | export interface Item { 6 | id: string; 7 | price: number; 8 | quantity?: number; 9 | itemTotal?: number; 10 | [key: string]: any; 11 | } 12 | 13 | export interface InitialState { 14 | id: string; 15 | items: Item[]; 16 | isEmpty: boolean; 17 | totalItems: number; 18 | totalUniqueItems: number; 19 | cartTotal: number; 20 | metadata?: Metadata; 21 | } 22 | 23 | export interface Metadata { 24 | [key: string]: any; 25 | } 26 | 27 | export interface CartProviderState extends InitialState { 28 | addItem: (item: Item, quantity?: number) => void; 29 | removeItem: (id: Item["id"]) => void; 30 | updateItem: (id: Item["id"], payload: object) => void; 31 | setItems: (items: Item[]) => void; 32 | updateItemQuantity: (id: Item["id"], quantity: number) => void; 33 | emptyCart: () => void; 34 | getItem: (id: Item["id"]) => any | undefined; 35 | inCart: (id: Item["id"]) => boolean; 36 | clearCartMetadata: () => void; 37 | setCartMetadata: (metadata: Metadata) => void; 38 | updateCartMetadata: (metadata: Metadata) => void; 39 | } 40 | 41 | export type Actions = 42 | | { type: "SET_ITEMS"; payload: Item[] } 43 | | { type: "ADD_ITEM"; payload: Item } 44 | | { type: "REMOVE_ITEM"; id: Item["id"] } 45 | | { 46 | type: "UPDATE_ITEM"; 47 | id: Item["id"]; 48 | payload: object; 49 | } 50 | | { type: "EMPTY_CART" } 51 | | { type: "CLEAR_CART_META" } 52 | | { type: "SET_CART_META"; payload: Metadata } 53 | | { type: "UPDATE_CART_META"; payload: Metadata }; 54 | 55 | export const initialState: any = { 56 | items: [], 57 | isEmpty: true, 58 | totalItems: 0, 59 | totalUniqueItems: 0, 60 | cartTotal: 0, 61 | metadata: {}, 62 | }; 63 | 64 | const CartContext = React.createContext( 65 | initialState 66 | ); 67 | 68 | export const createCartIdentifier = (len = 12) => 69 | [...Array(len)].map(() => (~~(Math.random() * 36)).toString(36)).join(""); 70 | 71 | export const useCart = () => { 72 | const context = React.useContext(CartContext); 73 | 74 | if (!context) throw new Error("Expected to be wrapped in a CartProvider"); 75 | 76 | return context; 77 | }; 78 | 79 | function reducer(state: CartProviderState, action: Actions) { 80 | switch (action.type) { 81 | case "SET_ITEMS": 82 | return generateCartState(state, action.payload); 83 | 84 | case "ADD_ITEM": { 85 | const items = [...state.items, action.payload]; 86 | 87 | return generateCartState(state, items); 88 | } 89 | 90 | case "UPDATE_ITEM": { 91 | const items = state.items.map((item: Item) => { 92 | if (item.id !== action.id) return item; 93 | 94 | return { 95 | ...item, 96 | ...action.payload, 97 | }; 98 | }); 99 | 100 | return generateCartState(state, items); 101 | } 102 | 103 | case "REMOVE_ITEM": { 104 | const items = state.items.filter((i: Item) => i.id !== action.id); 105 | 106 | return generateCartState(state, items); 107 | } 108 | 109 | case "EMPTY_CART": 110 | return initialState; 111 | 112 | case "CLEAR_CART_META": 113 | return { 114 | ...state, 115 | metadata: {}, 116 | }; 117 | 118 | case "SET_CART_META": 119 | return { 120 | ...state, 121 | metadata: { 122 | ...action.payload, 123 | }, 124 | }; 125 | 126 | case "UPDATE_CART_META": 127 | return { 128 | ...state, 129 | metadata: { 130 | ...state.metadata, 131 | ...action.payload, 132 | }, 133 | }; 134 | 135 | default: 136 | throw new Error("No action specified"); 137 | } 138 | } 139 | 140 | const generateCartState = (state = initialState, items: Item[]) => { 141 | const totalUniqueItems = calculateUniqueItems(items); 142 | const isEmpty = totalUniqueItems === 0; 143 | 144 | return { 145 | ...initialState, 146 | ...state, 147 | items: calculateItemTotals(items), 148 | totalItems: calculateTotalItems(items), 149 | totalUniqueItems, 150 | cartTotal: calculateTotal(items), 151 | isEmpty, 152 | }; 153 | }; 154 | 155 | const calculateItemTotals = (items: Item[]) => 156 | items.map(item => ({ 157 | ...item, 158 | itemTotal: item.price * item.quantity!, 159 | })); 160 | 161 | const calculateTotal = (items: Item[]) => 162 | items.reduce((total, item) => total + item.quantity! * item.price, 0); 163 | 164 | const calculateTotalItems = (items: Item[]) => 165 | items.reduce((sum, item) => sum + item.quantity!, 0); 166 | 167 | const calculateUniqueItems = (items: Item[]) => items.length; 168 | 169 | export const CartProvider: React.FC<{ 170 | children?: React.ReactNode; 171 | id?: string; 172 | defaultItems?: Item[]; 173 | onSetItems?: (items: Item[]) => void; 174 | onItemAdd?: (payload: Item) => void; 175 | onItemUpdate?: (payload: object) => void; 176 | onItemRemove?: (id: Item["id"]) => void; 177 | onEmptyCart?: () => void; 178 | storage?: ( 179 | key: string, 180 | initialValue: string 181 | ) => [string, (value: Function | string) => void]; 182 | metadata?: Metadata; 183 | }> = ({ 184 | children, 185 | id: cartId, 186 | defaultItems = [], 187 | onSetItems, 188 | onItemAdd, 189 | onItemUpdate, 190 | onItemRemove, 191 | onEmptyCart, 192 | storage = useLocalStorage, 193 | metadata, 194 | }) => { 195 | const id = cartId ? cartId : createCartIdentifier(); 196 | 197 | const [savedCart, saveCart] = storage( 198 | cartId ? `react-use-cart-${id}` : `react-use-cart`, 199 | JSON.stringify({ 200 | id, 201 | ...initialState, 202 | items: defaultItems, 203 | metadata, 204 | }) 205 | ); 206 | 207 | const [state, dispatch] = React.useReducer(reducer, JSON.parse(savedCart)); 208 | React.useEffect(() => { 209 | saveCart(JSON.stringify(state)); 210 | }, [state, saveCart]); 211 | 212 | const setItems = (items: Item[]) => { 213 | dispatch({ 214 | type: "SET_ITEMS", 215 | payload: items.map(item => ({ 216 | ...item, 217 | quantity: item.quantity || 1, 218 | })), 219 | }); 220 | 221 | onSetItems && onSetItems(items); 222 | }; 223 | 224 | const addItem = (item: Item, quantity = 1) => { 225 | if (!item.id) throw new Error("You must provide an `id` for items"); 226 | if (quantity <= 0) return; 227 | 228 | const currentItem = state.items.find((i: Item) => i.id === item.id); 229 | 230 | if (!currentItem && !item.hasOwnProperty("price")) 231 | throw new Error("You must pass a `price` for new items"); 232 | 233 | if (!currentItem) { 234 | const payload = { ...item, quantity }; 235 | 236 | dispatch({ type: "ADD_ITEM", payload }); 237 | 238 | onItemAdd && onItemAdd(payload); 239 | 240 | return; 241 | } 242 | 243 | const payload = { ...item, quantity: currentItem.quantity + quantity }; 244 | 245 | dispatch({ 246 | type: "UPDATE_ITEM", 247 | id: item.id, 248 | payload, 249 | }); 250 | 251 | onItemUpdate && onItemUpdate(payload); 252 | }; 253 | 254 | const updateItem = (id: Item["id"], payload: object) => { 255 | if (!id || !payload) { 256 | return; 257 | } 258 | 259 | dispatch({ type: "UPDATE_ITEM", id, payload }); 260 | 261 | onItemUpdate && onItemUpdate(payload); 262 | }; 263 | 264 | const updateItemQuantity = (id: Item["id"], quantity: number) => { 265 | if (quantity <= 0) { 266 | onItemRemove && onItemRemove(id); 267 | 268 | dispatch({ type: "REMOVE_ITEM", id }); 269 | 270 | return; 271 | } 272 | 273 | const currentItem = state.items.find((item: Item) => item.id === id); 274 | 275 | if (!currentItem) throw new Error("No such item to update"); 276 | 277 | const payload = { ...currentItem, quantity }; 278 | 279 | dispatch({ 280 | type: "UPDATE_ITEM", 281 | id, 282 | payload, 283 | }); 284 | 285 | onItemUpdate && onItemUpdate(payload); 286 | }; 287 | 288 | const removeItem = (id: Item["id"]) => { 289 | if (!id) return; 290 | 291 | dispatch({ type: "REMOVE_ITEM", id }); 292 | 293 | onItemRemove && onItemRemove(id); 294 | }; 295 | 296 | const emptyCart = () => { 297 | dispatch({ type: "EMPTY_CART" }); 298 | 299 | onEmptyCart && onEmptyCart(); 300 | } 301 | 302 | const getItem = (id: Item["id"]) => 303 | state.items.find((i: Item) => i.id === id); 304 | 305 | const inCart = (id: Item["id"]) => state.items.some((i: Item) => i.id === id); 306 | 307 | const clearCartMetadata = () => { 308 | dispatch({ 309 | type: "CLEAR_CART_META", 310 | }); 311 | }; 312 | 313 | const setCartMetadata = (metadata: Metadata) => { 314 | if (!metadata) return; 315 | 316 | dispatch({ 317 | type: "SET_CART_META", 318 | payload: metadata, 319 | }); 320 | }; 321 | 322 | const updateCartMetadata = (metadata: Metadata) => { 323 | if (!metadata) return; 324 | 325 | dispatch({ 326 | type: "UPDATE_CART_META", 327 | payload: metadata, 328 | }); 329 | }; 330 | 331 | return ( 332 | 348 | {children} 349 | 350 | ); 351 | }; 352 | -------------------------------------------------------------------------------- /src/useLocalStorage.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export default function useLocalStorage( 4 | key: string, 5 | initialValue: string 6 | ): [string, (value: Function | string) => void] { 7 | const [storedValue, setStoredValue] = React.useState(() => { 8 | try { 9 | const item = 10 | typeof window !== "undefined" && window.localStorage.getItem(key); 11 | 12 | return item ? item : initialValue; 13 | } catch (error) { 14 | return initialValue; 15 | } 16 | }); 17 | 18 | const setValue = (value: Function | string) => { 19 | try { 20 | const valueToStore = 21 | value instanceof Function ? value(storedValue) : value; 22 | 23 | setStoredValue(valueToStore); 24 | 25 | window.localStorage.setItem(key, valueToStore); 26 | } catch (error) { 27 | console.log(error); 28 | } 29 | }; 30 | 31 | return [storedValue, setValue]; 32 | } 33 | -------------------------------------------------------------------------------- /stories/CartProvider.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Meta, Story } from "@storybook/react"; 3 | import { CartProvider } from "../src"; 4 | 5 | const meta: Meta = { 6 | title: "Welcome", 7 | component: CartProvider, 8 | argTypes: { 9 | children: { 10 | control: { 11 | type: "text", 12 | }, 13 | }, 14 | }, 15 | parameters: { 16 | controls: { expanded: true }, 17 | }, 18 | }; 19 | 20 | export default meta; 21 | 22 | const Template: Story = (args) => ; 23 | 24 | export const Default = Template.bind({}); 25 | 26 | Default.args = {}; 27 | -------------------------------------------------------------------------------- /test/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | CartProvider, 3 | createCartIdentifier, 4 | initialState, 5 | useCart, 6 | } from "../src"; 7 | import React, { FC, HTMLAttributes, ReactChild } from "react"; 8 | import { act, renderHook } from "@testing-library/react-hooks"; 9 | 10 | export interface Props extends HTMLAttributes { 11 | children?: ReactChild; 12 | } 13 | 14 | afterEach(() => window.localStorage.clear()); 15 | 16 | describe("createCartIdentifier", () => { 17 | test("returns a 12 character string by default", () => { 18 | const id = createCartIdentifier(); 19 | 20 | expect(id).toHaveLength(12); 21 | }); 22 | 23 | test("returns a custom length string", () => { 24 | const id = createCartIdentifier(20); 25 | 26 | expect(id).toHaveLength(20); 27 | }); 28 | 29 | test("created id is unique", () => { 30 | const id = createCartIdentifier(); 31 | const id2 = createCartIdentifier(); 32 | 33 | expect(id).not.toEqual(id2); 34 | }); 35 | }); 36 | 37 | describe("CartProvider", () => { 38 | test("uses ID for cart if provided", () => { 39 | const wrapper: FC = ({ children }) => ( 40 | {children} 41 | ); 42 | 43 | const { result } = renderHook(() => useCart(), { 44 | wrapper, 45 | }); 46 | 47 | expect(result.current.id).toBeDefined(); 48 | expect(result.current.id).toEqual("test"); 49 | }); 50 | 51 | test("creates an ID for cart if non provided", () => { 52 | const { result } = renderHook(() => useCart(), { 53 | wrapper: CartProvider, 54 | }); 55 | 56 | expect(result.current.id).toBeDefined(); 57 | expect(result.current.id).toHaveLength(12); 58 | }); 59 | 60 | test("initial cart meta state is set", () => { 61 | const { result } = renderHook(() => useCart(), { 62 | wrapper: CartProvider, 63 | }); 64 | 65 | expect(result.current.items).toEqual(initialState.items); 66 | expect(result.current.totalItems).toEqual(initialState.totalItems); 67 | expect(result.current.totalUniqueItems).toEqual( 68 | initialState.totalUniqueItems 69 | ); 70 | expect(result.current.isEmpty).toBe(initialState.isEmpty); 71 | expect(result.current.cartTotal).toEqual(initialState.cartTotal); 72 | }); 73 | 74 | test("sets cart metadata", () => { 75 | const metadata = { 76 | coupon: "abc123", 77 | notes: "Leave on door step", 78 | }; 79 | 80 | const wrapper: FC = ({ children }) => ( 81 | {children} 82 | ); 83 | 84 | const { result } = renderHook(() => useCart(), { 85 | wrapper, 86 | }); 87 | 88 | expect(result.current.metadata).toEqual(metadata); 89 | }); 90 | }); 91 | 92 | describe("addItem", () => { 93 | test("adds item to the cart", () => { 94 | const { result } = renderHook(() => useCart(), { 95 | wrapper: CartProvider, 96 | }); 97 | 98 | const item = { id: "test", price: 1000 }; 99 | 100 | act(() => result.current.addItem(item)); 101 | 102 | expect(result.current.items).toHaveLength(1); 103 | expect(result.current.totalItems).toBe(1); 104 | expect(result.current.totalUniqueItems).toBe(1); 105 | }); 106 | 107 | test("increments existing item quantity in the cart", () => { 108 | const { result } = renderHook(() => useCart(), { 109 | wrapper: CartProvider, 110 | }); 111 | 112 | const item = { id: "test", price: 1000 }; 113 | const item2 = { id: "test", price: 1000 }; 114 | 115 | act(() => result.current.addItem(item)); 116 | act(() => result.current.addItem(item2)); 117 | 118 | expect(result.current.items).toHaveLength(1); 119 | expect(result.current.totalItems).toBe(2); 120 | expect(result.current.totalUniqueItems).toBe(1); 121 | }); 122 | 123 | test("updates cart meta state", () => { 124 | const { result } = renderHook(() => useCart(), { 125 | wrapper: CartProvider, 126 | }); 127 | 128 | const item = { id: "test", price: 1000 }; 129 | 130 | act(() => result.current.addItem(item)); 131 | 132 | expect(result.current.items).toHaveLength(1); 133 | expect(result.current.totalItems).toBe(1); 134 | expect(result.current.totalUniqueItems).toBe(1); 135 | expect(result.current.cartTotal).toBe(1000); 136 | expect(result.current.isEmpty).toBe(false); 137 | }); 138 | 139 | test("allows free item", () => { 140 | const { result } = renderHook(() => useCart(), { 141 | wrapper: CartProvider, 142 | }); 143 | 144 | const item = { id: "test", price: 0 }; 145 | 146 | act(() => result.current.addItem(item)); 147 | 148 | expect(result.current.items).toHaveLength(1); 149 | expect(result.current.totalItems).toBe(1); 150 | expect(result.current.totalUniqueItems).toBe(1); 151 | expect(result.current.cartTotal).toBe(0); 152 | expect(result.current.isEmpty).toBe(false); 153 | }); 154 | 155 | test("triggers onItemAdd when cart empty", () => { 156 | let called = false; 157 | 158 | const wrapper: FC = ({ children }) => ( 159 | (called = true)}>{children} 160 | ); 161 | 162 | const { result } = renderHook(() => useCart(), { 163 | wrapper, 164 | }); 165 | 166 | const item = { id: "test", price: 1000 }; 167 | 168 | act(() => result.current.addItem(item)); 169 | 170 | expect(called).toBe(true); 171 | }); 172 | 173 | test("triggers onItemUpdate when cart has existing item", () => { 174 | let called = false; 175 | 176 | const item = { id: "test", price: 1000 }; 177 | 178 | const wrapper: FC = ({ children }) => ( 179 | (called = true)}> 180 | {children} 181 | 182 | ); 183 | 184 | const { result } = renderHook(() => useCart(), { 185 | wrapper, 186 | }); 187 | 188 | act(() => result.current.updateItem(item.id, { price: item.price })); 189 | 190 | expect(called).toBe(true); 191 | }); 192 | 193 | test("add item with price", () => { 194 | const { result } = renderHook(() => useCart(), { 195 | wrapper: CartProvider, 196 | }); 197 | 198 | const item = { id: "test", price: 1000 }; 199 | 200 | act(() => result.current.addItem(item)); 201 | 202 | expect(result.current.cartTotal).toBe(1000); 203 | }); 204 | }); 205 | 206 | describe("updateItem", () => { 207 | test("updates cart meta state", () => { 208 | const items = [{ id: "test", price: 1000 }]; 209 | const [item] = items; 210 | 211 | const wrapper: FC = ({ children }) => ( 212 | {children} 213 | ); 214 | 215 | const { result } = renderHook(() => useCart(), { 216 | wrapper, 217 | }); 218 | 219 | act(() => 220 | result.current.updateItem(item.id, { 221 | quantity: 2, 222 | }) 223 | ); 224 | 225 | expect(result.current.items).toHaveLength(1); 226 | expect(result.current.totalItems).toBe(2); 227 | expect(result.current.totalUniqueItems).toBe(1); 228 | expect(result.current.isEmpty).toBe(false); 229 | }); 230 | 231 | test("triggers onItemUpdate when updating existing item", () => { 232 | let called = false; 233 | 234 | const item = { id: "test", price: 1000 }; 235 | 236 | const wrapper: FC = ({ children }) => ( 237 | (called = true)}> 238 | {children} 239 | 240 | ); 241 | 242 | const { result } = renderHook(() => useCart(), { 243 | wrapper, 244 | }); 245 | 246 | act(() => result.current.addItem(item)); 247 | 248 | expect(called).toBe(true); 249 | }); 250 | }); 251 | 252 | describe("updateItemQuantity", () => { 253 | test("updates cart meta state", () => { 254 | const items = [{ id: "test", price: 1000 }]; 255 | const [item] = items; 256 | 257 | const wrapper: FC = ({ children }) => ( 258 | {children} 259 | ); 260 | 261 | const { result } = renderHook(() => useCart(), { 262 | wrapper, 263 | }); 264 | 265 | act(() => result.current.updateItemQuantity(item.id, 3)); 266 | 267 | expect(result.current.items).toHaveLength(1); 268 | expect(result.current.totalItems).toBe(3); 269 | expect(result.current.totalUniqueItems).toBe(1); 270 | expect(result.current.isEmpty).toBe(false); 271 | }); 272 | 273 | test("triggers onItemUpdate when setting quantity above 0", () => { 274 | let called = false; 275 | 276 | const item = { id: "test", price: 1000 }; 277 | 278 | const wrapper: FC = ({ children }) => ( 279 | (called = true)}> 280 | {children} 281 | 282 | ); 283 | 284 | const { result } = renderHook(() => useCart(), { 285 | wrapper, 286 | }); 287 | 288 | act(() => result.current.updateItemQuantity(item.id, 2)); 289 | 290 | expect(result.current.items).toHaveLength(1); 291 | expect(called).toBe(true); 292 | }); 293 | 294 | test("triggers onItemRemove when setting quantity to 0", () => { 295 | let called = false; 296 | 297 | const item = { id: "test", price: 1000 }; 298 | 299 | const wrapper: FC = ({ children }) => ( 300 | (called = true)}> 301 | {children} 302 | 303 | ); 304 | 305 | const { result } = renderHook(() => useCart(), { 306 | wrapper, 307 | }); 308 | 309 | act(() => result.current.updateItemQuantity(item.id, 0)); 310 | 311 | expect(result.current.items).toHaveLength(0); 312 | expect(called).toBe(true); 313 | }); 314 | 315 | test("recalculates itemTotal when incrementing item quantity", () => { 316 | const item = { id: "test", price: 1000 }; 317 | 318 | const { result } = renderHook(() => useCart(), { 319 | wrapper: CartProvider, 320 | }); 321 | 322 | act(() => result.current.addItem(item)); 323 | act(() => result.current.updateItemQuantity(item.id, 2)); 324 | 325 | expect(result.current.items).toHaveLength(1); 326 | expect(result.current.items).toContainEqual( 327 | expect.objectContaining({ itemTotal: 2000, quantity: 2 }) 328 | ); 329 | }); 330 | 331 | test("recalculates itemTotal when decrementing item quantity", () => { 332 | const item = { id: "test", price: 1000, quantity: 2 }; 333 | 334 | const { result } = renderHook(() => useCart(), { 335 | wrapper: CartProvider, 336 | }); 337 | 338 | act(() => result.current.addItem(item)); 339 | act(() => result.current.updateItemQuantity(item.id, 1)); 340 | 341 | expect(result.current.items).toHaveLength(1); 342 | expect(result.current.items).toContainEqual( 343 | expect.objectContaining({ itemTotal: 1000, quantity: 1 }) 344 | ); 345 | }); 346 | }); 347 | 348 | describe("removeItem", () => { 349 | test("updates cart meta state", () => { 350 | const items = [{ id: "test", price: 1000 }]; 351 | const [item] = items; 352 | 353 | const wrapper: FC = ({ children }) => ( 354 | {children} 355 | ); 356 | 357 | const { result } = renderHook(() => useCart(), { 358 | wrapper, 359 | }); 360 | 361 | act(() => result.current.removeItem(item.id)); 362 | 363 | expect(result.current.items).toEqual([]); 364 | expect(result.current.totalItems).toBe(0); 365 | expect(result.current.totalUniqueItems).toBe(0); 366 | expect(result.current.isEmpty).toBe(true); 367 | }); 368 | 369 | test("triggers onItemRemove when removing item", () => { 370 | let called = false; 371 | 372 | const item = { id: "test", price: 1000 }; 373 | 374 | const wrapper: FC = ({ children }) => ( 375 | (called = true)}> 376 | {children} 377 | 378 | ); 379 | 380 | const { result } = renderHook(() => useCart(), { 381 | wrapper, 382 | }); 383 | 384 | act(() => result.current.updateItemQuantity(item.id, 0)); 385 | 386 | expect(called).toBe(true); 387 | }); 388 | }); 389 | 390 | describe("emptyCart", () => { 391 | test("updates cart meta state", () => { 392 | const items = [{ id: "test", price: 1000 }]; 393 | 394 | const wrapper: FC = ({ children }) => ( 395 | {children} 396 | ); 397 | 398 | const { result } = renderHook(() => useCart(), { 399 | wrapper, 400 | }); 401 | 402 | act(() => result.current.emptyCart()); 403 | 404 | expect(result.current.items).toEqual([]); 405 | expect(result.current.totalItems).toBe(0); 406 | expect(result.current.totalUniqueItems).toBe(0); 407 | expect(result.current.isEmpty).toBe(true); 408 | }); 409 | 410 | test("triggers onEmptyCart when empty cart", () => { 411 | let called = false; 412 | 413 | const wrapper: FC = ({ children }) => ( 414 | (called = true)}> 415 | {children} 416 | 417 | ); 418 | 419 | const { result } = renderHook(() => useCart(), { wrapper }); 420 | 421 | act(() => result.current.emptyCart()); 422 | expect(called).toBe(true); 423 | }); 424 | }); 425 | 426 | describe("updateCartMetadata", () => { 427 | test("clears cart metadata", () => { 428 | const { result } = renderHook(() => useCart(), { 429 | wrapper: CartProvider, 430 | }); 431 | 432 | const metadata = { 433 | coupon: "abc123", 434 | notes: "Leave on door step", 435 | }; 436 | 437 | act(() => result.current.updateCartMetadata(metadata)); 438 | 439 | expect(result.current.metadata).toEqual(metadata); 440 | 441 | act(() => result.current.clearCartMetadata()); 442 | 443 | expect(result.current.metadata).toEqual({}); 444 | }); 445 | 446 | test("sets cart metadata", () => { 447 | const { result } = renderHook(() => useCart(), { 448 | wrapper: CartProvider, 449 | }); 450 | 451 | const metadata = { 452 | coupon: "abc123", 453 | notes: "Leave on door step", 454 | }; 455 | 456 | act(() => result.current.updateCartMetadata(metadata)); 457 | 458 | expect(result.current.metadata).toEqual(metadata); 459 | 460 | const replaceMetadata = { 461 | delivery: "same-day", 462 | }; 463 | 464 | act(() => result.current.setCartMetadata(replaceMetadata)); 465 | 466 | expect(result.current.metadata).toEqual(replaceMetadata); 467 | }); 468 | 469 | test("updates cart metadata", () => { 470 | const { result } = renderHook(() => useCart(), { 471 | wrapper: CartProvider, 472 | }); 473 | 474 | const metadata = { 475 | coupon: "abc123", 476 | notes: "Leave on door step", 477 | }; 478 | 479 | act(() => result.current.updateCartMetadata(metadata)); 480 | 481 | expect(result.current.metadata).toEqual(metadata); 482 | }); 483 | 484 | test("merge new metadata with existing", () => { 485 | const initialMetadata = { 486 | coupon: "abc123", 487 | }; 488 | 489 | const wrapper: FC = ({ children }) => ( 490 | {children} 491 | ); 492 | 493 | const { result } = renderHook(() => useCart(), { 494 | wrapper, 495 | }); 496 | 497 | const metadata = { 498 | notes: "Leave on door step", 499 | }; 500 | 501 | act(() => result.current.updateCartMetadata(metadata)); 502 | 503 | expect(result.current.metadata).toEqual({ 504 | ...initialMetadata, 505 | ...metadata, 506 | }); 507 | }); 508 | }); 509 | describe("setItems", () => { 510 | test("set cart items state", () => { 511 | const items = [ 512 | { id: "test", price: 1000 }, 513 | { id: "test2", price: 2000 }, 514 | ]; 515 | 516 | const wrapper: FC = ({ children }) => ( 517 | {children} 518 | ); 519 | const { result } = renderHook(() => useCart(), { 520 | wrapper, 521 | }); 522 | 523 | act(() => result.current.setItems(items)); 524 | expect(result.current.items).toHaveLength(2); 525 | expect(result.current.totalItems).toBe(2); 526 | expect(result.current.totalUniqueItems).toBe(2); 527 | expect(result.current.isEmpty).toBe(false); 528 | expect(result.current.items).toContainEqual( 529 | expect.objectContaining({ id: "test2", price: 2000, quantity: 1 }) 530 | ); 531 | }); 532 | test("add custom quantities with setItems", () => { 533 | const items = [ 534 | { id: "test", price: 1000, quantity: 2 }, 535 | { id: "test2", price: 2000, quantity: 1 }, 536 | ]; 537 | const wrapper: FC = ({ children }) => ( 538 | {children} 539 | ); 540 | const { result } = renderHook(() => useCart(), { 541 | wrapper, 542 | }); 543 | 544 | act(() => result.current.setItems(items)); 545 | expect(result.current.items).toHaveLength(2); 546 | expect(result.current.totalItems).toBe(3); 547 | expect(result.current.totalUniqueItems).toBe(2); 548 | }); 549 | test("current items is replaced when setItems has been called with a new set of items", () => { 550 | const itemToBeReplaced = { id: "test", price: 1000 }; 551 | const wrapper: FC = ({ children }) => ( 552 | {children} 553 | ); 554 | const { result } = renderHook(() => useCart(), { 555 | wrapper, 556 | }); 557 | const items = [ 558 | { id: "test2", price: 2000 }, 559 | { id: "test3", price: 3000 }, 560 | ]; 561 | act(() => result.current.setItems(items)); 562 | expect(result.current.items).toHaveLength(2); 563 | expect(result.current.items).not.toContainEqual( 564 | expect.objectContaining(itemToBeReplaced) 565 | ); 566 | }); 567 | test("trigger onSetItems when setItems is called", () => { 568 | let called = false; 569 | 570 | const wrapper: FC = ({ children }) => ( 571 | (called = true)}>{children} 572 | ); 573 | 574 | const { result } = renderHook(() => useCart(), { 575 | wrapper, 576 | }); 577 | 578 | const items = [{ id: "test", price: 1000 }]; 579 | 580 | act(() => result.current.setItems(items)); 581 | 582 | expect(called).toBe(true); 583 | }); 584 | }); 585 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs 3 | "include": ["src", "types"], 4 | "compilerOptions": { 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | // output .d.ts declaration files for consumers 9 | "declaration": true, 10 | // output .js.map sourcemap files for consumers 11 | "sourceMap": true, 12 | // match output dir to input dir. e.g. dist/index instead of dist/src/index 13 | "rootDir": "./src", 14 | // stricter type-checking for stronger correctness. Recommended by TS 15 | "strict": true, 16 | // linter checks for common issues 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": true, 19 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | // use Node's module resolution algorithm, instead of the legacy TS one 23 | "moduleResolution": "node", 24 | // transpile JSX to React.createElement 25 | "jsx": "react", 26 | // interop between ESM and CJS modules. Recommended by TS 27 | "esModuleInterop": true, 28 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS 29 | "skipLibCheck": true, 30 | // error out if import and file system have a casing mismatch. Recommended by TS 31 | "forceConsistentCasingInFileNames": true, 32 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc` 33 | "noEmit": true, 34 | } 35 | } 36 | --------------------------------------------------------------------------------