├── .babelrc.js ├── .env.sample ├── .eslintignore ├── .eslintrc.js ├── .github ├── release-draft-template.yml └── workflows │ ├── pr-checks.yml │ ├── publish.yml │ └── release-drafter.yml ├── .gitignore ├── .huskyrc ├── .nvmrc ├── LICENSE ├── README.md ├── examples ├── next │ └── cart │ │ ├── common │ │ ├── addToCart.js │ │ └── mergeCarts.js │ │ ├── customer │ │ ├── cart.js │ │ ├── createCustomer.js │ │ ├── generateToken.js │ │ └── revokeToken.js │ │ └── guest │ │ ├── cart.js │ │ └── obtainSession.js ├── react │ ├── components │ │ └── BasicLoginForm.js │ ├── hooks │ │ ├── useCartTotal.js │ │ └── useLoginStatus.js │ └── session │ │ ├── SessionContext.js │ │ ├── SessionProvider.js │ │ └── cookieHelpers.js └── rsf │ ├── getGlobalData.js │ ├── productHandler.js │ ├── productReviewsHandler.js │ └── subcategoryHandler.js ├── package-lock.json ├── package.json ├── src ├── app │ ├── getAppData.ts │ └── withAppData.ts ├── cart │ ├── addToCart.ts │ ├── cart.ts │ ├── common │ │ ├── addSimpleProductsToCart │ │ │ ├── fetcher.ts │ │ │ ├── index.ts │ │ │ ├── normalizer.ts │ │ │ └── query.ts │ │ ├── mergeCarts │ │ │ ├── fetcher.ts │ │ │ ├── index.ts │ │ │ ├── normalizer.ts │ │ │ └── query.ts │ │ └── updateCart │ │ │ ├── fetcher.ts │ │ │ ├── index.ts │ │ │ ├── normalizer.ts │ │ │ └── query.ts │ ├── customer │ │ ├── cart │ │ │ ├── fetcher.ts │ │ │ ├── index.ts │ │ │ ├── normalizer.ts │ │ │ └── query.ts │ │ ├── createCustomer │ │ │ ├── fetcher.ts │ │ │ ├── index.ts │ │ │ ├── normalizer.ts │ │ │ └── query.ts │ │ ├── generateToken │ │ │ ├── fetcher.ts │ │ │ ├── index.ts │ │ │ ├── normalizer.ts │ │ │ └── query.ts │ │ └── revokeToken │ │ │ ├── fetcher.ts │ │ │ ├── index.ts │ │ │ ├── normalizer.ts │ │ │ └── query.ts │ ├── guest │ │ ├── cart │ │ │ ├── fetcher.ts │ │ │ ├── index.ts │ │ │ ├── normalizer.ts │ │ │ └── query.ts │ │ └── createEmptyCart │ │ │ ├── fetcher.ts │ │ │ ├── index.ts │ │ │ ├── normalizer.ts │ │ │ └── query.ts │ ├── index.ts │ ├── removeCartItem.ts │ ├── schemas │ │ └── cartItemProductDefaultSchema.ts │ └── updateCartItem.ts ├── cms │ └── blocks │ │ ├── fetcher.ts │ │ ├── index.ts │ │ ├── normalizer.ts │ │ └── query.ts ├── config.ts ├── constants.ts ├── fetchWithGraphQl.ts ├── helpers │ ├── cartItemsNormalizer.ts │ ├── getError.ts │ └── nodeCookieHelpers.ts ├── home │ ├── home.ts │ └── index.ts ├── index.ts ├── menu │ ├── fetcher.ts │ ├── index.ts │ ├── normalizer.ts │ └── query.ts ├── product │ ├── fetcher.ts │ ├── index.ts │ ├── normalizer.ts │ ├── product.ts │ ├── productSlots.ts │ ├── productSuggestions.ts │ ├── query.ts │ └── reviews │ │ ├── fetchProductReviews.ts │ │ └── index.ts ├── routes.ts ├── search │ ├── fetcher.ts │ ├── index.ts │ ├── normalizer.ts │ ├── search.ts │ └── searchSuggestions.ts ├── session │ ├── common │ │ └── mergeCarts.ts │ ├── customer │ │ ├── cart.ts │ │ ├── createCustomer.ts │ │ ├── generateToken.ts │ │ └── revokeToken.ts │ ├── guest │ │ ├── cart.ts │ │ └── obtainSession.ts │ ├── index.ts │ ├── session.ts │ ├── signIn.ts │ ├── signOut.ts │ └── signUp.ts ├── subcategory │ ├── fetcher.ts │ ├── id │ │ ├── fetcher.ts │ │ ├── index.ts │ │ ├── normalizer.ts │ │ └── query.ts │ ├── index.ts │ ├── normalizer.ts │ ├── query.ts │ ├── sub-categories │ │ ├── fetcher.ts │ │ ├── index.ts │ │ ├── normalizer.ts │ │ └── query.ts │ └── subcategory.ts └── types │ ├── CartResponse.ts │ ├── Connector.ts │ ├── Error.ts │ ├── GraphQlQuery.ts │ └── ProductPageData.ts └── tsconfig.json /.babelrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "presets": [ 3 | "@babel/env", 4 | "@babel/preset-typescript" 5 | ], 6 | "plugins": [ 7 | [ 8 | "@babel/plugin-transform-runtime", 9 | { 10 | "regenerator": true 11 | } 12 | ], 13 | [ 14 | "babel-plugin-transform-imports" 15 | ], 16 | "@babel/plugin-proposal-class-properties" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | M2_CONFIG_HOST=https://www.your-magento-site.com -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | examples/ -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true, 5 | node: true, 6 | }, 7 | extends: [ 8 | 'airbnb-typescript/base', 9 | ], 10 | globals: { 11 | Atomics: 'readonly', 12 | SharedArrayBuffer: 'readonly', 13 | }, 14 | parser: '@typescript-eslint/parser', 15 | parserOptions: { 16 | ecmaVersion: 2018, 17 | sourceType: 'module', 18 | project: './tsconfig.json', 19 | }, 20 | plugins: [ 21 | '@typescript-eslint', 22 | ], 23 | rules: { 24 | 'max-len': ['error', { 'code': 120 }], 25 | 'no-plusplus': ["error", { "allowForLoopAfterthoughts": true }], 26 | 'no-unused-vars': ["error", { "argsIgnorePattern": "^_" }], 27 | '@typescript-eslint/no-unused-vars': ["error", { "argsIgnorePattern": "^_" }], 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /.github/release-draft-template.yml: -------------------------------------------------------------------------------- 1 | categories: 2 | - title: '🚀 Features' 3 | labels: 4 | - 'feature' 5 | - 'enhancement' 6 | - title: '🐛 Bug Fixes' 7 | labels: 8 | - 'bug' 9 | - title: '🧰 Maintenance' 10 | labels: 11 | - 'chore' 12 | - 'documentation' 13 | template: | 14 | ## What's Changed 15 | 16 | $CHANGES 17 | -------------------------------------------------------------------------------- /.github/workflows/pr-checks.yml: -------------------------------------------------------------------------------- 1 | name: PR Checks 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | publish-npm: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v1 11 | - uses: actions/setup-node@v1 12 | - run: npm ci 13 | - run: npm run ts:check 14 | - run: npm run lint:check 15 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to NPM 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish-npm: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v1 12 | - uses: actions/setup-node@v1 13 | with: 14 | node-version: 12 15 | registry-url: https://registry.npmjs.org/ 16 | - run: npm ci 17 | - run: npm run release 18 | env: 19 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 20 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | update_release_draft: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: release-drafter/release-drafter@v5 13 | with: 14 | config-name: release-draft-template.yml 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .yalc 4 | yalc.lock 5 | -------------------------------------------------------------------------------- /.huskyrc: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "pre-commit": "npm run ts:check && npm run lint:check" 4 | } 5 | } -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v12.14.0 -------------------------------------------------------------------------------- /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 | # Magento 2 Connector 2 | 3 | The [React Storefront](https://github.com/storefront-foundation/react-storefront) headless ecommerce connector for [Adobe Magento 2](https://devdocs.magento.com/guides/v2.3/graphql/). 4 | 5 | Main features: 6 | - Product listing pages (PLP) 7 | - Product detail pages (PDP) 8 | - Cart 9 | 10 | This guide covers how to get up and running with the Magento 2 Connector. For information on connectors in general and how to write your own connector refer to the [React Storefront Connectors](https://docs.reactstorefront.io/guides/connectors) documentation. 11 | 12 | ## Requirements 13 | 14 | You will need a Magento 2 (sometimes referred to as "M2") backend to try out the connector. 15 | 16 | ## Running Locally 17 | 18 | Create a new React Storefront app using version 8.14.0 or later: 19 | 20 | ``` 21 | npm create react-storefront my-m2-app 22 | ``` 23 | 24 | Next `cd` into your created application and install the Magento2 connector: 25 | 26 | ``` 27 | cd my-m2-app 28 | npm install react-storefront-magento2-connector 29 | ``` 30 | 31 | Next configure the `M2_CONFIG_HOST` environment variable in `.env` file to point to your Magento2 backend. See `.env.sample` file as an example of adding env variable via [dotenv](https://www.npmjs.com/package/dotenv). You can also check [this guide](https://www.twilio.com/blog/working-with-environment-variables-in-node-js-html) to get more info about Node.js Environment Variables. For example, your `.env` file may look like: 32 | 33 | ``` 34 | LEGACY_BACKEND_DOMAIN=www.my-magento-site.com 35 | LEGACY_BACKEND_HOST_HEADER=www.my-magento-site.com 36 | M2_CONFIG_HOST=http://www.my-magento-site.com 37 | ``` 38 | 39 | Finally set the connector in your `next.config.js` file. By default this file is set to use the `react-storefront/mock-connector` as shown below: 40 | 41 | ``` 42 | module.exports = withReactStorefront({ 43 | 44 | // ... Some code 45 | 46 | connector: 'react-storefront/mock-connector', 47 | 48 | // ... More code 49 | ``` 50 | 51 | Change this line to use the `react-storefront-magento2-connector` as shown below: 52 | 53 | ``` 54 | module.exports = withReactStorefront({ 55 | 56 | // ... Some code 57 | 58 | connector: 'react-storefront-magento2-connector', 59 | 60 | // ... More code 61 | ``` 62 | 63 | Now you can run your project locally, 64 | 65 | ``` 66 | npm start 67 | ``` 68 | 69 | And then visit http://127.0.0.1:3000 in your browser. 70 | 71 | ## Deploying to the Moovweb XDN 72 | 73 | The front-end React Storefront can be hosted anywhere that supports Node and Express but it works great on the [Moovweb XDN](https://www.moovweb.com/). You can try the XDN for free by signing up [here](https://moovweb.app/signup?redirectTo=/). Once you have an account you can deploy it by running `xdn deploy`: 74 | 75 | ``` 76 | xdn deploy 77 | ``` 78 | 79 | Refer to the [XDN deployment guide](https://developer.moovweb.com/guides/deploying) for more information. 80 | 81 | 82 | ## Development 83 | 84 | - In 1st terminal window (this repo), run `yalc publish` & `npm run watch` 85 | - In 2nd terminal window, open [RSF starter app (`commercial` branch)](https://github.com/storefront-foundation/react-storefront-starter-app/tree/commercial) 86 | - Go to `next.config.js` and change `connector` field value to `react-storefront-magento2-connector` 87 | - Run `yalc add react-storefront-connector` 88 | - Run `npm i` 89 | - Run `npm run start` 90 | -------------------------------------------------------------------------------- /examples/next/cart/common/addToCart.js: -------------------------------------------------------------------------------- 1 | import { 2 | fetchAddSimpleProductsToCart, 3 | normalizeAddSimpleProductsToCart, 4 | } from 'react-storefront-magento2-connector/cart/common/addSimpleProductsToCart'; 5 | 6 | import get from 'lodash/get'; 7 | import getError from 'react-storefront-magento2-connector/helpers/getError'; 8 | 9 | /** 10 | * Magento 2: common addToCart handler 11 | */ 12 | export default async function addToCart(req, res) { 13 | const token = get(req, 'query.token'); 14 | const cartId = get(req, 'query.cartId'); 15 | const sku = get(req, 'query.sku'); 16 | const quantity = get(req, 'query.quantity'); 17 | const rawData = await fetchAddSimpleProductsToCart({ 18 | token, 19 | cartId, 20 | sku, 21 | quantity, 22 | }); 23 | 24 | const error = getError(rawData); 25 | if (error) { 26 | return res.json({ 27 | error, 28 | }); 29 | } 30 | 31 | const data = normalizeAddSimpleProductsToCart(rawData); 32 | return res.json({ 33 | ...data, 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /examples/next/cart/common/mergeCarts.js: -------------------------------------------------------------------------------- 1 | import { fetchMergeCarts, normalizeMergeCarts } from 'react-storefront-magento2-connector/cart/common/mergeCarts'; 2 | 3 | import get from 'lodash/get'; 4 | 5 | /** 6 | * Magento 2: common mergeCarts handler 7 | */ 8 | export default async function mergeCarts(req, res) { 9 | const token = get(req, 'query.token'); 10 | const sourceCartId = get(req, 'query.sourceCartId'); 11 | const destinationCartId = get(req, 'query.destinationCartId'); 12 | const rawData = await fetchMergeCarts(token, sourceCartId, destinationCartId); 13 | const data = normalizeMergeCarts(rawData); 14 | return res.json({ 15 | ...data, 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /examples/next/cart/customer/cart.js: -------------------------------------------------------------------------------- 1 | import { fetchCart, normalizeCart } from 'react-storefront-magento2-connector/cart/customer/cart'; 2 | 3 | import get from 'lodash/get'; 4 | 5 | /** 6 | * Magento 2: customer cart handler 7 | */ 8 | export default async function cart(req, res) { 9 | const token = get(req, 'query.token'); 10 | const rawData = await fetchCart(token); 11 | const data = normalizeCart(rawData); 12 | return res.json({ 13 | ...data, 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /examples/next/cart/customer/createCustomer.js: -------------------------------------------------------------------------------- 1 | import { fetchCreateCustomer, normalizeCreateCustomer } from 'react-storefront-magento2-connector/cart/customer/createCustomer'; 2 | 3 | import get from 'lodash/get'; 4 | import getError from 'react-storefront-magento2-connectorr/helpers/getError'; 5 | 6 | /** 7 | * Magento 2: createCustomer handler 8 | */ 9 | export default async function createCustomer(req, res) { 10 | const firstName = get(req, 'query.firstName'); 11 | const lastName = get(req, 'query.lastName'); 12 | const email = get(req, 'query.email'); 13 | const password = get(req, 'query.password'); 14 | 15 | const rawData = await fetchCreateCustomer({ 16 | firstName, 17 | lastName, 18 | email, 19 | password, 20 | }); 21 | const error = getError(rawData); 22 | if (error) { 23 | return res.json({ 24 | error, 25 | }); 26 | } 27 | const data = normalizeCreateCustomer(rawData); 28 | return res.json({ 29 | ...data, 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /examples/next/cart/customer/generateToken.js: -------------------------------------------------------------------------------- 1 | import { fetchGenerateToken, normalizeGenerateToken } from 'react-storefront-magento2-connector/cart/customer/generateToken'; 2 | 3 | import get from 'lodash/get'; 4 | 5 | /** 6 | * Magento 2: customer generateToken handler 7 | */ 8 | export default async function generateToken(req, res) { 9 | const email = get(req, 'query.email'); 10 | const password = get(req, 'query.password'); 11 | const rawData = await fetchGenerateToken(email, password); 12 | const data = normalizeGenerateToken(rawData); 13 | return res.json({ 14 | ...data, 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /examples/next/cart/customer/revokeToken.js: -------------------------------------------------------------------------------- 1 | import { fetchRevokeToken, normalizeRevokeToken } from 'react-storefront-magento2-connector/cart/customer/revokeToken'; 2 | 3 | import get from 'lodash/get'; 4 | 5 | /** 6 | * Magento 2: customer revokeToken handler 7 | */ 8 | export default async function revokeToken(req, res) { 9 | const token = get(req, 'query.token'); 10 | const rawData = await fetchRevokeToken(token); 11 | const data = normalizeRevokeToken(rawData); 12 | return res.json({ 13 | ...data, 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /examples/next/cart/guest/cart.js: -------------------------------------------------------------------------------- 1 | import { fetchCart, normalizeCart } from 'react-storefront-magento2-connector/cart/guest/cart'; 2 | 3 | import get from 'lodash/get'; 4 | 5 | /** 6 | * Magento 2: guest cart handler 7 | */ 8 | export default async function cart(req, res) { 9 | const cartId = get(req, 'query.cartId'); 10 | const rawData = await fetchCart(cartId); 11 | const data = normalizeCart(rawData); 12 | return res.json({ 13 | ...data, 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /examples/next/cart/guest/obtainSession.js: -------------------------------------------------------------------------------- 1 | import { 2 | fetchCreateEmptyCart, 3 | normalizeCreateEmptyCart, 4 | } from 'react-storefront-magento2-connector/cart/guest/createEmptyCart'; 5 | 6 | /** 7 | * Magento 2: guest obtainSession handler 8 | */ 9 | export default async function obtainSession(req, res) { 10 | const rawData = await fetchCreateEmptyCart(); 11 | const guestCartId = normalizeCreateEmptyCart(rawData); 12 | return res.json({ 13 | guestCartId, 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /examples/react/components/BasicLoginForm.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useContext } from 'react'; 2 | import { Container, TextField, Button } from '@material-ui/core'; 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | import useLoginStatus from '../hooks/useLoginStatus'; 5 | import SessionContext from '../session/SessionContext'; 6 | 7 | const useStyles = makeStyles((/* theme */) => ({ 8 | root: { 9 | border: '1px solid #9c0000', 10 | minHeight: 100, 11 | padding: 10, 12 | }, 13 | spacingBlock: { 14 | margin: 10, 15 | }, 16 | })); 17 | 18 | export default function BasicLoginForm() { 19 | const classes = useStyles(); 20 | 21 | const { actions } = useContext(SessionContext); 22 | const isLoggedIn = useLoginStatus(); 23 | 24 | const [signInEmail, setSignInEmail] = useState(''); 25 | const [signInPassword, setSignInPassword] = useState(''); 26 | const [signInError, setSignInError] = useState(''); 27 | 28 | const [signUpEmail, setSignUpEmail] = useState(''); 29 | const [signUpPassword, setSignUpPassword] = useState(''); 30 | const [signUpFirstName, setSignUpFirstName] = useState(''); 31 | const [signUpLastName, setSignUpLastName] = useState(''); 32 | const [signUpError, setSignUpError] = useState(''); 33 | 34 | const signIn = async () => { 35 | setSignInError(''); 36 | const email = signInEmail; 37 | const password = signInPassword; 38 | const response = await actions.signIn(email, password); 39 | if (!response.success) { 40 | setSignInError(response.reason); 41 | } 42 | }; 43 | 44 | const signOut = async () => { 45 | await actions.signOut(); 46 | }; 47 | 48 | const signUp = async () => { 49 | setSignUpError(''); 50 | const firstName = signUpFirstName; 51 | const lastName = signUpLastName; 52 | const email = signUpEmail; 53 | const password = signUpPassword; 54 | const response1 = await actions.signUp({ 55 | firstName, 56 | lastName, 57 | email, 58 | password, 59 | }); 60 | if (!response1.success) { 61 | setSignUpError(response1.reason); 62 | return; 63 | } 64 | const response2 = await actions.signIn(email, password); 65 | if (!response2.success) { 66 | setSignUpError(response2.reason); 67 | } 68 | }; 69 | 70 | return ( 71 | <> 72 | 73 |
74 | Is logged in user: 75 | {` ${isLoggedIn}`} 76 |
77 | {!isLoggedIn ? ( 78 | <> 79 |
80 |

SIGN IN

81 |
82 |
83 | setSignInEmail(event.target.value)} 87 | /> 88 |
89 |
90 | setSignInPassword(event.target.value)} 95 | /> 96 |
97 |
98 | 99 |
100 | {signInError && ( 101 |
102 | {signInError} 103 |
104 | )} 105 |
106 |

or

107 |

SIGN UP

108 |
109 |
110 | setSignUpFirstName(event.target.value)} 114 | /> 115 |
116 |
117 | setSignUpLastName(event.target.value)} 121 | /> 122 |
123 |
124 | setSignUpEmail(event.target.value)} 128 | /> 129 |
130 |
131 | setSignUpPassword(event.target.value)} 136 | /> 137 |
138 |
139 | 140 |
141 | {signUpError && ( 142 |
143 | {signUpError} 144 |
145 | )} 146 | 147 | ) : ( 148 | <> 149 |
150 | 151 |
152 | 153 | )} 154 |
155 | 156 | ); 157 | } 158 | -------------------------------------------------------------------------------- /examples/react/hooks/useCartTotal.js: -------------------------------------------------------------------------------- 1 | import { useContext, useMemo } from 'react'; 2 | import get from 'lodash/get'; 3 | import SessionContext from '../session/SessionContext'; 4 | 5 | function useCartTotal() { 6 | const context = useContext(SessionContext); 7 | const items = get(context, 'session.cart.items', []); 8 | const total = useMemo(() => items 9 | .reduce((totalAcc, item) => item.quantity + totalAcc, 0), 10 | [items]); 11 | return total; 12 | } 13 | 14 | export default useCartTotal; 15 | -------------------------------------------------------------------------------- /examples/react/hooks/useLoginStatus.js: -------------------------------------------------------------------------------- 1 | import { useContext, useMemo } from 'react'; 2 | import get from 'lodash/get'; 3 | import SessionContext from '../session/SessionContext'; 4 | 5 | function useLoginStatus() { 6 | const context = useContext(SessionContext); 7 | const customerCartId = get(context, 'session.customerCartId', null); 8 | const isLoggedIn = useMemo(() => Boolean(customerCartId), [customerCartId]); 9 | return isLoggedIn; 10 | } 11 | 12 | export default useLoginStatus; 13 | -------------------------------------------------------------------------------- /examples/react/session/SessionContext.js: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | const initSessionState = { 4 | email: null, 5 | guestCartId: null, 6 | customerCartId: null, 7 | customerToken: null, 8 | cart: { 9 | items: [], 10 | }, 11 | // account: {...}, // customer's account data will be here 12 | }; 13 | 14 | const SessionContext = createContext(initSessionState); 15 | 16 | export { initSessionState }; 17 | export default SessionContext; 18 | -------------------------------------------------------------------------------- /examples/react/session/SessionProvider.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useMemo, useState } from 'react'; 2 | import SessionContext, { initSessionState } from './SessionContext'; 3 | import { getCookieValue, killCookie, setCookie } from './cookieHelpers'; 4 | 5 | import fetch from 'isomorphic-unfetch'; 6 | import get from 'lodash/get'; 7 | 8 | async function obtainSession(session, setSession) { 9 | // ### 1 - LOGGED IN SESSION 10 | const tokenCookieValue = getCookieValue('M2_token'); 11 | if (tokenCookieValue) { 12 | const customerCartUrl = `/api/M2/cart/customer/cart?token=${encodeURIComponent(tokenCookieValue)}`; 13 | const customerCartData = await fetch(customerCartUrl).then((res) => res.json()); 14 | const { cart, customerCartId } = customerCartData; 15 | setSession({ // initialize customer session & drop guest 16 | ...session, 17 | guestCartId: null, 18 | customerCartId, 19 | customerToken: tokenCookieValue, 20 | cart, 21 | }); 22 | setCookie('M2_token', tokenCookieValue, 3600 * 24 * 30); // renew customer token cookie for 30 more days 23 | setCookie('M2_customerCartId', customerCartId, 3600 * 24 * 30); // set/renew customer cart ID cookie for 30 days 24 | killCookie('M2_guestCartId'); // kill guest cart ID cookie just in case (prevents possible cart merges issues) 25 | return; 26 | } 27 | 28 | // ### 2 - GUEST SESSION 29 | // # 2.1 - Obtain returning guest session 30 | killCookie('M2_customerCartId'); // kill customer cart ID cookie just in case (prevents possible cart merges issues) 31 | const guestCartIdCookieValue = getCookieValue('M2_guestCartId'); 32 | if (guestCartIdCookieValue) { 33 | const guestCartUrl = `/api/M2/cart/guest/cart?cartId=${encodeURIComponent(guestCartIdCookieValue)}`; 34 | const guestCartData = await fetch(guestCartUrl).then((res) => res.json()); 35 | const { cart } = guestCartData; 36 | setSession({ // initialize guest session 37 | ...session, 38 | guestCartId: guestCartIdCookieValue, 39 | cart, 40 | }); 41 | setCookie('M2_guestCartId', guestCartIdCookieValue, 3600 * 24 * 7); // renew cookie for 7 more days 42 | return; 43 | } 44 | 45 | // # 2.2 - Obtain new guest session 46 | const { guestCartId } = await fetch('/api/M2/cart/guest/obtainSession').then((res) => res.json()); 47 | setSession({ // initialize guest session 48 | ...session, 49 | guestCartId, 50 | }); 51 | setCookie('M2_guestCartId', guestCartId, 3600 * 24 * 7); // set guest cart id cookie for 7 days 52 | } 53 | 54 | export default function SessionProvider({ children }) { 55 | const [session, setSession] = useState(initSessionState); 56 | const context = useMemo(() => ({ 57 | session, 58 | actions: { 59 | signIn: async (email, password) => { 60 | const emailQuery = encodeURIComponent(email); 61 | const passwordQuery = encodeURIComponent(password); 62 | const fetchUrl = `/api/M2/cart/customer/generateToken?email=${emailQuery}&password=${passwordQuery}`; 63 | const { token } = await fetch(fetchUrl).then((res) => res.json()); 64 | if (!token) { 65 | // unsuccessful login 66 | return { 67 | success: false, 68 | reason: 'The account sign-in was incorrect or your account is disabled temporarily. Please wait and try again later.', 69 | }; 70 | } 71 | 72 | // fetch customer cart data 73 | const customerCartUrl = `/api/M2/cart/customer/cart?token=${encodeURIComponent(token)}`; 74 | const customerCartData = await fetch(customerCartUrl).then((res) => res.json()); 75 | let { cart, customerCartId } = customerCartData; 76 | 77 | // if guest has cart items -> merge it with logged in customer cart 78 | const guestCartIdCookieValue = getCookieValue('M2_guestCartId'); 79 | if (guestCartIdCookieValue && get(session, 'cart.items', []).length) { 80 | const tkn = encodeURIComponent(token); 81 | const sourceCartId = encodeURIComponent(guestCartIdCookieValue); 82 | const destinationCartId = encodeURIComponent(customerCartId); 83 | const queryStr = `token=${tkn}&sourceCartId=${sourceCartId}&destinationCartId=${destinationCartId}`; 84 | const mergeCartsFetchUrl = `/api/M2/cart/common/mergeCarts?${queryStr}`; 85 | const mergeCartsData = await fetch(mergeCartsFetchUrl).then((res) => res.json()); 86 | cart = get(mergeCartsData, 'cart', cart); 87 | customerCartId = get(mergeCartsData, 'customerCartId', customerCartId); 88 | } 89 | 90 | // @TODO: also customer account data like firstName, lastName can be fetched here 91 | // Docs: https://devdocs.magento.com/guides/v2.3/graphql/queries/customer.html 92 | // ... 93 | 94 | setSession({ // initialize customer session & drop guest 95 | ...session, 96 | guestCartId: null, 97 | customerCartId, 98 | customerToken: token, 99 | cart, 100 | }); 101 | setCookie('M2_token', token, 3600 * 24 * 30); // set customer token cookie for 30 more days 102 | setCookie('M2_customerCartId', customerCartId, 3600 * 24 * 30); // set customer cart ID cookie for 30 days 103 | killCookie('M2_guestCartId'); // kill guest cart ID cookie just in case (prevents possible cart merges issues) 104 | return { 105 | success: true, 106 | }; 107 | }, 108 | signOut: async () => { 109 | const token = encodeURIComponent(get(session, 'customerToken', '')); 110 | if (!token) { 111 | return { 112 | success: false, 113 | reason: 'session.customerToken doesn\'t exist', 114 | }; 115 | } 116 | 117 | const { result } = await fetch(`/api/M2/cart/customer/revokeToken?token=${token}`).then((res) => res.json()); 118 | if (result !== true) { 119 | console.error('An error occured during customer token revoke'); 120 | } 121 | const { guestCartId } = await fetch('/api/M2/cart/guest/obtainSession').then((res) => res.json()); 122 | setSession({ // initialize new guest session 123 | ...initSessionState, 124 | guestCartId, 125 | }); 126 | setCookie('M2_guestCartId', guestCartId, 3600 * 24 * 7); // set guest cart id cookie for 7 days 127 | killCookie('M2_token'); // kill customer token cookie 128 | killCookie('M2_customerCartId'); // kill customer cart id cookie 129 | return { 130 | success: true, 131 | }; 132 | }, 133 | signUp: async ({ 134 | firstName, 135 | lastName, 136 | email, 137 | password, 138 | }) => { 139 | let queryStr = ''; 140 | queryStr += `firstName=${encodeURIComponent(firstName)}`; 141 | queryStr += `&lastName=${encodeURIComponent(lastName)}`; 142 | queryStr += `&email=${encodeURIComponent(email)}`; 143 | queryStr += `&password=${encodeURIComponent(password)}`; 144 | const signUpFetchUrl = `/api/M2/cart/customer/createCustomer?${queryStr}`; 145 | const signUpData = await fetch(signUpFetchUrl).then((res) => res.json()); 146 | if (signUpData.error) { 147 | return { 148 | success: false, 149 | reason: signUpData.error, 150 | }; 151 | } 152 | return { 153 | success: true, 154 | }; 155 | }, 156 | addToCart: async (product = {}, { size = {}, color = {}, quantity = 1 }) => { 157 | const cartId = get(session, 'guestCartId') || get(session, 'customerCartId'); 158 | const token = get(session, 'customerToken'); 159 | 160 | let sku = get(product, 'sku'); 161 | 162 | if (product.isConfigurableProduct) { 163 | sku += `-${size.id}-${color.id}`; 164 | } 165 | 166 | let queryStr = ''; 167 | queryStr += `sku=${encodeURIComponent(sku)}`; 168 | queryStr += `&quantity=${encodeURIComponent(quantity)}`; 169 | queryStr += `&cartId=${encodeURIComponent(cartId)}`; 170 | queryStr += token ? `&token=${encodeURIComponent(token)}` : ''; 171 | const addToCartFetchUrl = `/api/M2/cart/common/addToCart?${queryStr}`; 172 | const addToCartData = await fetch(addToCartFetchUrl).then((res) => res.json()); 173 | if (addToCartData.error) { 174 | return { 175 | success: false, 176 | reason: addToCartData.error, 177 | }; 178 | } 179 | const { cart } = addToCartData; 180 | setSession({ // initialize customer session & drop guest 181 | ...session, 182 | cart, 183 | }); 184 | return { 185 | success: true, 186 | }; 187 | }, 188 | }, 189 | }), [session]); 190 | 191 | useEffect(() => { 192 | obtainSession(session, setSession); 193 | }, []); 194 | 195 | return ( 196 | 197 | {children} 198 | 199 | ); 200 | } 201 | -------------------------------------------------------------------------------- /examples/react/session/cookieHelpers.js: -------------------------------------------------------------------------------- 1 | import get from 'lodash/get'; 2 | 3 | function convertCookieStringToObject(cookiesStr) { 4 | return (cookiesStr || '').split(';').reduce( 5 | (cookiesObjectAcc, cookieStr) => { 6 | let [name, value] = cookieStr.split('='); 7 | name = (name || '').trim(); // add trimming just in case 8 | value = (value || '').trim(); 9 | return { 10 | ...cookiesObjectAcc, 11 | [name]: value, 12 | }; 13 | }, 14 | {}, 15 | ) || null; // return `null` instead of empty string 16 | } 17 | 18 | export function setCookie(cookieName, cookieValue, expireInSeconds = 3600 * 24/* default: 1 day */) { 19 | const timestamp = new Date(); 20 | timestamp.setSeconds(timestamp.getSeconds() + expireInSeconds); 21 | const expires = timestamp.toUTCString(); 22 | document.cookie = `${cookieName}=${cookieValue}; expires=${expires}`; 23 | } 24 | 25 | export function killCookie(cookieName) { 26 | setCookie(cookieName, 'Unbeing dead isn\'t being alive', -3600 * 24 * 365); 27 | } 28 | 29 | export function getCookieValue(cookieName) { 30 | const cookies = convertCookieStringToObject(document.cookie); 31 | return get(cookies, cookieName, null); 32 | } 33 | -------------------------------------------------------------------------------- /examples/rsf/getGlobalData.js: -------------------------------------------------------------------------------- 1 | import { fetchMenu, normalizeMenu } from 'react-storefront-magento2-connector/menu'; 2 | 3 | /** 4 | * To be used as global data RSF handler (also known as `globalState` in RSF v6) 5 | */ 6 | import get from 'lodash/get'; 7 | import isEmpty from 'lodash/isEmpty'; 8 | import pick from 'lodash/pick'; 9 | 10 | function normalizeMenuItems(items) { 11 | if (isEmpty(items)) { 12 | return null; 13 | } 14 | return items.map(item => ({ 15 | text: get(item, 'name'), 16 | as: `/s${get(item, 'url')}`, 17 | href: '/s/[subcategoryId]', 18 | items: normalizeMenuItems(get(item, 'items', [])), 19 | })); 20 | } 21 | 22 | function getTabs(menu) { 23 | const items = get(menu, 'items', []); 24 | return items.map((item) => ({ 25 | ...pick(item, ['text', 'href', 'as']), 26 | subcategories: (get(item, 'items') || []).map(subcategoryItem => ({ 27 | ...pick(subcategoryItem, ['text', 'href', 'as']), 28 | })), 29 | })); 30 | } 31 | 32 | export default async function getGlobalData() { 33 | const rawData = await fetchMenu({ numberOfLevels: 3 }); 34 | const menuItems = normalizeMenu(rawData); 35 | const menu = { 36 | header: 'header', 37 | footer: 'footer', 38 | items: normalizeMenuItems(menuItems), 39 | }; 40 | const tabs = getTabs(menu); 41 | return Promise.resolve({ menu, tabs }); 42 | } 43 | -------------------------------------------------------------------------------- /examples/rsf/productHandler.js: -------------------------------------------------------------------------------- 1 | import { fetchProduct, normalizeProduct } from 'react-storefront-magento2-connector/product'; 2 | 3 | /** 4 | * To be used as `/api/p/[productId].js` RSF product handler 5 | */ 6 | import fulfillAPIRequest from 'react-storefront/props/fulfillAPIRequest'; 7 | import get from 'lodash/get'; 8 | import getGlobalData from './getGlobalData'; 9 | import withCaching from 'react-storefront/utils/withCaching'; 10 | 11 | async function getPageData(productId) { 12 | const pid = productId.replace('.html', ''); 13 | const rawProduct = await fetchProduct(pid); 14 | const product = normalizeProduct(rawProduct, pid); 15 | 16 | return { 17 | title: `Product ${pid}`, 18 | product, 19 | breadcrumbs: [ 20 | { 21 | text: 'Home', 22 | href: '/', 23 | }, 24 | ], 25 | }; 26 | } 27 | 28 | async function productHandler(req, res) { 29 | const productId = get(req, 'query.productId'); 30 | 31 | const result = await fulfillAPIRequest(req, { 32 | appData: getGlobalData, 33 | pageData: () => getPageData(productId), 34 | }); 35 | 36 | res.json(result); 37 | } 38 | 39 | export default withCaching(productHandler, 60 * 60 * 24); // cache with the service worker for 24 hours 40 | -------------------------------------------------------------------------------- /examples/rsf/productReviewsHandler.js: -------------------------------------------------------------------------------- 1 | import fetchProductReviews from 'react-storefront-magento2-connector/product/reviews/fetchProductReviews'; 2 | import get from 'lodash/get'; 3 | 4 | export default async function reviews(req, res) { 5 | const productId = get(req, 'query.productId', ''); 6 | const data = await fetchProductReviews(productId); 7 | return res.send(data); 8 | } 9 | -------------------------------------------------------------------------------- /examples/rsf/subcategoryHandler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * To be used as `/api/s/[...slug].js` RSF subcategory handler 3 | */ 4 | import { fetchCmsBlocks, normalizeCmsBlocks } from 'react-storefront-magento2-connector/cms/blocks'; 5 | import { fetchSubcategory, normalizeSubcategory } from 'react-storefront-magento2-connector/subcategory'; 6 | import { fetchSubcategoryId, normalizeSubcategoryId } from 'react-storefront-magento2-connector/subcategory/id'; 7 | import { 8 | fetchSubcategorySubCategories, 9 | normalizeSubcategorySubCategories, 10 | } from 'react-storefront-magento2-connector/subcategory/sub-categories'; 11 | 12 | import first from 'lodash/first'; 13 | import fulfillAPIRequest from 'react-storefront/props/fulfillAPIRequest'; 14 | import get from 'lodash/get'; 15 | import getGlobalData from './getGlobalData'; 16 | import groupBy from 'lodash/groupBy'; 17 | import isArray from 'lodash/isArray'; 18 | import isEmpty from 'lodash/isEmpty'; 19 | import last from 'lodash/last'; 20 | import withCaching from 'react-storefront/utils/withCaching'; 21 | 22 | function filtersToQuery(filters) { 23 | const filtersGrouped = groupBy(filters, (x) => x.split(':')[0]); 24 | const keys = Object.keys(filtersGrouped); 25 | return keys.map((key) => { 26 | const values = filtersGrouped[key].map((f) => f.replace(`${key}:`, '')); 27 | if (key !== 'price') { 28 | return `${key}: { in: ${JSON.stringify(values)} }`; 29 | } 30 | 31 | const prices = values.map((x) => x.split('_').map(Number)).flat().sort(); 32 | const from = first(prices); 33 | const to = last(prices); 34 | if (!from && !to) { 35 | return null; 36 | } 37 | const fromQuery = from ? `from: "${from}"` : ''; 38 | const toQuery = to ? `to: "${to}"` : ''; 39 | return ` 40 | ${key}: { 41 | ${fromQuery} 42 | ${toQuery} 43 | } 44 | `; 45 | }).filter(Boolean).join('\n'); 46 | } 47 | 48 | function resolveCmsBlocksIdentifiers(urlKey) { 49 | if (urlKey === 'what-is-new') { 50 | urlKey = 'new'; // eslint-disable-line no-param-reassign 51 | } 52 | return `${urlKey}-block`; 53 | } 54 | 55 | async function subcategoryHandler(req, res) { 56 | const defaultSort = 'position: DESC'; // default sort value on demo 57 | const { query } = req; 58 | const { q = '', page = 1 } = query; 59 | let { slug, filters, sort = defaultSort } = query; 60 | if (!isArray(slug)) { 61 | slug = slug.split('/'); 62 | } 63 | 64 | const isLanding = get(slug, 'length', 0) === 1; // 1st level pages (/women, /men, etc.) are landings 65 | const isSearch = !isEmpty(q); 66 | const urlKey = (last(slug) || '').replace('.html', ''); 67 | 68 | if (sort === 'rating') { 69 | sort = defaultSort; // remove default RSF filter 70 | } 71 | 72 | if (filters) { 73 | filters = JSON.parse(filters); 74 | } else { 75 | filters = []; 76 | } 77 | 78 | // 1) get `id` and `name` & `navMenu` data 79 | let id; 80 | let name; 81 | let navMenu = null; 82 | if (isSearch) { 83 | id = `Search: ${q}`; 84 | name = `Results for "${q}"`; 85 | } else { 86 | const rawIdData = await fetchSubcategoryId({ urlKey }); 87 | const idData = normalizeSubcategoryId(rawIdData); 88 | id = idData.id; 89 | name = idData.name; 90 | const rawSubCategoriesData = await fetchSubcategorySubCategories({ urlKey }); 91 | navMenu = normalizeSubcategorySubCategories(rawSubCategoriesData); 92 | } 93 | 94 | // 2) get all subcategory page data 95 | const rawData = await fetchSubcategory({ 96 | categoryId: isSearch ? null : id, 97 | sort, 98 | currentPage: page, 99 | filter: filtersToQuery(filters), 100 | search: q, 101 | }); 102 | const data = normalizeSubcategory(rawData); 103 | 104 | // 3) get CMS slots data 105 | let cmsBlocks = []; 106 | if (isLanding) { 107 | const identifiers = resolveCmsBlocksIdentifiers(urlKey); 108 | const rawCmsBlocks = await fetchCmsBlocks({ identifiers }); 109 | cmsBlocks = normalizeCmsBlocks(rawCmsBlocks).items; 110 | } 111 | 112 | // collect all page data 113 | const pageData = { 114 | id, 115 | name, 116 | title: name, 117 | total: get(data, 'total', 0), 118 | page: get(data, 'currentPage', 1), 119 | totalPages: get(data, 'totalPages', 0), 120 | isLanding, 121 | cmsBlocks, 122 | products: get(data, 'items').map((item) => ({ 123 | ...item, 124 | id: item.sku, 125 | thumbnail: { 126 | src: get(item, 'thumbnail', ''), 127 | }, 128 | price: get(item, 'basePrice'), 129 | })), 130 | sort, 131 | sortOptions: get(data, 'sortOptions', []) 132 | .map((option) => ([{ // split up for ASC/DESC sort for demo 133 | name: `${get(option, 'name')} ⬇️`, 134 | code: `${get(option, 'code')}: DESC`, 135 | }, { 136 | name: `${get(option, 'name')} ⬆️`, 137 | code: `${get(option, 'code')}: ASC`, 138 | }])).flat(), 139 | filters, 140 | facets: get(data, 'facets', []), 141 | navMenu, 142 | breadcrumbs: [ 143 | { 144 | text: 'Home', 145 | href: '/', 146 | }, 147 | ], 148 | }; 149 | 150 | res.json( 151 | await fulfillAPIRequest(req, { 152 | appData: getGlobalData, 153 | pageData: () => Promise.resolve(pageData), 154 | }), 155 | ); 156 | } 157 | 158 | export default withCaching(subcategoryHandler, 60 * 60 * 24); // cache with the service worker for 24 hours 159 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-storefront-magento2-connector", 3 | "version": "1.0.4", 4 | "description": "Magento 2 Connector", 5 | "module": "./index.js", 6 | "watch": { 7 | "push-build": { 8 | "patterns": [ 9 | "src" 10 | ], 11 | "extensions": "js,ts", 12 | "quiet": false 13 | } 14 | }, 15 | "scripts": { 16 | "release": "npm run build && cd dist && npm publish && cd ..", 17 | "watch": "npm-watch push-build", 18 | "ts:watch": "tsc --watch", 19 | "ts:check": "tsc", 20 | "push-build": "npm run build && cd dist && yalc push && cd ..", 21 | "clean": "mkdir -p dist; cd dist; ls | grep -v 'node_modules\\|package-lock.json' | xargs rm -rf; cd ../;", 22 | "build": "npm run ts:check && npm run build:prep && NODE_ENV=production npm run build:sources", 23 | "build:prep": "npm run clean && cp package.json README.md dist", 24 | "build:sources": "babel ./src --source-maps --out-dir dist --extensions \".js,.ts\"", 25 | "lint": "npm run lint:check", 26 | "lint:check": "npx eslint src --ext .ts,.js", 27 | "lint:fix": "npx eslint src --ext .ts,.js --fix" 28 | }, 29 | "author": "", 30 | "license": "ISC", 31 | "dependencies": { 32 | "cheerio": "^1.0.0-rc.3", 33 | "isomorphic-unfetch": "^3.0.0", 34 | "lodash": "^4.17.20", 35 | "react-storefront": "^8.17.2", 36 | "react-storefront-connector": "^1.0.0" 37 | }, 38 | "devDependencies": { 39 | "@babel/cli": "^7.7.7", 40 | "@babel/core": "^7.7.7", 41 | "@babel/plugin-proposal-class-properties": "^7.8.0", 42 | "@babel/plugin-transform-runtime": "^7.7.6", 43 | "@babel/preset-env": "^7.7.7", 44 | "@babel/preset-react": "^7.7.4", 45 | "@babel/preset-typescript": "^7.10.1", 46 | "@types/isomorphic-fetch": "0.0.35", 47 | "@types/lodash": "^4.14.155", 48 | "@typescript-eslint/eslint-plugin": "^4.2.0", 49 | "@typescript-eslint/parser": "^4.2.0", 50 | "babel-loader": "^8.0.6", 51 | "babel-plugin-transform-imports": "^2.0.0", 52 | "eslint": "^7.9.0", 53 | "eslint-config-airbnb-typescript": "^10.0.0", 54 | "eslint-plugin-import": "^2.22.0", 55 | "husky": "^4.3.0", 56 | "npm-watch": "^0.6.0", 57 | "typescript": "^3.9.7" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/app/getAppData.ts: -------------------------------------------------------------------------------- 1 | import get from 'lodash/get'; 2 | import isEmpty from 'lodash/isEmpty'; 3 | import pick from 'lodash/pick'; 4 | import MenuItem from 'react-storefront-connector/MenuItem'; 5 | import AppData from 'react-storefront-connector/AppData'; 6 | import { fetchMenu, normalizeMenu } from '../menu'; 7 | 8 | function normalizeMenuItems(items: any[]): MenuItem[] { 9 | if (isEmpty(items)) { 10 | return null; 11 | } 12 | return items.map((item) => ({ 13 | text: get(item, 'name'), 14 | as: `/s${get(item, 'url')}`, 15 | href: '/s/[...categorySlug]', 16 | items: normalizeMenuItems(get(item, 'items', [])), 17 | })); 18 | } 19 | 20 | function getTabs(menu: MenuItem): MenuItem[] { 21 | const items: MenuItem[] = menu.items || []; 22 | return items.map((item: MenuItem): MenuItem => ({ 23 | ...pick(item, ['text', 'href', 'as']), 24 | items: (get(item, 'items') || []).map((subcategoryItem: MenuItem): MenuItem => ({ 25 | ...pick(subcategoryItem, ['text', 'href', 'as']), 26 | items: [], 27 | })), 28 | })); 29 | } 30 | 31 | export default async function getAppData(): Promise { 32 | const rawData = await fetchMenu({ numberOfLevels: 3 }); 33 | const menuItems = normalizeMenu(rawData); 34 | const menu: MenuItem = { 35 | header: 'header', 36 | footer: 'footer', 37 | items: normalizeMenuItems(menuItems), 38 | }; 39 | const tabs = getTabs(menu); 40 | return { menu, tabs }; 41 | } 42 | -------------------------------------------------------------------------------- /src/app/withAppData.ts: -------------------------------------------------------------------------------- 1 | import fulfillAPIRequest from 'react-storefront/props/fulfillAPIRequest'; 2 | import getAppData from './getAppData'; 3 | 4 | export default function withAppData(req, getPageData) { 5 | return fulfillAPIRequest(req, { 6 | appData: getAppData, 7 | pageData: getPageData, 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /src/cart/addToCart.ts: -------------------------------------------------------------------------------- 1 | import get from 'lodash/get'; 2 | import { getCookieValue } from '../helpers/nodeCookieHelpers'; 3 | import { 4 | fetchAddSimpleProductsToCart, 5 | normalizeAddSimpleProductsToCart, 6 | } from './common/addSimpleProductsToCart'; 7 | import { COOKIES } from '../constants'; 8 | import getError from '../helpers/getError'; 9 | import CartResponse from '../types/CartResponse'; 10 | import obtainSession from '../session/guest/obtainSession'; 11 | 12 | /** 13 | * Magento 2: common -> addToCart 14 | */ 15 | async function fetchAddToCart({ 16 | token, cartId, sku, quantity, 17 | }): Promise { 18 | const rawData = await fetchAddSimpleProductsToCart({ 19 | token, 20 | cartId, 21 | sku, 22 | quantity, 23 | }); 24 | 25 | const error = getError(rawData); 26 | if (error) { 27 | return { 28 | error, 29 | }; 30 | } 31 | 32 | const data = normalizeAddSimpleProductsToCart(rawData); 33 | return { 34 | ...data, 35 | }; 36 | } 37 | 38 | /** 39 | * add to cart handler 40 | */ 41 | export default async function addToCart(product, quantity, req/* , res */): Promise { 42 | const cartId = getCookieValue(req, COOKIES.M2_GUEST_CART_ID) 43 | || getCookieValue(req, COOKIES.M2_CUSTOMER_CART_ID) 44 | || (await obtainSession()); // will get here in AMP 45 | 46 | const token = getCookieValue(req, COOKIES.M2_CUSTOMER_TOKEN); 47 | const body = get(req, 'body', {}); 48 | const size = get(body, 'size'); 49 | const color = get(body, 'color'); 50 | let sku = get(product, 'sku'); 51 | 52 | if (get(product, 'isConfigurableProduct') && size && color) { 53 | sku += `-${size}-${color}`; 54 | } 55 | 56 | const responseData = await fetchAddToCart({ 57 | token, cartId, sku, quantity, 58 | }); 59 | 60 | if (responseData.error) { 61 | throw new Error(responseData.error); 62 | } else { 63 | return responseData; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/cart/cart.ts: -------------------------------------------------------------------------------- 1 | import Result from 'react-storefront-connector/Result'; 2 | import withAppData from '../app/withAppData'; 3 | import CartResponse from '../types/CartResponse'; 4 | 5 | /** 6 | * GET: default cart handler 7 | */ 8 | export default async function cart(req/* , res */): Promise> { 9 | return withAppData(req, () => Promise.resolve({ 10 | title: 'Cart', 11 | cart: {}, 12 | breadcrumbs: [ 13 | { 14 | text: 'Home', 15 | href: '/', 16 | }, 17 | ], 18 | })); 19 | } 20 | -------------------------------------------------------------------------------- /src/cart/common/addSimpleProductsToCart/fetcher.ts: -------------------------------------------------------------------------------- 1 | import fetchWithGraphQl from '../../../fetchWithGraphQl'; 2 | import addSimpleProductsToCartQuery from './query'; 3 | 4 | /** 5 | * Magento 2: common addSimpleProductsToCart fetcher 6 | */ 7 | async function fetcher({ 8 | cartId, 9 | token = null, 10 | sku, 11 | quantity = 1, 12 | }): Promise { 13 | const query = addSimpleProductsToCartQuery({ 14 | cartId, 15 | sku, 16 | quantity, 17 | }); 18 | const rawData = await fetchWithGraphQl(query, token); 19 | return rawData; 20 | } 21 | 22 | export default fetcher; 23 | -------------------------------------------------------------------------------- /src/cart/common/addSimpleProductsToCart/index.ts: -------------------------------------------------------------------------------- 1 | import addSimpleProductsToCartQuery from './query'; 2 | import fetchAddSimpleProductsToCart from './fetcher'; 3 | import normalizeAddSimpleProductsToCart from './normalizer'; 4 | 5 | /** 6 | * Usage example (in handler): 7 | * 8 | * import { 9 | * fetchAddSimpleProductsToCart, 10 | * normalizeAddSimpleProductsToCart, 11 | * } from 'api/magento/cart/common/addSimpleProductsToCart'; 12 | * ... 13 | * ... 14 | * const rawData = await fetchAddSimpleProductsToCart({ 15 | * cartId, 16 | * token, 17 | * sku, 18 | * quantity 19 | * }); 20 | * const data = normalizeAddSimpleProductsToCart(rawData); 21 | * ... 22 | * ... 23 | */ 24 | export { 25 | addSimpleProductsToCartQuery, 26 | fetchAddSimpleProductsToCart, 27 | normalizeAddSimpleProductsToCart, 28 | }; 29 | -------------------------------------------------------------------------------- /src/cart/common/addSimpleProductsToCart/normalizer.ts: -------------------------------------------------------------------------------- 1 | import get from 'lodash/get'; 2 | import CartItem from 'react-storefront-connector/CartItem'; 3 | import cartItemsNormalizer from '../../../helpers/cartItemsNormalizer'; 4 | import CartResponse from '../../../types/CartResponse'; 5 | 6 | /** 7 | * Magento 2: common addSimpleProductsToCart normalizer 8 | */ 9 | function normalizer(rawData: any): CartResponse { 10 | const rawCartData = get(rawData, 'data.addSimpleProductsToCart.cart', null); 11 | const items: CartItem[] = cartItemsNormalizer(get(rawCartData, 'items', [])); 12 | return { 13 | cart: { 14 | items, 15 | }, 16 | }; 17 | } 18 | 19 | export default normalizer; 20 | -------------------------------------------------------------------------------- /src/cart/common/addSimpleProductsToCart/query.ts: -------------------------------------------------------------------------------- 1 | import cartItemProductDefaultSchema from '../../schemas/cartItemProductDefaultSchema'; 2 | import GraphQlQuery from '../../../types/GraphQlQuery'; 3 | 4 | /** 5 | * Magento 2: common addSimpleProductsToCart Graph QL query 6 | */ 7 | const query = ({ 8 | cartId, 9 | sku, 10 | quantity = 1, 11 | cartItemProductSchema = cartItemProductDefaultSchema, 12 | }): GraphQlQuery => ({ 13 | query: ` 14 | mutation { 15 | addSimpleProductsToCart( 16 | input: { 17 | cart_id: "${cartId}" 18 | cart_items: [ 19 | { 20 | data: { 21 | quantity: ${quantity} 22 | sku: "${sku}" 23 | } 24 | } 25 | ] 26 | } 27 | ) { 28 | cart { 29 | items { 30 | id 31 | quantity 32 | product { 33 | ${cartItemProductSchema} 34 | } 35 | } 36 | } 37 | } 38 | } 39 | `, 40 | }); 41 | 42 | export default query; 43 | -------------------------------------------------------------------------------- /src/cart/common/mergeCarts/fetcher.ts: -------------------------------------------------------------------------------- 1 | import fetchWithGraphQl from '../../../fetchWithGraphQl'; 2 | import mergeCartsQuery from './query'; 3 | 4 | /** 5 | * Magento 2: common mergeCarts fetcher 6 | */ 7 | async function fetcher(token, sourceCartId, destinationCartId): Promise { 8 | const query = mergeCartsQuery({ sourceCartId, destinationCartId }); 9 | const rawData = await fetchWithGraphQl(query, token); 10 | return rawData; 11 | } 12 | 13 | export default fetcher; 14 | -------------------------------------------------------------------------------- /src/cart/common/mergeCarts/index.ts: -------------------------------------------------------------------------------- 1 | import mergeCartsQuery from './query'; 2 | import fetchMergeCarts from './fetcher'; 3 | import normalizeMergeCarts from './normalizer'; 4 | 5 | /** 6 | * Usage example (in handler): 7 | * 8 | * import { fetchMergeCarts, normalizeMergeCarts } from 'api/magento/cart/common/mergeCarts'; 9 | * ... 10 | * ... 11 | * const rawData = await fetchMergeCarts(token, sourceCartId, destinationCartId); 12 | * const data = normalizeMergeCarts(rawData); 13 | * ... 14 | * ... 15 | */ 16 | export { 17 | mergeCartsQuery, 18 | fetchMergeCarts, 19 | normalizeMergeCarts, 20 | }; 21 | -------------------------------------------------------------------------------- /src/cart/common/mergeCarts/normalizer.ts: -------------------------------------------------------------------------------- 1 | import get from 'lodash/get'; 2 | import CartItem from 'react-storefront-connector/CartItem'; 3 | import cartItemsNormalizer from '../../../helpers/cartItemsNormalizer'; 4 | import CartResponse from '../../../types/CartResponse'; 5 | 6 | /** 7 | * Magento 2: common mergeCarts normalizer 8 | */ 9 | function normalizer(rawData: any): CartResponse { 10 | const rawCartData = get(rawData, 'data.mergeCarts', null); 11 | const customerCartId = get(rawCartData, 'id', null); 12 | const items: CartItem[] = cartItemsNormalizer(get(rawCartData, 'items', [])); 13 | return { 14 | customerCartId, 15 | cart: { 16 | items, 17 | }, 18 | }; 19 | } 20 | 21 | export default normalizer; 22 | -------------------------------------------------------------------------------- /src/cart/common/mergeCarts/query.ts: -------------------------------------------------------------------------------- 1 | import cartItemProductDefaultSchema from '../../schemas/cartItemProductDefaultSchema'; 2 | import GraphQlQuery from '../../../types/GraphQlQuery'; 3 | 4 | /** 5 | * Magento 2: common mergeCarts Graph QL query 6 | */ 7 | const query = ({ 8 | sourceCartId, 9 | destinationCartId, 10 | cartItemProductSchema = cartItemProductDefaultSchema, 11 | }): GraphQlQuery => ({ 12 | query: ` 13 | mutation { 14 | mergeCarts(source_cart_id: "${sourceCartId}", destination_cart_id: "${destinationCartId}") { 15 | id, 16 | items { 17 | id 18 | quantity 19 | product { 20 | ${cartItemProductSchema} 21 | } 22 | } 23 | } 24 | } 25 | `, 26 | }); 27 | 28 | export default query; 29 | -------------------------------------------------------------------------------- /src/cart/common/updateCart/fetcher.ts: -------------------------------------------------------------------------------- 1 | import fetchWithGraphQl from '../../../fetchWithGraphQl'; 2 | import updateCartQuery from './query'; 3 | 4 | /** 5 | * Magento 2: common updateCart fetcher 6 | */ 7 | async function fetcher({ 8 | cartId, 9 | token = null, 10 | cartItemId, 11 | quantity = 0, 12 | }): Promise { 13 | const query = updateCartQuery({ 14 | cartId, 15 | cartItemId, 16 | quantity, 17 | }); 18 | const rawData = await fetchWithGraphQl(query, token); 19 | return rawData; 20 | } 21 | 22 | export default fetcher; 23 | -------------------------------------------------------------------------------- /src/cart/common/updateCart/index.ts: -------------------------------------------------------------------------------- 1 | import fetchUpdateCart from './fetcher'; 2 | import updateCartQuery from './query'; 3 | import normalizeUpdateCart from './normalizer'; 4 | 5 | /** 6 | * Usage example (in handler): 7 | * 8 | * import { 9 | * fetchUpdateCart, 10 | * normalizeUpdateCart, 11 | * } from 'api/magento/cart/common/updateCart'; 12 | * ... 13 | * ... 14 | * const rawData = await fetchAddSimpleProductsToCart({ 15 | * cartId, 16 | * token, 17 | * cartItemId, 18 | * quantity 19 | * }); 20 | * const data = normalizeUpdateCart(rawData); 21 | * ... 22 | * ... 23 | */ 24 | export { 25 | updateCartQuery, 26 | fetchUpdateCart, 27 | normalizeUpdateCart, 28 | }; 29 | -------------------------------------------------------------------------------- /src/cart/common/updateCart/normalizer.ts: -------------------------------------------------------------------------------- 1 | import get from 'lodash/get'; 2 | import CartItem from 'react-storefront-connector/CartItem'; 3 | import cartItemsNormalizer from '../../../helpers/cartItemsNormalizer'; 4 | import CartResponse from '../../../types/CartResponse'; 5 | 6 | /** 7 | * Magento 2: common updateCart normalizer 8 | */ 9 | function normalizer(rawData: any): CartResponse { 10 | const rawCartData = get(rawData, 'data.updateCartItems.cart', null); 11 | const items: CartItem[] = cartItemsNormalizer(get(rawCartData, 'items', [])); 12 | return { 13 | cart: { 14 | items, 15 | }, 16 | }; 17 | } 18 | 19 | export default normalizer; 20 | -------------------------------------------------------------------------------- /src/cart/common/updateCart/query.ts: -------------------------------------------------------------------------------- 1 | import cartItemProductDefaultSchema from '../../schemas/cartItemProductDefaultSchema'; 2 | import GraphQlQuery from '../../../types/GraphQlQuery'; 3 | 4 | /** 5 | * Magento 2: common updateCart Graph QL query 6 | */ 7 | const query = ({ 8 | cartId, 9 | cartItemId, 10 | quantity = 0, 11 | cartItemProductSchema = cartItemProductDefaultSchema, 12 | }): GraphQlQuery => ({ 13 | query: ` 14 | mutation { 15 | updateCartItems( 16 | input: { 17 | cart_id: "${cartId}" 18 | cart_items: [ 19 | { 20 | cart_item_id: ${cartItemId} 21 | quantity: ${quantity} 22 | } 23 | ] 24 | } 25 | ) { 26 | cart { 27 | items { 28 | id 29 | quantity 30 | product { 31 | ${cartItemProductSchema} 32 | } 33 | } 34 | } 35 | } 36 | } 37 | `, 38 | }); 39 | 40 | export default query; 41 | -------------------------------------------------------------------------------- /src/cart/customer/cart/fetcher.ts: -------------------------------------------------------------------------------- 1 | import fetchWithGraphQl from '../../../fetchWithGraphQl'; 2 | import customerCartQuery from './query'; 3 | 4 | /** 5 | * Magento 2: customer cart fetcher 6 | */ 7 | async function fetcher(token): Promise { 8 | const rawData = await fetchWithGraphQl(customerCartQuery({}), token); 9 | return rawData; 10 | } 11 | 12 | export default fetcher; 13 | -------------------------------------------------------------------------------- /src/cart/customer/cart/index.ts: -------------------------------------------------------------------------------- 1 | import cartQuery from './query'; 2 | import fetchCart from './fetcher'; 3 | import normalizeCart from './normalizer'; 4 | 5 | /** 6 | * Usage example (in handler): 7 | * 8 | * import { fetchCart, normalizeCart } from 'api/magento/cart/customer/cart'; 9 | * ... 10 | * ... 11 | * const rawData = await fetchCart(token); 12 | * const data = normalizeCart(rawData); 13 | * ... 14 | * ... 15 | */ 16 | export { 17 | cartQuery, 18 | fetchCart, 19 | normalizeCart, 20 | }; 21 | -------------------------------------------------------------------------------- /src/cart/customer/cart/normalizer.ts: -------------------------------------------------------------------------------- 1 | import get from 'lodash/get'; 2 | import CartItem from 'react-storefront-connector/CartItem'; 3 | import cartItemsNormalizer from '../../../helpers/cartItemsNormalizer'; 4 | import CartResponse from '../../../types/CartResponse'; 5 | 6 | /** 7 | * Magento 2: customer cart normalizer 8 | */ 9 | function normalizer(rawData: any): CartResponse { 10 | const rawCartData = get(rawData, 'data.customerCart', null); 11 | const customerCartId = get(rawCartData, 'id', null); 12 | const items: CartItem[] = cartItemsNormalizer(get(rawCartData, 'items', [])); 13 | return { 14 | customerCartId, 15 | cart: { 16 | items, 17 | }, 18 | }; 19 | } 20 | 21 | export default normalizer; 22 | -------------------------------------------------------------------------------- /src/cart/customer/cart/query.ts: -------------------------------------------------------------------------------- 1 | import cartItemProductDefaultSchema from '../../schemas/cartItemProductDefaultSchema'; 2 | import GraphQlQuery from '../../../types/GraphQlQuery'; 3 | 4 | /** 5 | * Magento 2: customer cart Graph QL query 6 | */ 7 | const query = ({ 8 | cartItemProductSchema = cartItemProductDefaultSchema, 9 | }): GraphQlQuery => ({ 10 | query: `{ 11 | customerCart { 12 | id 13 | items { 14 | id 15 | quantity 16 | product { 17 | ${cartItemProductSchema} 18 | } 19 | } 20 | } 21 | }`, 22 | }); 23 | 24 | export default query; 25 | -------------------------------------------------------------------------------- /src/cart/customer/createCustomer/fetcher.ts: -------------------------------------------------------------------------------- 1 | import fetchWithGraphQl from '../../../fetchWithGraphQl'; 2 | import createCustomerQuery from './query'; 3 | 4 | /** 5 | * Magento 2: createCustomer fetcher 6 | */ 7 | async function fetcher(queryData): Promise { 8 | const rawData = await fetchWithGraphQl(createCustomerQuery(queryData)); 9 | return rawData; 10 | } 11 | 12 | export default fetcher; 13 | -------------------------------------------------------------------------------- /src/cart/customer/createCustomer/index.ts: -------------------------------------------------------------------------------- 1 | import createCustomerQuery from './query'; 2 | import fetchCreateCustomer from './fetcher'; 3 | import normalizeCreateCustomer from './normalizer'; 4 | 5 | /** 6 | * Usage example (in handler): 7 | * 8 | * import { 9 | * fetchCreateCustomer, normalizeCreateCustomer, 10 | * } from 'api/magento/cart/customer/createCustomer'; 11 | * ... 12 | * ... 13 | * const rawData = await fetchCreateCustomer(queryData); 14 | * const data = normalizeCreateCustomer(rawData); 15 | * ... 16 | * ... 17 | */ 18 | export { 19 | createCustomerQuery, 20 | fetchCreateCustomer, 21 | normalizeCreateCustomer, 22 | }; 23 | -------------------------------------------------------------------------------- /src/cart/customer/createCustomer/normalizer.ts: -------------------------------------------------------------------------------- 1 | import get from 'lodash/get'; 2 | 3 | /** 4 | * Magento 2: createCustomer normalizer 5 | */ 6 | function normalizer(rawData): any { 7 | const rawCustomerData = get(rawData, 'data.createCustomer.customer', null); 8 | const firstName = get(rawCustomerData, 'firstname', null); 9 | const lastName = get(rawCustomerData, 'lastname', null); 10 | const isSubscribed = get(rawCustomerData, 'is_subscribed', null); 11 | return { 12 | account: { 13 | firstName, 14 | lastName, 15 | isSubscribed, 16 | }, 17 | }; 18 | } 19 | 20 | export default normalizer; 21 | -------------------------------------------------------------------------------- /src/cart/customer/createCustomer/query.ts: -------------------------------------------------------------------------------- 1 | import GraphQlQuery from '../../../types/GraphQlQuery'; 2 | 3 | /** 4 | * Magento 2: createCustomer Graph QL query 5 | */ 6 | const query = ({ 7 | firstName, 8 | lastName, 9 | email, 10 | password, 11 | isSubscribed = false, 12 | }): GraphQlQuery => ({ 13 | query: ` 14 | mutation { 15 | createCustomer( 16 | input: { 17 | firstname: "${firstName}" 18 | lastname: "${lastName}" 19 | email: "${email}" 20 | password: "${password}" 21 | is_subscribed: ${isSubscribed} 22 | } 23 | ) { 24 | customer { 25 | firstname 26 | lastname 27 | email 28 | is_subscribed 29 | } 30 | } 31 | } 32 | `, 33 | }); 34 | 35 | export default query; 36 | -------------------------------------------------------------------------------- /src/cart/customer/generateToken/fetcher.ts: -------------------------------------------------------------------------------- 1 | import fetchWithGraphQl from '../../../fetchWithGraphQl'; 2 | import generateCustomerTokenQuery from './query'; 3 | 4 | /** 5 | * Magento 2: customer generateToken fetcher 6 | */ 7 | async function fetcher(email, password): Promise { 8 | const rawData = await fetchWithGraphQl(generateCustomerTokenQuery(email, password)); 9 | return rawData; 10 | } 11 | 12 | export default fetcher; 13 | -------------------------------------------------------------------------------- /src/cart/customer/generateToken/index.ts: -------------------------------------------------------------------------------- 1 | import generateTokenQuery from './query'; 2 | import fetchGenerateToken from './fetcher'; 3 | import normalizeGenerateToken from './normalizer'; 4 | 5 | /** 6 | * Usage example (in handler): 7 | * 8 | * import { 9 | * fetchGenerateToken, normalizeGenerateToken, 10 | * } from 'api/magento/cart/customer/generateToken'; 11 | * ... 12 | * ... 13 | * const rawData = await fetchGenerateToken(email, password); 14 | * const data = normalizeGenerateToken(rawData); 15 | * ... 16 | * ... 17 | */ 18 | export { 19 | generateTokenQuery, 20 | fetchGenerateToken, 21 | normalizeGenerateToken, 22 | }; 23 | -------------------------------------------------------------------------------- /src/cart/customer/generateToken/normalizer.ts: -------------------------------------------------------------------------------- 1 | import get from 'lodash/get'; 2 | 3 | interface TokenData { 4 | token: string, 5 | } 6 | 7 | /** 8 | * Magento 2: customer generateToken normalizer 9 | */ 10 | function normalizer(rawData: any): TokenData { 11 | const token = get(rawData, 'data.generateCustomerToken.token', null); 12 | return { 13 | token, 14 | }; 15 | } 16 | 17 | export default normalizer; 18 | -------------------------------------------------------------------------------- /src/cart/customer/generateToken/query.ts: -------------------------------------------------------------------------------- 1 | import GraphQlQuery from '../../../types/GraphQlQuery'; 2 | 3 | /** 4 | * Magento 2: customer generateToken Graph QL query 5 | */ 6 | const query = (email, password): GraphQlQuery => ({ 7 | query: ` 8 | mutation { 9 | generateCustomerToken( 10 | email: "${email}", 11 | password: "${password}" 12 | ) { 13 | token 14 | } 15 | }`, 16 | }); 17 | 18 | export default query; 19 | -------------------------------------------------------------------------------- /src/cart/customer/revokeToken/fetcher.ts: -------------------------------------------------------------------------------- 1 | import fetchWithGraphQl from '../../../fetchWithGraphQl'; 2 | import revokeCustomerTokenQuery from './query'; 3 | 4 | /** 5 | * Magento 2: customer revokeToken fetcher 6 | */ 7 | async function fetcher(token): Promise { 8 | const rawData = await fetchWithGraphQl(revokeCustomerTokenQuery(), token); 9 | return rawData; 10 | } 11 | 12 | export default fetcher; 13 | -------------------------------------------------------------------------------- /src/cart/customer/revokeToken/index.ts: -------------------------------------------------------------------------------- 1 | import revokeTokenQuery from './query'; 2 | import fetchRevokeToken from './fetcher'; 3 | import normalizeRevokeToken from './normalizer'; 4 | 5 | /** 6 | * Usage example (in handler): 7 | * 8 | * import { fetchRevokeToken, normalizeRevokeToken } from 'api/magento/cart/customer/revokeToken'; 9 | * ... 10 | * ... 11 | * const rawData = await fetchRevokeToken(token); 12 | * const data = normalizeRevokeToken(rawData); 13 | * ... 14 | * ... 15 | */ 16 | export { 17 | revokeTokenQuery, 18 | fetchRevokeToken, 19 | normalizeRevokeToken, 20 | }; 21 | -------------------------------------------------------------------------------- /src/cart/customer/revokeToken/normalizer.ts: -------------------------------------------------------------------------------- 1 | import get from 'lodash/get'; 2 | 3 | /** 4 | * Magento 2: customer revokeToken normalizer 5 | */ 6 | function normalizer(rawData): any { 7 | const result = get(rawData, 'data.revokeCustomerToken.result', false); 8 | return { 9 | result, 10 | }; 11 | } 12 | 13 | export default normalizer; 14 | -------------------------------------------------------------------------------- /src/cart/customer/revokeToken/query.ts: -------------------------------------------------------------------------------- 1 | import GraphQlQuery from '../../../types/GraphQlQuery'; 2 | 3 | /** 4 | * Magento 2: customer revokeToken Graph QL query 5 | */ 6 | const query = (): GraphQlQuery => ({ 7 | query: ` 8 | mutation { 9 | revokeCustomerToken { 10 | result 11 | } 12 | } 13 | `, 14 | }); 15 | 16 | export default query; 17 | -------------------------------------------------------------------------------- /src/cart/guest/cart/fetcher.ts: -------------------------------------------------------------------------------- 1 | import fetchWithGraphQl from '../../../fetchWithGraphQl'; 2 | import cartQuery from './query'; 3 | 4 | /** 5 | * Magento 2: guest cart fetcher 6 | */ 7 | async function fetcher(cartId): Promise { 8 | const rawData = await fetchWithGraphQl(cartQuery({ cartId })); 9 | return rawData; 10 | } 11 | 12 | export default fetcher; 13 | -------------------------------------------------------------------------------- /src/cart/guest/cart/index.ts: -------------------------------------------------------------------------------- 1 | import cartQuery from './query'; 2 | import fetchCart from './fetcher'; 3 | import normalizeCart from './normalizer'; 4 | 5 | /** 6 | * Usage example (in handler): 7 | * 8 | * import { fetchCart, normalizeCart } from 'api/magento/cart/guest/cart'; 9 | * ... 10 | * ... 11 | * const rawData = await fetchCart(cartId); 12 | * const data = normalizeCart(rawData); 13 | * ... 14 | * ... 15 | */ 16 | export { 17 | cartQuery, 18 | fetchCart, 19 | normalizeCart, 20 | }; 21 | -------------------------------------------------------------------------------- /src/cart/guest/cart/normalizer.ts: -------------------------------------------------------------------------------- 1 | import get from 'lodash/get'; 2 | import CartItem from 'react-storefront-connector/CartItem'; 3 | import cartItemsNormalizer from '../../../helpers/cartItemsNormalizer'; 4 | import CartResponse from '../../../types/CartResponse'; 5 | 6 | /** 7 | * Magento 2: guest cart normalizer 8 | */ 9 | function normalizer(rawData: any): CartResponse { 10 | const rawCartData = get(rawData, 'data.cart', null); 11 | const guestCartId = get(rawCartData, 'id', null); 12 | const items: CartItem[] = cartItemsNormalizer(get(rawCartData, 'items', [])); 13 | return { 14 | guestCartId, 15 | cart: { 16 | items, 17 | }, 18 | }; 19 | } 20 | 21 | export default normalizer; 22 | -------------------------------------------------------------------------------- /src/cart/guest/cart/query.ts: -------------------------------------------------------------------------------- 1 | import cartItemProductDefaultSchema from '../../schemas/cartItemProductDefaultSchema'; 2 | import GraphQlQuery from '../../../types/GraphQlQuery'; 3 | 4 | /** 5 | * Magento 2: guest cart Graph QL query 6 | */ 7 | const query = ({ 8 | cartId, 9 | cartItemProductSchema = cartItemProductDefaultSchema, 10 | }): GraphQlQuery => ({ 11 | query: ` 12 | { 13 | cart ( 14 | cart_id: "${cartId}" 15 | ) { 16 | id 17 | items { 18 | id 19 | quantity 20 | product { 21 | ${cartItemProductSchema} 22 | } 23 | } 24 | } 25 | } 26 | `, 27 | }); 28 | 29 | export default query; 30 | -------------------------------------------------------------------------------- /src/cart/guest/createEmptyCart/fetcher.ts: -------------------------------------------------------------------------------- 1 | import fetchWithGraphQl from '../../../fetchWithGraphQl'; 2 | import createEmptyCartQuery from './query'; 3 | 4 | /** 5 | * Magento 2: guest createEmptyCart fetcher 6 | */ 7 | async function fetcher(): Promise { 8 | const rawData = await fetchWithGraphQl(createEmptyCartQuery()); 9 | return rawData; 10 | } 11 | 12 | export default fetcher; 13 | -------------------------------------------------------------------------------- /src/cart/guest/createEmptyCart/index.ts: -------------------------------------------------------------------------------- 1 | import createEmptyCartQuery from './query'; 2 | import fetchCreateEmptyCart from './fetcher'; 3 | import normalizeCreateEmptyCart from './normalizer'; 4 | 5 | /** 6 | * Usage example (in handler): 7 | * 8 | * import { 9 | * fetchCreateEmptyCart, normalizeCreateEmptyCart 10 | * } from 'api/magento/cart/guest/createEmptyCart'; 11 | * ... 12 | * ... 13 | * const rawData = await fetchCreateEmptyCart(); 14 | * const cartId = normalizeCreateEmptyCart(rawData); 15 | * ... 16 | * ... 17 | */ 18 | export { 19 | createEmptyCartQuery, 20 | fetchCreateEmptyCart, 21 | normalizeCreateEmptyCart, 22 | }; 23 | -------------------------------------------------------------------------------- /src/cart/guest/createEmptyCart/normalizer.ts: -------------------------------------------------------------------------------- 1 | import get from 'lodash/get'; 2 | 3 | /** 4 | * Magento 2: guest createEmptyCart normalizer 5 | */ 6 | function normalizer(rawData): string { 7 | const guestCartId = get(rawData, 'data.createEmptyCart', null); 8 | return guestCartId; 9 | } 10 | 11 | export default normalizer; 12 | -------------------------------------------------------------------------------- /src/cart/guest/createEmptyCart/query.ts: -------------------------------------------------------------------------------- 1 | import GraphQlQuery from '../../../types/GraphQlQuery'; 2 | 3 | /** 4 | * Magento 2: guest createEmptyCart Graph QL query 5 | */ 6 | const query = (): GraphQlQuery => ({ 7 | query: ` 8 | mutation { 9 | createEmptyCart 10 | } 11 | `, 12 | }); 13 | 14 | export default query; 15 | -------------------------------------------------------------------------------- /src/cart/index.ts: -------------------------------------------------------------------------------- 1 | import cart from './cart'; 2 | 3 | export default cart; 4 | -------------------------------------------------------------------------------- /src/cart/removeCartItem.ts: -------------------------------------------------------------------------------- 1 | import CartResponse from '../types/CartResponse'; 2 | import updateCart from './updateCartItem'; 3 | 4 | /** 5 | * removeCartItem handler 6 | */ 7 | export default function removeCartItem(item, req, res): Promise { 8 | return updateCart(item, 0, req, res); 9 | } 10 | -------------------------------------------------------------------------------- /src/cart/schemas/cartItemProductDefaultSchema.ts: -------------------------------------------------------------------------------- 1 | const cartItemProductDefaultSchema = ` 2 | name 3 | sku 4 | url_key 5 | url_suffix 6 | thumbnail { url } 7 | price_range { 8 | maximum_price { 9 | regular_price { value currency } 10 | final_price { value currency } 11 | discount { amount_off percent_off } 12 | } 13 | } 14 | `; 15 | 16 | export default cartItemProductDefaultSchema; 17 | -------------------------------------------------------------------------------- /src/cart/updateCartItem.ts: -------------------------------------------------------------------------------- 1 | import get from 'lodash/get'; 2 | import { getCookieValue } from '../helpers/nodeCookieHelpers'; 3 | import { fetchUpdateCart, normalizeUpdateCart } from './common/updateCart'; 4 | import { COOKIES } from '../constants'; 5 | import getError from '../helpers/getError'; 6 | import CartResponse from '../types/CartResponse'; 7 | 8 | /** 9 | * Magento 2: common -> addToCart 10 | */ 11 | async function fetchUC({ 12 | cartId, token = null, cartItemId, quantity = 0, 13 | }): Promise { 14 | const rawData = await fetchUpdateCart({ 15 | cartId, 16 | cartItemId, 17 | token, 18 | quantity, 19 | }); 20 | 21 | const error = getError(rawData); 22 | if (error) { 23 | return { 24 | error, 25 | }; 26 | } 27 | 28 | const data = normalizeUpdateCart(rawData); 29 | return { 30 | ...data, 31 | }; 32 | } 33 | 34 | /** 35 | * updateCart handler 36 | */ 37 | export default async function updateCartItem(item, quantity, req, _res): Promise { 38 | const cartId = getCookieValue(req, COOKIES.M2_GUEST_CART_ID) 39 | || getCookieValue(req, COOKIES.M2_CUSTOMER_CART_ID); 40 | const token = getCookieValue(req, COOKIES.M2_CUSTOMER_TOKEN); 41 | const cartItemId = Number(get(item, 'id')); 42 | 43 | const responseData = await fetchUC({ 44 | token, cartId, cartItemId, quantity, 45 | }); 46 | 47 | if (responseData.error) { 48 | throw new Error(responseData.error); 49 | } else { 50 | return responseData; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/cms/blocks/fetcher.ts: -------------------------------------------------------------------------------- 1 | import fetchWithGraphQl from '../../fetchWithGraphQl'; 2 | import cmsBlocksQuery from './query'; 3 | 4 | /** 5 | * Magento 2: cms blocks fetcher 6 | */ 7 | async function fetcher({ identifiers }): Promise { 8 | const query = cmsBlocksQuery({ identifiers }); 9 | const rawData = await fetchWithGraphQl(query); 10 | return rawData; 11 | } 12 | 13 | export default fetcher; 14 | -------------------------------------------------------------------------------- /src/cms/blocks/index.ts: -------------------------------------------------------------------------------- 1 | import cmsBlocksQuery from './query'; 2 | import fetchCmsBlocks from './fetcher'; 3 | import normalizeCmsBlocks from './normalizer'; 4 | 5 | /** 6 | * Usage example (in handler): 7 | * 8 | * import { fetchCmsBlocks, normalizeCmsBlocks } from 'api/magento/cms/blocks'; 9 | * ... 10 | * ... 11 | * const rawData = await fetchCmsBlocks({ identifiers }); 12 | * const data = normalizeCmsBlocks(rawData); 13 | * ... 14 | * ... 15 | */ 16 | export { 17 | cmsBlocksQuery, 18 | fetchCmsBlocks, 19 | normalizeCmsBlocks, 20 | }; 21 | -------------------------------------------------------------------------------- /src/cms/blocks/normalizer.ts: -------------------------------------------------------------------------------- 1 | import get from 'lodash/get'; 2 | import cheerio from 'cheerio'; 3 | import { host } from '../../config'; 4 | 5 | /** 6 | * Magento 2: cmsBlocks normalizer 7 | */ 8 | function normalizer(rawData): any { 9 | const items = get(rawData, 'data.cmsBlocks.items', []); 10 | return { 11 | items: items.map((item) => { 12 | const content = get(item, 'content', ''); 13 | const $content = cheerio.load(content); 14 | $content('a[href]').each((i, elem) => { 15 | const $link = $content(elem); 16 | const rawHref = $link.attr('href'); 17 | let newHref = rawHref.replace(host, ''); 18 | 19 | // @TODO: find a better way to create RSF router links 20 | if ( 21 | newHref.startsWith('/women') 22 | || newHref.startsWith('/men') 23 | || newHref.startsWith('/collections') 24 | || newHref.startsWith('/gear') 25 | || newHref.startsWith('/training') 26 | || newHref.startsWith('/sale') 27 | ) { 28 | newHref = `/s${newHref}`; 29 | } else { 30 | newHref = `/p/${newHref}`; 31 | } 32 | 33 | $link.attr('href', newHref); 34 | }); 35 | return { 36 | ...item, 37 | content: $content.html(), 38 | }; 39 | }), 40 | }; 41 | } 42 | 43 | export default normalizer; 44 | -------------------------------------------------------------------------------- /src/cms/blocks/query.ts: -------------------------------------------------------------------------------- 1 | import GraphQlQuery from '../../types/GraphQlQuery'; 2 | 3 | /** 4 | * Magento 2: cmsBlocks Graph QL query 5 | */ 6 | const query = ({ identifiers }): GraphQlQuery => ({ 7 | query: ` 8 | { 9 | cmsBlocks(identifiers: "${identifiers}") { 10 | items { 11 | identifier 12 | title 13 | content 14 | } 15 | } 16 | } 17 | `, 18 | }); 19 | 20 | export default query; 21 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | export const host = process.env.M2_CONFIG_HOST; 2 | export const graphQlHost = `${host}/graphql`; 3 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/prefer-default-export 2 | export const COOKIES = { 3 | M2_GUEST_CART_ID: 'M2_guestCartId', 4 | M2_CUSTOMER_CART_ID: 'M2_customerCartId', 5 | M2_CUSTOMER_TOKEN: 'M2_token', 6 | }; 7 | -------------------------------------------------------------------------------- /src/fetchWithGraphQl.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'isomorphic-unfetch'; 2 | import { graphQlHost } from './config'; 3 | import GraphQlQuery from './types/GraphQlQuery'; 4 | 5 | function fetchWithGraphQl(query: GraphQlQuery, token: string | null = null): Promise { 6 | const authHeaders = token ? { 7 | Authorization: `Bearer ${token}`, 8 | } : {}; 9 | return fetch(graphQlHost, { 10 | method: 'POST', 11 | headers: { 12 | ...authHeaders, 13 | Accept: 'application/json', 14 | 'Content-Type': 'application/json', 15 | Store: 'default', 16 | }, 17 | body: JSON.stringify(query), 18 | }).then((res) => res.json()); 19 | } 20 | 21 | export default fetchWithGraphQl; 22 | -------------------------------------------------------------------------------- /src/helpers/cartItemsNormalizer.ts: -------------------------------------------------------------------------------- 1 | import get from 'lodash/get'; 2 | import CartItem from 'react-storefront-connector/CartItem'; 3 | 4 | function cartItemsNormalizer(items: any[]): CartItem[] { 5 | return items.map((item: any, index: number): CartItem => { 6 | const product = get(item, 'product', {}); 7 | return { 8 | ...item, 9 | id: get(item, 'id', `cart-item-${index}`), 10 | quantity: get(item, 'quantity', 1), 11 | name: get(product, 'name', ''), 12 | url: `/p/${get(product, 'url_key', '')}${get(product, 'url_suffix', '')}`, 13 | thumbnail: { 14 | src: get(product, 'thumbnail.url', ''), 15 | type: 'image', 16 | }, 17 | price: get(product, 'price_range.maximum_price.final_price.value', 0), 18 | priceText: `$${get(product, 'price_range.maximum_price.final_price.value', 0)}`, 19 | }; 20 | }); 21 | } 22 | 23 | export default cartItemsNormalizer; 24 | -------------------------------------------------------------------------------- /src/helpers/getError.ts: -------------------------------------------------------------------------------- 1 | import get from 'lodash/get'; 2 | 3 | /** 4 | * A helper that returns an error if present in Graph QL raw data response 5 | * @param {Object} rawData - Graph QL query raw response data 6 | * @returns {String} error 7 | */ 8 | function getError(rawData: any): string { 9 | const rawErrors = get(rawData, 'errors', []); 10 | const error = rawErrors 11 | .map((err) => get(err, 'message', '')) 12 | .filter(Boolean) 13 | .join('\n'); 14 | return error; 15 | } 16 | 17 | export default getError; 18 | -------------------------------------------------------------------------------- /src/helpers/nodeCookieHelpers.ts: -------------------------------------------------------------------------------- 1 | import get from 'lodash/get'; 2 | 3 | function convertCookieStringToObject(cookiesStr: string): Object | null { 4 | return ( 5 | (cookiesStr || '').split(';').reduce((cookiesObjectAcc, cookieStr) => { 6 | let [name, value] = cookieStr.split('='); 7 | name = (name || '').trim(); // add trimming just in case 8 | value = (value || '').trim(); 9 | return { 10 | ...cookiesObjectAcc, 11 | [name]: value, 12 | }; 13 | }, {}) || null 14 | ); // return `null` instead of empty string 15 | } 16 | 17 | /** 18 | * Gets cookie value in NodeJS handler 19 | * 20 | * @param {Request} req The request object 21 | * @param {String} cookieName Cookie name 22 | * @return {String} Cookie value (null if missing) 23 | */ 24 | export function getCookieValue(req: Request | any, cookieName: string): string | null { 25 | const cookie = get(req, 'headers.cookie'); 26 | const cookies = convertCookieStringToObject(cookie); 27 | return get(cookies, cookieName, null); 28 | } 29 | 30 | interface SetCookieOptions { 31 | /** 32 | * The Max-Age cookie option 33 | */ 34 | maxAge?: number 35 | 36 | /** 37 | * The Expires cookie option 38 | */ 39 | expires?: Date 40 | } 41 | 42 | /** 43 | * Prepares a Set-Cookie value string 44 | * @param name The name of the cookie 45 | * @param value The value to set 46 | * @param options Additional options 47 | * @returns {string} 48 | */ 49 | export function prepareSetCookie(name: string, value: string, options: SetCookieOptions = {}): string { 50 | const cookieValue = [`${name}=${value}`]; 51 | 52 | if (options.maxAge) { 53 | cookieValue.push(`Max-Age=${options.maxAge}`); 54 | } 55 | 56 | if (options.expires && !options.maxAge) { 57 | cookieValue.push(`Expires=${options.expires.toUTCString()}`); 58 | } 59 | 60 | return cookieValue.join('; '); 61 | } 62 | 63 | /** 64 | * Prepares a Set-Cookie value string for cookie needs to be removed (sets negative expiry time) 65 | * 66 | * @param {string} cookieName Cookie name 67 | * @returns {string} 68 | */ 69 | export function prepareKillCookie(cookieName: string): string { 70 | return prepareSetCookie(cookieName, 'EXP', { expires: new Date(0) }); // 1 Jan 1970 71 | } 72 | 73 | /** 74 | * Sets multiple cookies into response object 75 | * 76 | * @param {Response} res Response object 77 | * @param {string[]} cookies Array of Set-Cookie response header values 78 | * @returns {string} 79 | */ 80 | export function setCookies(res: any, cookies: string[]): void { 81 | res.setHeader('Set-Cookie', cookies); 82 | } 83 | -------------------------------------------------------------------------------- /src/home/home.ts: -------------------------------------------------------------------------------- 1 | import Result from 'react-storefront-connector/Result'; 2 | import withAppData from '../app/withAppData'; 3 | 4 | export default async function home(req/* , res */): Promise> { 5 | const data = await withAppData(req, () => Promise.resolve({ 6 | title: 'Home', 7 | slots: { 8 | heading: 'Home', 9 | description: 'Welcome!', 10 | }, 11 | breadcrumbs: [ 12 | { 13 | text: 'Home', 14 | href: '/', 15 | }, 16 | ], 17 | })); 18 | return { ...data }; 19 | } 20 | -------------------------------------------------------------------------------- /src/home/index.ts: -------------------------------------------------------------------------------- 1 | import home from './home'; 2 | 3 | export default home; 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as home } from './home'; 2 | export { default as addToCart } from './cart/addToCart'; 3 | export { default as updateCartItem } from './cart/updateCartItem'; 4 | export { default as removeCartItem } from './cart/removeCartItem'; 5 | export { default as cart } from './cart'; 6 | export { default as fetchWithGraphQl } from './fetchWithGraphQl'; 7 | export { default as product } from './product'; 8 | export { default as productSuggestions } from './product/productSuggestions'; 9 | export { default as routes } from './routes'; 10 | export { default as session } from './session'; 11 | export { default as signIn } from './session/signIn'; 12 | export { default as signOut } from './session/signOut'; 13 | export { default as signUp } from './session/signUp'; 14 | export { default as subcategory } from './subcategory'; 15 | export { default as search } from './search'; 16 | export { default as searchSuggestions } from './search/searchSuggestions'; 17 | 18 | export { default } from './types/Connector'; 19 | -------------------------------------------------------------------------------- /src/menu/fetcher.ts: -------------------------------------------------------------------------------- 1 | import fetchWithGraphQl from '../fetchWithGraphQl'; 2 | import menuQuery from './query'; 3 | 4 | /** 5 | * Magento 2: menu fetcher 6 | */ 7 | async function fetcher({ 8 | numberOfLevels = 2, 9 | menuItemFields = [ 10 | 'name', 11 | 'url_path', 12 | 'url_suffix', 13 | 'position', 14 | ], 15 | }): Promise { 16 | const query = menuQuery({ numberOfLevels, menuItemFields }); 17 | const rawData = await fetchWithGraphQl(query); 18 | return rawData; 19 | } 20 | 21 | export default fetcher; 22 | -------------------------------------------------------------------------------- /src/menu/index.ts: -------------------------------------------------------------------------------- 1 | import menuQuery from './query'; 2 | import fetchMenu from './fetcher'; 3 | import normalizeMenu from './normalizer'; 4 | 5 | /** 6 | * Usage example (in handler): 7 | * 8 | * import { fetchMenu, normalizeMenu } from 'api/magento/menu'; 9 | * ... 10 | * ... 11 | * const rawData = await fetchMenu({}); 12 | * const data = normalizeMenu(rawData); 13 | * ... 14 | * ... 15 | */ 16 | export { 17 | menuQuery, 18 | fetchMenu, 19 | normalizeMenu, 20 | }; 21 | -------------------------------------------------------------------------------- /src/menu/normalizer.ts: -------------------------------------------------------------------------------- 1 | import get from 'lodash/get'; 2 | import isEmpty from 'lodash/isEmpty'; 3 | import orderBy from 'lodash/orderBy'; 4 | 5 | function normalizeItems(children): any[] { 6 | const childrenSorted = orderBy(children, ['position']); 7 | return childrenSorted.map((item) => ({ 8 | name: get(item, 'name', ''), 9 | url: `/${get(item, 'url_path', '')}${get(item, 'url_suffix') || ''}`, 10 | items: !isEmpty(item.children) ? normalizeItems(item.children) : [], 11 | })); 12 | } 13 | 14 | /** 15 | * Magento 2: menu normalizer 16 | */ 17 | function normalizer(rawData): any[] { 18 | const rawMenu = get(rawData, 'data.categoryList', []) 19 | .filter((menu) => get(menu, 'level') === 1)[0]; 20 | const children = get(rawMenu, 'children', []); 21 | const menu = normalizeItems(children); 22 | return menu; 23 | } 24 | 25 | export default normalizer; 26 | -------------------------------------------------------------------------------- /src/menu/query.ts: -------------------------------------------------------------------------------- 1 | import GraphQlQuery from '../types/GraphQlQuery'; 2 | 3 | function getFullInnerSchema(menuItemFields, numberOfLevels): string { 4 | let fullInnerSchema = ''; 5 | const menuItemFieldsJoined = menuItemFields 6 | .filter((itemField) => itemField !== 'children') 7 | .join('\n'); 8 | const childrenEmptyObject = 'children {}'; 9 | for (let i = 0; i < numberOfLevels; i++) { 10 | if (!fullInnerSchema.includes(childrenEmptyObject)) { 11 | fullInnerSchema += `${menuItemFieldsJoined}\n${childrenEmptyObject}`; 12 | } else { 13 | fullInnerSchema = fullInnerSchema 14 | .replace(childrenEmptyObject, `children { ${menuItemFieldsJoined}\n${childrenEmptyObject} }`); 15 | } 16 | } 17 | fullInnerSchema = fullInnerSchema.replace(childrenEmptyObject, ''); 18 | return fullInnerSchema; 19 | } 20 | 21 | /** 22 | * Magento 2: menu Graph QL query 23 | */ 24 | const query = ({ 25 | numberOfLevels = 2, 26 | menuItemFields = [ 27 | 'name', 28 | 'url_path', 29 | 'url_suffix', 30 | 'position', 31 | ], 32 | }): GraphQlQuery => { 33 | const fullInnerSchema = getFullInnerSchema(menuItemFields, numberOfLevels); 34 | return { 35 | query: ` 36 | { 37 | categoryList(filters: {}) { 38 | level 39 | children { 40 | ${fullInnerSchema} 41 | } 42 | } 43 | } 44 | `, 45 | }; 46 | }; 47 | 48 | export default query; 49 | -------------------------------------------------------------------------------- /src/product/fetcher.ts: -------------------------------------------------------------------------------- 1 | import fetchWithGraphQl from '../fetchWithGraphQl'; 2 | import productQuery from './query'; 3 | 4 | /** 5 | * Magento 2: product fetcher 6 | */ 7 | async function fetcher(productId): Promise { 8 | const pid = productId.replace('.html', ''); 9 | const rawData = await fetchWithGraphQl(productQuery(pid)); 10 | return rawData; 11 | } 12 | 13 | export default fetcher; 14 | -------------------------------------------------------------------------------- /src/product/index.ts: -------------------------------------------------------------------------------- 1 | import productQuery from './query'; 2 | import fetchProduct from './fetcher'; 3 | import normalizeProduct from './normalizer'; 4 | import product from './product'; 5 | 6 | /** 7 | * Usage example (in handler): 8 | * 9 | * import { fetchProduct, normalizeProduct } from 'api/magento/product'; 10 | * ... 11 | * ... 12 | * const rawData = await fetchProduct(productId); 13 | * const data = normalizeProduct(rawData); 14 | * ... 15 | * ... 16 | */ 17 | export default product; 18 | export { 19 | productQuery, 20 | fetchProduct, 21 | normalizeProduct, 22 | }; 23 | -------------------------------------------------------------------------------- /src/product/normalizer.ts: -------------------------------------------------------------------------------- 1 | import get from 'lodash/get'; 2 | import isEmpty from 'lodash/isEmpty'; 3 | import Media from 'react-storefront-connector/Media'; 4 | import { Product } from '../types/ProductPageData'; 5 | 6 | function getThumbnail(rawProduct): Media { 7 | return { 8 | src: get(rawProduct, 'image.url', ''), 9 | alt: get(rawProduct, 'image.label', 'thumbnail'), 10 | type: 'image', 11 | }; 12 | } 13 | 14 | function getMedia(rawProduct) { 15 | const thumbnail = getThumbnail(rawProduct); 16 | const thumbnails = get(rawProduct, 'media_gallery', []).map((item, i) => ({ 17 | src: get(item, 'url', ''), 18 | alt: get(item, 'label', `thumbnail ${i}`), 19 | })); 20 | return { 21 | thumbnail, 22 | thumbnails, 23 | full: thumbnails, 24 | }; 25 | } 26 | 27 | function getSizes(rawProduct) { 28 | const configurableOptions = get(rawProduct, 'configurable_options', []); 29 | const sizeOption = configurableOptions.find((obj) => obj.attribute_code === 'size'); 30 | return get(sizeOption, 'values', []) 31 | .map((opt) => ({ 32 | text: get(opt, 'label'), 33 | id: get(opt, 'swatch_data.value'), 34 | })); 35 | } 36 | 37 | function getColors(rawProduct) { 38 | const configurableOptions = get(rawProduct, 'configurable_options', []); 39 | const colorOption = configurableOptions.find((obj) => obj.attribute_code === 'color'); 40 | const rawProductVariants = get(rawProduct, 'variants', []); 41 | return get(colorOption, 'values', []) 42 | .map((opt) => { 43 | const color = get(opt, 'label'); 44 | const variant = rawProductVariants.find((_variant) => get(_variant, 'product.sku', '').includes(`-${color}`)); 45 | const thumbnails = get(variant, 'product.media_gallery', []).map((mediaGalleryObj) => ({ 46 | src: get(mediaGalleryObj, 'url'), 47 | alt: color, 48 | })); 49 | return { 50 | id: color, 51 | text: color, 52 | css: get(opt, 'swatch_data.value'), 53 | image: { 54 | alt: color, 55 | // @TODO: add support of RGB color code in ProductOptionSelector component: 56 | src: `https://via.placeholder.com/48x48/${get(opt, 'swatch_data.value').replace('#', '')}?text=%20`, 57 | }, 58 | media: { 59 | thumbnails, 60 | thumbnail: thumbnails[0], 61 | full: thumbnails, // @HARDCODE for now 62 | }, 63 | }; 64 | }); 65 | } 66 | 67 | function getSpecs(rawProduct, rawCustomAttributes) { 68 | const specsAttributes = [ 69 | { 70 | name: 'Style', 71 | key: 'style_general', 72 | }, 73 | { 74 | name: 'Material', 75 | key: 'material', 76 | }, 77 | { 78 | name: 'Pattern', 79 | key: 'pattern', 80 | }, 81 | { 82 | name: 'Climate', 83 | key: 'climate', 84 | }, 85 | { 86 | name: 'Activity', 87 | key: 'activity', 88 | }, 89 | { 90 | name: 'Gender', 91 | key: 'gender', 92 | }, 93 | { 94 | name: 'Category', 95 | key: 'category_gear', 96 | }, 97 | ]; 98 | return specsAttributes 99 | .map((specsAttribute) => { 100 | const spec = specsAttribute.key; 101 | const specName = specsAttribute.name; 102 | if (!rawCustomAttributes) return null; 103 | 104 | const attr = rawCustomAttributes.find((_attr) => get(_attr, 'attribute_code') === spec); 105 | const rawValue = get(rawProduct, spec) || ''; 106 | const value = rawValue 107 | .split(',') 108 | .map((x) => { 109 | const opts = get(attr, 'attribute_options', []); 110 | const opt = opts.find((_attr) => _attr.value === x.trim()); 111 | return get(opt, 'label', ''); 112 | }) 113 | .join(', '); 114 | if (!value) { 115 | return null; 116 | } 117 | return { 118 | name: specName, 119 | value, 120 | }; 121 | }) 122 | .filter(Boolean); 123 | } 124 | 125 | function specsToHtml(specs) { 126 | return specs 127 | .filter((spec) => spec.name && spec.value) 128 | .map((spec) => `${spec.name}: ${spec.value}`) 129 | .join('
'); 130 | } 131 | 132 | /** 133 | * Magento 2: product normalizer 134 | */ 135 | function normalizer(rawData, productId): Product | null { 136 | const rawProduct = get(rawData, 'data.products.items[0]'); 137 | const rawCustomAttributes = get(rawData, 'data.customAttributeMetadata.items', []); 138 | 139 | if (!rawProduct) { 140 | return null; 141 | } 142 | 143 | const colors = getColors(rawProduct); 144 | const sizes = getSizes(rawProduct); 145 | const isConfigurableProduct = !isEmpty(get(rawProduct, 'configurable_options')); 146 | const price = get(rawProduct, 'price_range.maximum_price.final_price.value'); 147 | 148 | return { 149 | isConfigurableProduct, 150 | id: productId, 151 | reviewsKey: get(rawProduct, 'id'), // product ID in Magento database (used for other queries) 152 | sku: get(rawProduct, 'sku'), 153 | url: `/p/${productId}.html`, 154 | name: get(rawProduct, 'name'), 155 | description: get(rawProduct, 'description.html'), 156 | price, 157 | priceText: `$${price.toFixed(2)}`, 158 | sizes, 159 | colors, 160 | thumbnail: getThumbnail(rawProduct), 161 | media: getMedia(rawProduct), 162 | specs: specsToHtml(getSpecs(rawProduct, rawCustomAttributes)), 163 | }; 164 | } 165 | 166 | export default normalizer; 167 | -------------------------------------------------------------------------------- /src/product/product.ts: -------------------------------------------------------------------------------- 1 | import Result from 'react-storefront-connector/Result'; 2 | import fetch from './fetcher'; 3 | import normalize from './normalizer'; 4 | import withAppData from '../app/withAppData'; 5 | import ProductPageData from '../types/ProductPageData'; 6 | 7 | export default async function product({ id/* , color, size */ }, req/* , res */): Promise> { 8 | return withAppData(req, async () => { 9 | id = id.replace('.html', ''); // eslint-disable-line no-param-reassign 10 | const normalizedProduct = normalize(await fetch(id), id); 11 | 12 | return { 13 | title: `Product ${id}`, 14 | product: normalizedProduct, 15 | breadcrumbs: [ 16 | { 17 | text: 'Home', 18 | href: '/', 19 | }, 20 | ], 21 | }; 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /src/product/productSlots.ts: -------------------------------------------------------------------------------- 1 | import CmsSlots from 'react-storefront-connector/CmsSlots'; 2 | 3 | /** 4 | * @TODO: implement 5 | */ 6 | function productSlots(/* params, req, res */): CmsSlots { 7 | return {}; 8 | } 9 | 10 | export default productSlots; 11 | -------------------------------------------------------------------------------- /src/product/productSuggestions.ts: -------------------------------------------------------------------------------- 1 | import { Product } from '../types/ProductPageData'; 2 | 3 | /** 4 | * @TODO: implement 5 | */ 6 | function productSuggestions(/* params, req, res */): Promise { 7 | return Promise.resolve([]); 8 | } 9 | 10 | export default productSuggestions; 11 | -------------------------------------------------------------------------------- /src/product/query.ts: -------------------------------------------------------------------------------- 1 | import GraphQlQuery from '../types/GraphQlQuery'; 2 | 3 | const specsAttributes = [ 4 | // 'style_general', 5 | // 'material', 6 | // 'pattern', 7 | // 'climate', 8 | // 'category_gear', 9 | // 'gender', 10 | // 'activity', 11 | ]; 12 | 13 | const customAttributeMetadata = ` 14 | customAttributeMetadata( 15 | attributes: [ 16 | ${specsAttributes.reduce((str, spec) => `${str}{ 17 | attribute_code: "${spec}", 18 | entity_type: "4" 19 | }\n`, '')} 20 | ] 21 | ) { 22 | items { 23 | attribute_code 24 | attribute_type 25 | attribute_options { 26 | value 27 | label 28 | } 29 | } 30 | } 31 | `; 32 | 33 | /** 34 | * Magento 2: product query 35 | */ 36 | const query = (urlKey): GraphQlQuery => ({ 37 | query: ` 38 | { 39 | ${customAttributeMetadata} 40 | products(filter: { 41 | url_key: { 42 | eq: "${urlKey}" 43 | } 44 | }) { 45 | items { 46 | id, 47 | image { 48 | label 49 | url 50 | }, 51 | name, 52 | description { 53 | html 54 | }, 55 | 56 | # specs 57 | ${specsAttributes.reduce((str, val) => `${str}${val}\n`, '')} 58 | 59 | sku, 60 | media_gallery { 61 | label 62 | url 63 | }, 64 | only_x_left_in_stock, 65 | stock_status, 66 | price_range { 67 | maximum_price { 68 | final_price { 69 | currency, 70 | value 71 | }, 72 | discount{ 73 | amount_off 74 | }, 75 | fixed_product_taxes{ 76 | amount { 77 | currency, 78 | value 79 | }, 80 | label 81 | } 82 | regular_price{ 83 | currency, 84 | value 85 | } 86 | } 87 | } 88 | ... on ConfigurableProduct { 89 | configurable_options { 90 | attribute_code, 91 | label, 92 | id, 93 | values { 94 | label 95 | swatch_data{ 96 | value, 97 | ...on ImageSwatchData { 98 | thumbnail 99 | } 100 | }, 101 | } 102 | }, 103 | variants { 104 | attributes { 105 | code, 106 | label 107 | }, 108 | product { 109 | name, 110 | url_key, 111 | url_suffix, 112 | sku, 113 | swatch_image, 114 | media_gallery { 115 | url 116 | }, 117 | } 118 | } 119 | } 120 | } 121 | } 122 | } 123 | `, 124 | }); 125 | 126 | export default query; 127 | -------------------------------------------------------------------------------- /src/product/reviews/fetchProductReviews.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'isomorphic-unfetch'; 2 | import { host } from '../../config'; 3 | 4 | function fetchProductReviews(productId): Promise { 5 | return fetch(`${host}/review/product/listAjax/id/${productId}`) 6 | .then((res) => res.text()); 7 | } 8 | 9 | export default fetchProductReviews; 10 | -------------------------------------------------------------------------------- /src/product/reviews/index.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/prefer-default-export 2 | export { default as fetchProductReviews } from './fetchProductReviews'; 3 | -------------------------------------------------------------------------------- /src/routes.ts: -------------------------------------------------------------------------------- 1 | export default []; 2 | -------------------------------------------------------------------------------- /src/search/fetcher.ts: -------------------------------------------------------------------------------- 1 | import { fetchSubcategory } from '../subcategory'; 2 | 3 | /** 4 | * Magento 2: search fetcher 5 | * > uses subcategory fetcher underneath 6 | */ 7 | async function fetcher({ 8 | pageSize = 16, 9 | currentPage = 1, 10 | filter = '', 11 | sort = '', 12 | search = '', 13 | }): Promise { 14 | const rawData = await fetchSubcategory({ 15 | pageSize, 16 | currentPage, 17 | filter, 18 | sort, 19 | search, 20 | }); 21 | return rawData; 22 | } 23 | 24 | export default fetcher; 25 | -------------------------------------------------------------------------------- /src/search/index.ts: -------------------------------------------------------------------------------- 1 | import fetchSearch from './fetcher'; 2 | import normalizeSearch from './normalizer'; 3 | import search from './search'; 4 | 5 | /** 6 | * Usage example (in handler): 7 | * 8 | * import { fetchSearch, normalizeSearch } from 'api/magento/search'; 9 | * ... 10 | * ... 11 | * const rawData = await fetchSearch({ search }); 12 | * const data = normalizeSearch(rawData); 13 | * ... 14 | * ... 15 | */ 16 | export default search; 17 | export { 18 | fetchSearch, 19 | normalizeSearch, 20 | }; 21 | -------------------------------------------------------------------------------- /src/search/normalizer.ts: -------------------------------------------------------------------------------- 1 | import { normalizeSubcategory } from '../subcategory'; 2 | 3 | /** 4 | * Magento 2: search normalizer 5 | * > uses subcategory normalizer underneath 6 | */ 7 | function normalizer(rawData) { 8 | return normalizeSubcategory(rawData); 9 | } 10 | 11 | export default normalizer; 12 | -------------------------------------------------------------------------------- /src/search/search.ts: -------------------------------------------------------------------------------- 1 | import subcategory from '../subcategory/subcategory'; 2 | 3 | export default function search(params, req, res) { 4 | return subcategory(params, req, res); 5 | } 6 | -------------------------------------------------------------------------------- /src/search/searchSuggestions.ts: -------------------------------------------------------------------------------- 1 | import SearchSuggestions from 'react-storefront-connector/SearchSuggestions'; 2 | 3 | /** 4 | * @TODO: implement 5 | */ 6 | function searchSuggestions(/* params, req, res */): Promise { 7 | return Promise.resolve({ 8 | text: '', 9 | groups: [], 10 | }); 11 | } 12 | 13 | export default searchSuggestions; 14 | -------------------------------------------------------------------------------- /src/session/common/mergeCarts.ts: -------------------------------------------------------------------------------- 1 | import { fetchMergeCarts, normalizeMergeCarts } from '../../cart/common/mergeCarts'; 2 | import CartResponse from '../../types/CartResponse'; 3 | 4 | /** 5 | * Magento 2: common -> mergeCarts 6 | */ 7 | export default async function mergeCarts({ token, sourceCartId, destinationCartId }): Promise { 8 | const rawData = await fetchMergeCarts(token, sourceCartId, destinationCartId); 9 | const data = normalizeMergeCarts(rawData); 10 | return { 11 | ...data, 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /src/session/customer/cart.ts: -------------------------------------------------------------------------------- 1 | import { fetchCart, normalizeCart } from '../../cart/customer/cart'; 2 | import getError from '../../helpers/getError'; 3 | import CartResponse from '../../types/CartResponse'; 4 | 5 | /** 6 | * Magento 2: customer -> cart 7 | */ 8 | export default async function cart(token): Promise { 9 | const rawData = await fetchCart(token); 10 | 11 | const error = getError(rawData); 12 | if (error) { 13 | return { 14 | error, 15 | }; 16 | } 17 | 18 | const data = normalizeCart(rawData); 19 | return { 20 | ...data, 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /src/session/customer/createCustomer.ts: -------------------------------------------------------------------------------- 1 | import SignUpData from 'react-storefront-connector/SignUpData'; 2 | import { fetchCreateCustomer, normalizeCreateCustomer } from '../../cart/customer/createCustomer'; 3 | import getError from '../../helpers/getError'; 4 | 5 | /** 6 | * Magento 2: customer -> createCustomer 7 | */ 8 | export default async function createCustomer({ 9 | firstName, 10 | lastName, 11 | email, 12 | password, 13 | }: SignUpData): Promise { 14 | const rawData = await fetchCreateCustomer({ 15 | firstName, 16 | lastName, 17 | email, 18 | password, 19 | }); 20 | 21 | const error = getError(rawData); 22 | if (error) { 23 | return { 24 | error, 25 | }; 26 | } 27 | 28 | const data = normalizeCreateCustomer(rawData); 29 | return { 30 | ...data, 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /src/session/customer/generateToken.ts: -------------------------------------------------------------------------------- 1 | import { fetchGenerateToken, normalizeGenerateToken } from '../../cart/customer/generateToken'; 2 | 3 | /** 4 | * Magento 2: customer -> generateToken 5 | */ 6 | export default async function generateToken({ email, password }): Promise { 7 | const rawData = await fetchGenerateToken(email, password); 8 | const data = normalizeGenerateToken(rawData); 9 | return { 10 | ...data, 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /src/session/customer/revokeToken.ts: -------------------------------------------------------------------------------- 1 | import { fetchRevokeToken, normalizeRevokeToken } from '../../cart/customer/revokeToken'; 2 | 3 | /** 4 | * Magento 2: customer -> revokeToken 5 | */ 6 | export default async function revokeToken(token): Promise { 7 | const rawData = await fetchRevokeToken(token); 8 | const data = normalizeRevokeToken(rawData); 9 | return { 10 | ...data, 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /src/session/guest/cart.ts: -------------------------------------------------------------------------------- 1 | import { fetchCart, normalizeCart } from '../../cart/guest/cart'; 2 | import getError from '../../helpers/getError'; 3 | import CartResponse from '../../types/CartResponse'; 4 | 5 | /** 6 | * Magento 2: guest -> cart 7 | */ 8 | export default async function cart(cartId): Promise { 9 | const rawData = await fetchCart(cartId); 10 | 11 | const error = getError(rawData); 12 | if (error) { 13 | return { 14 | error, 15 | }; 16 | } 17 | 18 | const data = normalizeCart(rawData); 19 | return { 20 | ...data, 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /src/session/guest/obtainSession.ts: -------------------------------------------------------------------------------- 1 | import { 2 | fetchCreateEmptyCart, 3 | normalizeCreateEmptyCart, 4 | } from '../../cart/guest/createEmptyCart'; 5 | import getError from '../../helpers/getError'; 6 | import CartResponse from '../../types/CartResponse'; 7 | 8 | /** 9 | * Magento 2: guest -> obtainSession 10 | */ 11 | export default async function obtainSession(): Promise { 12 | const rawData = await fetchCreateEmptyCart(); 13 | 14 | const error = getError(rawData); 15 | if (error) { 16 | return { 17 | error, 18 | }; 19 | } 20 | 21 | const guestCartId = normalizeCreateEmptyCart(rawData); 22 | return { 23 | guestCartId, 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /src/session/index.ts: -------------------------------------------------------------------------------- 1 | import session from './session'; 2 | 3 | export default session; 4 | -------------------------------------------------------------------------------- /src/session/session.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-len */ 2 | import { 3 | getCookieValue, 4 | setCookies, 5 | prepareSetCookie, 6 | prepareKillCookie, 7 | } from '../helpers/nodeCookieHelpers'; 8 | import obtainSession from './guest/obtainSession'; 9 | import guestCart from './guest/cart'; 10 | import customerCart from './customer/cart'; 11 | import { COOKIES } from '../constants'; 12 | 13 | export default async function session(req, res): Promise { 14 | const cookiesToSet = []; 15 | 16 | // ### 1 - LOGGED IN SESSION 17 | const tokenCookieValue = getCookieValue(req, COOKIES.M2_CUSTOMER_TOKEN); 18 | if (tokenCookieValue) { 19 | const customerCartData = await customerCart(tokenCookieValue); 20 | if (customerCartData.error) { 21 | return { 22 | error: customerCartData.error, 23 | }; 24 | } 25 | const { cart, customerCartId } = customerCartData; 26 | cookiesToSet.push( 27 | prepareSetCookie(COOKIES.M2_CUSTOMER_TOKEN, tokenCookieValue, { maxAge: 3600 * 24 * 30 }), 28 | ); // renew customer token cookie for 30 more days 29 | cookiesToSet.push( 30 | prepareSetCookie(COOKIES.M2_CUSTOMER_CART_ID, customerCartId, { maxAge: 3600 * 24 * 30 }), 31 | ); // set/renew customer cart ID cookie for 30 days 32 | cookiesToSet.push(prepareKillCookie(COOKIES.M2_GUEST_CART_ID)); // kill guest cart ID cookie (prevents possible cart merges issues) 33 | setCookies(res, cookiesToSet); 34 | return { 35 | signedIn: true, 36 | cart, 37 | }; 38 | } 39 | 40 | // ### 2 - GUEST SESSION 41 | // # 2.1 - Obtain returning guest session 42 | cookiesToSet.push(prepareKillCookie(COOKIES.M2_CUSTOMER_CART_ID)); // kill customer cart ID cookie (prevents possible cart merges issues) 43 | const guestCartIdCookieValue = getCookieValue(req, COOKIES.M2_GUEST_CART_ID); 44 | if (guestCartIdCookieValue) { 45 | const guestCartData = await guestCart(guestCartIdCookieValue); 46 | if (guestCartData.error) { 47 | setCookies(res, cookiesToSet); 48 | return { 49 | error: guestCartData.error, 50 | }; 51 | } 52 | const { cart } = guestCartData; 53 | cookiesToSet.push( 54 | prepareSetCookie(COOKIES.M2_GUEST_CART_ID, guestCartIdCookieValue, { maxAge: 3600 * 24 * 7 }), 55 | ); // renew cookie for 7 more days 56 | setCookies(res, cookiesToSet); 57 | return { 58 | signedIn: false, 59 | cart, 60 | }; 61 | } 62 | 63 | // # 2.2 - Obtain new guest session 64 | const obtainSessionData = await obtainSession(); 65 | if (obtainSessionData.error) { 66 | setCookies(res, cookiesToSet); 67 | return { 68 | error: obtainSessionData.error, 69 | }; 70 | } 71 | const { guestCartId } = obtainSessionData; 72 | cookiesToSet.push( 73 | prepareSetCookie(COOKIES.M2_GUEST_CART_ID, guestCartId, { maxAge: 3600 * 24 * 7 }), 74 | ); // set guest cart id cookie for 7 days 75 | setCookies(res, cookiesToSet); 76 | return { 77 | signedIn: false, 78 | cart: { 79 | items: [], 80 | }, 81 | }; 82 | } 83 | -------------------------------------------------------------------------------- /src/session/signIn.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-len */ 2 | import get from 'lodash/get'; 3 | import Session from 'react-storefront-connector/Session'; 4 | import fetchGenerateToken from './customer/generateToken'; 5 | import fetchCustomerCart from './customer/cart'; 6 | import fetchMergeCarts from './common/mergeCarts'; 7 | import { 8 | getCookieValue, 9 | prepareKillCookie, 10 | prepareSetCookie, 11 | setCookies, 12 | } from '../helpers/nodeCookieHelpers'; 13 | import { COOKIES } from '../constants'; 14 | 15 | export default async function signIn( 16 | email: string, 17 | password: string, 18 | req: Request, 19 | res: Response, 20 | ): Promise { 21 | const { token } = await fetchGenerateToken({ email, password }); 22 | 23 | if (!token) { 24 | // unsuccessful login 25 | throw new Error( 26 | 'The account sign-in was incorrect or your account is disabled temporarily. Please wait and try again later.', 27 | ); 28 | } 29 | 30 | // fetch customer cart data 31 | const customerCartData = await fetchCustomerCart(token); 32 | let { cart, customerCartId } = customerCartData; 33 | 34 | // if guest has cart items -> merge it with logged in customer cart 35 | const guestCartIdCookieValue = getCookieValue(req, COOKIES.M2_GUEST_CART_ID); 36 | if (guestCartIdCookieValue) { 37 | const sourceCartId = guestCartIdCookieValue; 38 | const destinationCartId = customerCartId; 39 | const mergeCartsData = await fetchMergeCarts({ token, sourceCartId, destinationCartId }); 40 | cart = get(mergeCartsData, 'cart', cart); 41 | customerCartId = get(mergeCartsData, 'customerCartId', customerCartId); 42 | } 43 | 44 | // @TODO: also customer account data like firstName, lastName can be fetched here 45 | // Docs: https://devdocs.magento.com/guides/v2.3/graphql/queries/customer.html 46 | // ... 47 | 48 | const cookiesToSet = []; 49 | cookiesToSet.push(prepareSetCookie(COOKIES.M2_CUSTOMER_TOKEN, token, { maxAge: 3600 * 24 * 30 })); // set customer token cookie for 30 days 50 | cookiesToSet.push( 51 | prepareSetCookie(COOKIES.M2_CUSTOMER_CART_ID, customerCartId, { maxAge: 3600 * 24 * 30 }), 52 | ); // set customer cart ID cookie for 30 days 53 | cookiesToSet.push(prepareKillCookie(COOKIES.M2_GUEST_CART_ID)); // kill guest cart ID cookie (prevents possible cart merges issues) 54 | setCookies(res, cookiesToSet); 55 | 56 | return { 57 | cart, 58 | signedIn: true, 59 | }; 60 | } 61 | -------------------------------------------------------------------------------- /src/session/signOut.ts: -------------------------------------------------------------------------------- 1 | import Session from 'react-storefront-connector/Session'; 2 | import revokeToken from './customer/revokeToken'; 3 | import obtainSession from './guest/obtainSession'; 4 | import { COOKIES } from '../constants'; 5 | import { 6 | getCookieValue, 7 | prepareKillCookie, 8 | prepareSetCookie, 9 | setCookies, 10 | } from '../helpers/nodeCookieHelpers'; 11 | 12 | export default async function signOut(req, res): Promise { 13 | const token = getCookieValue(req, COOKIES.M2_CUSTOMER_TOKEN); 14 | 15 | if (!token) { 16 | throw new Error(`${COOKIES.M2_CUSTOMER_TOKEN} cookie doesn't exist`); 17 | } 18 | 19 | const { result } = await revokeToken(token); 20 | 21 | if (!result) { 22 | throw new Error('An error occured during customer token revoke'); 23 | } 24 | 25 | // obtain new session after successful sign out 26 | const { guestCartId } = await obtainSession(); 27 | const cookiesToSet = []; 28 | cookiesToSet.push( 29 | prepareSetCookie(COOKIES.M2_GUEST_CART_ID, guestCartId, { maxAge: 3600 * 24 * 7 }), 30 | ); // set guest cart id cookie for 7 days 31 | cookiesToSet.push(prepareKillCookie(COOKIES.M2_CUSTOMER_TOKEN)); // kill customer token cookie 32 | cookiesToSet.push(prepareKillCookie(COOKIES.M2_CUSTOMER_CART_ID)); // kill customer cart id cookie 33 | setCookies(res, cookiesToSet); 34 | 35 | return { 36 | signedIn: false, 37 | cart: { items: [] }, 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /src/session/signUp.ts: -------------------------------------------------------------------------------- 1 | import Session from 'react-storefront-connector/Session'; 2 | import SignUpData from 'react-storefront-connector/SignUpData'; 3 | import createCustomer from './customer/createCustomer'; 4 | import signIn from './signIn'; 5 | 6 | export default async function signUp( 7 | data: SignUpData, 8 | req: Request, 9 | res: Response, 10 | ): Promise { 11 | const signUpData = await createCustomer(data); 12 | 13 | if (signUpData.error) { 14 | throw new Error(signUpData.error); 15 | } 16 | 17 | return signIn(data.email, data.password, req, res); 18 | } 19 | -------------------------------------------------------------------------------- /src/subcategory/fetcher.ts: -------------------------------------------------------------------------------- 1 | import fetchWithGraphQl from '../fetchWithGraphQl'; 2 | import subcategoryQuery from './query'; 3 | 4 | /** 5 | * Magento 2: subcategory fetcher 6 | */ 7 | async function fetcher({ 8 | categoryId = null, 9 | pageSize = 16, 10 | currentPage = 1, 11 | filter = '', 12 | sort = '', 13 | search = '', 14 | }): Promise { 15 | const query = subcategoryQuery({ 16 | categoryId, 17 | pageSize, 18 | currentPage, 19 | filter, 20 | sort, 21 | search, 22 | }); 23 | const rawData = await fetchWithGraphQl(query); 24 | return rawData; 25 | } 26 | 27 | export default fetcher; 28 | -------------------------------------------------------------------------------- /src/subcategory/id/fetcher.ts: -------------------------------------------------------------------------------- 1 | import fetchWithGraphQl from '../../fetchWithGraphQl'; 2 | import subcategoryIdQuery from './query'; 3 | 4 | /** 5 | * Magento 2: subcategory id fetcher 6 | */ 7 | async function fetcher({ urlKey }): Promise { 8 | const query = subcategoryIdQuery({ urlKey }); 9 | const rawData = await fetchWithGraphQl(query); 10 | return rawData; 11 | } 12 | 13 | export default fetcher; 14 | -------------------------------------------------------------------------------- /src/subcategory/id/index.ts: -------------------------------------------------------------------------------- 1 | import subcategoryIdQuery from './query'; 2 | import fetchSubcategoryId from './fetcher'; 3 | import normalizeSubcategoryId from './normalizer'; 4 | 5 | /** 6 | * Usage example (in handler): 7 | * 8 | * import { fetchSubcategoryId, normalizeSubcategoryId } from 'api/magento/subcategory/id'; 9 | * ... 10 | * ... 11 | * const rawData = await fetchSubcategoryId({ urlKey }); 12 | * const id = normalizeSubcategoryId(rawData); 13 | * ... 14 | * ... 15 | */ 16 | export { 17 | subcategoryIdQuery, 18 | fetchSubcategoryId, 19 | normalizeSubcategoryId, 20 | }; 21 | -------------------------------------------------------------------------------- /src/subcategory/id/normalizer.ts: -------------------------------------------------------------------------------- 1 | import get from 'lodash/get'; 2 | 3 | /** 4 | * Magento 2: subcategory id normalizer 5 | */ 6 | function normalizer(rawData): any { 7 | const id = get(rawData, 'data.categoryList[0]id', null); 8 | const name = get(rawData, 'data.categoryList[0]name', null); 9 | return { 10 | id, 11 | name, 12 | }; 13 | } 14 | 15 | export default normalizer; 16 | -------------------------------------------------------------------------------- /src/subcategory/id/query.ts: -------------------------------------------------------------------------------- 1 | import GraphQlQuery from '../../types/GraphQlQuery'; 2 | 3 | /** 4 | * Magento 2: subcategory id Graph QL query 5 | */ 6 | const query = ({ urlKey }): GraphQlQuery => ({ 7 | query: ` 8 | { 9 | categoryList( 10 | filters: { 11 | url_key: { 12 | eq: "${urlKey}" 13 | } 14 | } 15 | ) { 16 | id 17 | name 18 | } 19 | } 20 | `, 21 | }); 22 | 23 | export default query; 24 | -------------------------------------------------------------------------------- /src/subcategory/index.ts: -------------------------------------------------------------------------------- 1 | import subcategoryQuery from './query'; 2 | import fetchSubcategory from './fetcher'; 3 | import normalizeSubcategory from './normalizer'; 4 | import subcategory from './subcategory'; 5 | 6 | /** 7 | * Usage example (in handler): 8 | * 9 | * import { fetchSubcategory, normalizeSubcategory } from 'api/magento/subcategory'; 10 | * ... 11 | * ... 12 | * const rawData = await fetchSubcategory({ categoryId }); 13 | * const { id, name } = normalizeSubcategory(rawData); 14 | * ... 15 | * ... 16 | */ 17 | export default subcategory; 18 | export { 19 | subcategoryQuery, 20 | fetchSubcategory, 21 | normalizeSubcategory, 22 | }; 23 | -------------------------------------------------------------------------------- /src/subcategory/normalizer.ts: -------------------------------------------------------------------------------- 1 | import get from 'lodash/get'; 2 | import groupBy from 'lodash/groupBy'; 3 | import keyBy from 'lodash/keyBy'; 4 | 5 | function getSizes(rawProduct) { 6 | const rawConfigurableOptions = get(rawProduct, 'configurable_options', []); 7 | const sizes = get(keyBy(rawConfigurableOptions, 'attribute_code'), 'size.values', []); 8 | return sizes.map((size) => { 9 | const text = get(size, 'label', ''); 10 | const id = get(size, 'swatch_data.value', ''); 11 | return { 12 | text, 13 | id, 14 | }; 15 | }); 16 | } 17 | 18 | function getSwatches(rawProduct) { 19 | const rawConfigurableOptions = get(rawProduct, 'configurable_options', []); 20 | const colors = get(keyBy(rawConfigurableOptions, 'attribute_code'), 'color.values', []); 21 | const rawVariants = get(rawProduct, 'variants', []); 22 | const variantsGrouped = groupBy(rawVariants, (item) => { 23 | const attrs = get(item, 'attributes'); 24 | const attrsKeyed = keyBy(attrs, 'code'); 25 | return get(attrsKeyed, 'color.label'); 26 | }); 27 | return colors.map((color) => { 28 | const text = get(color, 'label', ''); 29 | const rgb = get(color, 'swatch_data.value', ''); 30 | const image = get(variantsGrouped, `${text}[0].product.media_gallery[0]url`, ''); 31 | const thumbnail = { 32 | alt: 'thumbnail image', 33 | src: image, 34 | }; 35 | return { 36 | id: rgb, 37 | css: rgb, 38 | text, 39 | image: { 40 | src: `https://via.placeholder.com/48x48/${rgb.replace('#', '')}?text=%20`, 41 | alt: `${text} swatch`, 42 | }, 43 | media: { 44 | thumbnail, 45 | thumbnails: [thumbnail], 46 | }, 47 | }; 48 | }); 49 | } 50 | 51 | function normalizeProductItem(rawItem) { 52 | const thumbnail = get(rawItem, 'thumbnail.url', ''); 53 | return { 54 | id: get(rawItem, 'sku', ''), 55 | url: `/p/${get(rawItem, 'url_key', '')}${get(rawItem, 'url_suffix', '')}`, 56 | name: get(rawItem, 'name', ''), 57 | price: get(rawItem, 'price_range.minimum_price.final_price.value', 0), 58 | basePriceText: `$${get(rawItem, 'price_range.minimum_price.final_price.value', 0)}`, 59 | colors: getSwatches(rawItem), 60 | sizes: getSizes(rawItem), 61 | thumbnail: { 62 | src: thumbnail, 63 | alt: 'thumbnail', 64 | type: 'image', 65 | }, 66 | reviewCount: 0, // @TODO: can we get this data? if no, just drop it 67 | }; 68 | } 69 | 70 | function getSortData(rawSubcategoryData) { 71 | const rawSortFields = get(rawSubcategoryData, 'sort_fields'); 72 | return { 73 | sortDefault: get(rawSortFields, 'default', 'position'), 74 | sortOptions: get(rawSortFields, 'options', []) 75 | .map((option) => ({ 76 | name: get(option, 'label'), 77 | code: get(option, 'value'), 78 | })), 79 | }; 80 | } 81 | 82 | function getFacetsData(rawSubcategoryData) { 83 | const rawFacets = get(rawSubcategoryData, 'aggregations', []) 84 | .filter((facet) => get(facet, 'attribute_code') !== 'category_id'); // skip categories 85 | return { 86 | facets: rawFacets.map((rawFilter) => { 87 | const attr = get(rawFilter, 'attribute_code'); 88 | const isColorFacet = attr === 'color'; 89 | const rawOptions = get(rawFilter, 'options', []); 90 | return { 91 | name: get(rawFilter, 'label'), 92 | ui: 'buttons', 93 | options: rawOptions 94 | .map((option) => ({ 95 | name: get(option, 'label'), 96 | code: `${attr}:${get(option, 'value')}`, 97 | matches: get(option, 'count', 0), 98 | css: isColorFacet ? get(option, 'label', '').toLowerCase() : '', 99 | })), 100 | }; 101 | }), 102 | }; 103 | } 104 | 105 | /** 106 | * Magento 2: subcategory normalizer 107 | */ 108 | function normalizer(rawData): any { 109 | const rawSubcategoryData = get(rawData, 'data.products', {}); 110 | return { 111 | total: get(rawSubcategoryData, 'total_count', 0), 112 | totalPages: get(rawSubcategoryData, 'page_info.total_pages', 1), 113 | currentPage: get(rawSubcategoryData, 'page_info.current_page', 1), 114 | products: get(rawSubcategoryData, 'items', []).map(normalizeProductItem), 115 | ...getSortData(rawSubcategoryData), 116 | ...getFacetsData(rawSubcategoryData), 117 | }; 118 | } 119 | 120 | export default normalizer; 121 | -------------------------------------------------------------------------------- /src/subcategory/query.ts: -------------------------------------------------------------------------------- 1 | import GraphQlQuery from '../types/GraphQlQuery'; 2 | 3 | const productItemSchema = ` 4 | name 5 | sku 6 | url_key 7 | thumbnail { url } 8 | price_range { 9 | minimum_price { 10 | regular_price { value currency } 11 | final_price { value currency } 12 | discount { amount_off percent_off } 13 | } 14 | } 15 | 16 | ... on ConfigurableProduct { 17 | configurable_options { 18 | attribute_code 19 | values { 20 | label 21 | swatch_data { value } 22 | } 23 | } 24 | variants { 25 | attributes { code label } 26 | product { 27 | media_gallery { url disabled } 28 | } 29 | } 30 | } 31 | `; 32 | 33 | const filtersSchema = ` 34 | aggregations { 35 | attribute_code 36 | count 37 | label 38 | options { 39 | count 40 | label 41 | value 42 | } 43 | } 44 | `; 45 | 46 | const sortSchema = ` 47 | sort_fields { 48 | default 49 | options { 50 | label 51 | value 52 | } 53 | } 54 | `; 55 | 56 | const pageInfoSchema = ` 57 | total_count 58 | page_info { 59 | page_size 60 | current_page 61 | total_pages 62 | } 63 | `; 64 | 65 | /** 66 | * Magento 2: subcategory Graph QL query 67 | */ 68 | const query = ({ 69 | categoryId = null, 70 | pageSize = 16, 71 | currentPage = 1, 72 | filter = '', 73 | sort = '', 74 | search = '', 75 | }): GraphQlQuery => { 76 | const searchQuery = search ? `search: "${search}"` : ''; 77 | const sortQuery = sort ? `sort: { ${sort} }` : ''; 78 | const categoryIdQuery = categoryId ? `category_id: { eq: "${categoryId}" }` : ''; 79 | const filterQuery = categoryIdQuery || filter ? ` 80 | filter: { 81 | ${categoryIdQuery} 82 | ${filter} 83 | }` : ''; 84 | return { 85 | query: ` 86 | { 87 | products( 88 | pageSize: ${pageSize} 89 | currentPage: ${currentPage} 90 | ${sortQuery} 91 | ${filterQuery} 92 | ${searchQuery} 93 | ) { 94 | ${filtersSchema} 95 | ${sortSchema} 96 | ${pageInfoSchema} 97 | items { 98 | ${productItemSchema} 99 | } 100 | } 101 | } 102 | `, 103 | }; 104 | }; 105 | 106 | export default query; 107 | -------------------------------------------------------------------------------- /src/subcategory/sub-categories/fetcher.ts: -------------------------------------------------------------------------------- 1 | import fetchWithGraphQl from '../../fetchWithGraphQl'; 2 | import subcategorySubCategoriesQuery from './query'; 3 | 4 | /** 5 | * Magento 2: subcategory sub-categories fetcher 6 | */ 7 | async function fetcher({ urlKey }): Promise { 8 | const query = subcategorySubCategoriesQuery({ urlKey }); 9 | const rawData = await fetchWithGraphQl(query); 10 | return rawData; 11 | } 12 | 13 | export default fetcher; 14 | -------------------------------------------------------------------------------- /src/subcategory/sub-categories/index.ts: -------------------------------------------------------------------------------- 1 | import subcategorySubCategoriesQuery from './query'; 2 | import fetchSubcategorySubCategories from './fetcher'; 3 | import normalizeSubcategorySubCategories from './normalizer'; 4 | 5 | /** 6 | * Usage example (in handler): 7 | * 8 | * import { 9 | * fetchSubcategorySubCategories, 10 | * normalizeSubcategorySubCategories, 11 | * } from 'api/magento/subcategory/sub-categories'; 12 | * ... 13 | * ... 14 | * const rawData = await fetchSubcategorySubCategories({ urlKey }); 15 | * const data = normalizeSubcategorySubCategories(rawData); 16 | * ... 17 | * ... 18 | */ 19 | export { 20 | subcategorySubCategoriesQuery, 21 | fetchSubcategorySubCategories, 22 | normalizeSubcategorySubCategories, 23 | }; 24 | -------------------------------------------------------------------------------- /src/subcategory/sub-categories/normalizer.ts: -------------------------------------------------------------------------------- 1 | import get from 'lodash/get'; 2 | import orderBy from 'lodash/orderBy'; 3 | 4 | /** 5 | * Magento 2: subcategory sub-categories normalizer 6 | */ 7 | function normalizer(rawData): any { 8 | const rawChildren = get(rawData, 'data.categoryList[0].children', []); 9 | const rawChildrenSorted = orderBy(rawChildren, 'position'); 10 | return { 11 | items: rawChildrenSorted.map((item) => ({ 12 | text: get(item, 'name', ''), 13 | href: `/${get(item, 'url_path', '')}${get(item, 'url_suffix', '')}`, 14 | })), 15 | }; 16 | } 17 | 18 | export default normalizer; 19 | -------------------------------------------------------------------------------- /src/subcategory/sub-categories/query.ts: -------------------------------------------------------------------------------- 1 | import GraphQlQuery from '../../types/GraphQlQuery'; 2 | 3 | /** 4 | * Magento 2: subcategory sub-categories Graph QL query 5 | */ 6 | const query = ({ urlKey }): GraphQlQuery => ({ 7 | query: ` 8 | { 9 | categoryList( 10 | filters: { 11 | url_key: { 12 | eq: "${urlKey}" 13 | } 14 | } 15 | ) { 16 | children { 17 | level 18 | name 19 | url_path 20 | url_suffix 21 | position 22 | } 23 | } 24 | } 25 | `, 26 | }); 27 | 28 | export default query; 29 | -------------------------------------------------------------------------------- /src/subcategory/subcategory.ts: -------------------------------------------------------------------------------- 1 | import first from 'lodash/first'; 2 | import get from 'lodash/get'; 3 | import groupBy from 'lodash/groupBy'; 4 | import isArray from 'lodash/isArray'; 5 | import isEmpty from 'lodash/isEmpty'; 6 | import last from 'lodash/last'; 7 | 8 | import SubcategoryPageData from 'react-storefront-connector/SubcategoryPageData'; 9 | import Result from 'react-storefront-connector/Result'; 10 | import withAppData from '../app/withAppData'; 11 | import { fetchSubcategorySubCategories, normalizeSubcategorySubCategories } from './sub-categories'; 12 | import { fetchSubcategoryId, normalizeSubcategoryId } from './id'; 13 | import fetchSubcategory from './fetcher'; 14 | import normalizeSubcategory from './normalizer'; 15 | import { fetchCmsBlocks, normalizeCmsBlocks } from '../cms/blocks'; 16 | 17 | function filtersToQuery(filters): string { 18 | const filtersGrouped = groupBy(filters, (x) => x.split(':')[0]); 19 | const keys = Object.keys(filtersGrouped); 20 | return keys 21 | .map((key) => { 22 | const values: Array = filtersGrouped[key].map((f) => f.replace(`${key}:`, '')); 23 | if (key !== 'price') { 24 | return `${key}: { in: ${JSON.stringify(values)} }`; 25 | } 26 | 27 | const prices = values 28 | .map((x: string) => x.split('_').map(Number)) 29 | .flat() 30 | .sort(); 31 | const from = first(prices); 32 | const to = last(prices); 33 | if (!from && !to) { 34 | return null; 35 | } 36 | const fromQuery = from ? `from: "${from}"` : ''; 37 | const toQuery = to ? `to: "${to}"` : ''; 38 | return ` 39 | ${key}: { 40 | ${fromQuery} 41 | ${toQuery} 42 | } 43 | `; 44 | }) 45 | .filter(Boolean) 46 | .join('\n'); 47 | } 48 | 49 | function resolveCmsBlocksIdentifiers(urlKey) { 50 | if (urlKey === 'what-is-new') { 51 | urlKey = 'new'; // eslint-disable-line no-param-reassign 52 | } 53 | return `${urlKey}-block`; 54 | } 55 | 56 | export default async function subcategory( 57 | params, 58 | req, 59 | _res, 60 | ): Promise> { 61 | return withAppData(req, async () => { 62 | const { q = '', page = 1 } = params; 63 | let { slug, filters, sort = 'position: DESC' } = params; 64 | 65 | if (!isArray(slug)) { 66 | slug = (slug || '').split('/'); 67 | } 68 | 69 | const defaultSort = 'position: DESC'; 70 | const isSearch = !isEmpty(q); 71 | 72 | // TODO: This is not correct in all cases 73 | const isLanding = get(slug, 'length', 0) === 1 && !isSearch; // 1st level pages (/women, /men, etc.) are landings 74 | const lastSlug: string = last(slug) || ''; 75 | const urlKey = lastSlug.replace('.html', ''); 76 | 77 | if (sort === 'rating') { 78 | sort = defaultSort; // remove default RSF filter 79 | } 80 | 81 | if (filters) { 82 | filters = JSON.parse(filters); 83 | } else { 84 | filters = []; 85 | } 86 | 87 | // 1) get `id` and `name` & `navMenu` data 88 | let id; 89 | let name; 90 | let navMenu = null; 91 | if (isSearch) { 92 | id = `Search: ${q}`; 93 | name = `Results for "${q}"`; 94 | } else { 95 | const rawIdData = await fetchSubcategoryId({ urlKey }); 96 | const idData = normalizeSubcategoryId(rawIdData); 97 | id = idData.id; 98 | name = idData.name; 99 | const rawSubCategoriesData = await fetchSubcategorySubCategories({ urlKey }); 100 | navMenu = normalizeSubcategorySubCategories(rawSubCategoriesData); 101 | } 102 | 103 | // 2) get all subcategory page data 104 | const rawData = await fetchSubcategory({ 105 | categoryId: isSearch ? null : id, 106 | sort, 107 | currentPage: page, 108 | filter: filtersToQuery(filters), 109 | search: q, 110 | }); 111 | const data = normalizeSubcategory(rawData); 112 | 113 | // 3) get CMS slots data 114 | let cmsBlocks = []; 115 | 116 | if (isLanding) { 117 | const identifiers = resolveCmsBlocksIdentifiers(urlKey); 118 | const rawCmsBlocks = await fetchCmsBlocks({ identifiers }); 119 | cmsBlocks = normalizeCmsBlocks(rawCmsBlocks).items; 120 | } 121 | 122 | // collect all page data 123 | return { 124 | id, 125 | name, 126 | title: name, 127 | total: get(data, 'total', 0), 128 | page: get(data, 'currentPage', 1), 129 | totalPages: get(data, 'totalPages', 0), 130 | isLanding, 131 | cmsBlocks, 132 | products: get(data, 'products', []), 133 | sort, 134 | sortOptions: get(data, 'sortOptions', []) 135 | .map((option) => [ 136 | { 137 | // split up for ASC/DESC sort for demo 138 | name: `${get(option, 'name')} ⬇️`, 139 | code: `${get(option, 'code')}: DESC`, 140 | }, 141 | { 142 | name: `${get(option, 'name')} ⬆️`, 143 | code: `${get(option, 'code')}: ASC`, 144 | }, 145 | ]) 146 | .flat(), 147 | filters, 148 | facets: get(data, 'facets', []), 149 | navMenu, 150 | breadcrumbs: [ 151 | { 152 | text: 'Home', 153 | href: '/', 154 | }, 155 | ], 156 | }; 157 | }); 158 | } 159 | -------------------------------------------------------------------------------- /src/types/CartResponse.ts: -------------------------------------------------------------------------------- 1 | import _CartResponse from 'react-storefront-connector/CartResponse'; 2 | import Error from './Error'; 3 | 4 | export default interface CartResponse extends _CartResponse, Error { 5 | /** 6 | * The ID of guest cart 7 | */ 8 | guestCartId?: string 9 | 10 | /** 11 | * The ID of customer cart 12 | */ 13 | customerCartId?: string 14 | } 15 | -------------------------------------------------------------------------------- /src/types/Connector.ts: -------------------------------------------------------------------------------- 1 | import Connector from 'react-storefront-connector'; 2 | import home from '../home'; 3 | import cart from '../cart'; 4 | import addToCart from '../cart/addToCart'; 5 | import updateCartItem from '../cart/updateCartItem'; 6 | import removeCartItem from '../cart/removeCartItem'; 7 | import product from '../product'; 8 | import productSlots from '../product/productSlots'; 9 | import productSuggestions from '../product/productSuggestions'; 10 | import routes from '../routes'; 11 | import session from '../session'; 12 | import signIn from '../session/signIn'; 13 | import signOut from '../session/signOut'; 14 | import signUp from '../session/signUp'; 15 | import subcategory from '../subcategory'; 16 | import search from '../search'; 17 | import searchSuggestions from '../search/searchSuggestions'; 18 | 19 | export default class Magento2Connector implements Connector { 20 | home = home; 21 | 22 | cart = cart; 23 | 24 | addToCart = addToCart; 25 | 26 | updateCartItem = updateCartItem; 27 | 28 | removeCartItem = removeCartItem; 29 | 30 | product = product; 31 | 32 | session = session; 33 | 34 | signIn = signIn; 35 | 36 | signOut = signOut; 37 | 38 | signUp = signUp; 39 | 40 | subcategory = subcategory; 41 | 42 | search = search; 43 | 44 | routes = routes; 45 | 46 | productSlots = productSlots; 47 | 48 | productSuggestions = productSuggestions; 49 | 50 | searchSuggestions = searchSuggestions; 51 | } 52 | -------------------------------------------------------------------------------- /src/types/Error.ts: -------------------------------------------------------------------------------- 1 | export default interface Error { 2 | /** 3 | * Error from response 4 | */ 5 | error?: string 6 | } 7 | -------------------------------------------------------------------------------- /src/types/GraphQlQuery.ts: -------------------------------------------------------------------------------- 1 | export default interface GraphQlQuery { 2 | /** 3 | * Graph QL Query object (in string) 4 | */ 5 | query: string 6 | } 7 | -------------------------------------------------------------------------------- /src/types/ProductPageData.ts: -------------------------------------------------------------------------------- 1 | import ProductPageData, { Product as _Product } from 'react-storefront-connector/ProductPageData'; 2 | 3 | export default ProductPageData; 4 | 5 | /** 6 | * Information about a product 7 | */ 8 | export interface Product extends _Product { 9 | /** 10 | * Defines if the type is configurable product 11 | */ 12 | isConfigurableProduct?: boolean 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist", 4 | "allowJs": true, 5 | "target": "es5", 6 | "esModuleInterop": true, 7 | "lib": ["es2019", "dom"] 8 | }, 9 | "include": [ 10 | "./src/**/*" 11 | ] 12 | } --------------------------------------------------------------------------------