diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..0e2711e --- /dev/null +++ b/.babelrc @@ -0,0 +1,11 @@ +{ + "presets": [ + "@babel/preset-env", + "@babel/preset-react" + ], + "plugins": [ + "@babel/plugin-proposal-class-properties", + "@babel/plugin-syntax-object-rest-spread", + "@babel/plugin-syntax-dynamic-import" + ] + } \ No newline at end of file diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..af32a09 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,2 @@ +/webpack/ +/*.js \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..9ea3a86 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,56 @@ +{ + "env": { + "browser": true, + "es2021": true + }, + "extends": [ + "plugin:react/recommended", + "airbnb" + ], + "parser": "babel-eslint", + "parserOptions": { + "ecmaFeatures": { + "jsx": true + }, + "ecmaVersion": 12, + "sourceType": "module" + }, + "plugins": [ + "react" + ], + "rules": { + "no-multiple-empty-lines": [ + "error", + { + "max": 2, + "maxEOF": 1 + } + ], + "comma-dangle": [ + "error", + { + "arrays": "never", + "objects": "never", + "imports": "never", + "exports": "never", + "functions": "ignore" + } + ], + "no-console": 0 + }, + "settings": { + "import/resolver": { + "node": { + "paths": [ + "src" + ], + "extensions": [ + ".js", + ".jsx", + ".ts", + ".tsx" + ] + } + } + } +} \ No newline at end of file diff --git a/.firebaserc b/.firebaserc new file mode 100644 index 0000000..82154a5 --- /dev/null +++ b/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "salinaka-ecommerce" + } +} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..0dbfa72 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,31 @@ +name: Firebase Deploy + +on: + push: + branches: + - master + +jobs: + build: + name: Build and Deploy to Firebase Hosting + runs-on: ubuntu-latest + steps: + - name: Checkout Repo + uses: actions/checkout@master + - name: Install Dependencies + run: npm install + - name: Build + run: npm run build + env: + FIREBASE_API_KEY: ${{ secrets.FIREBASE_API_KEY }} + FIREBASE_AUTH_DOMAIN: ${{ secrets.FIREBASE_AUTH_DOMAIN }} + FIREBASE_DB_URL: ${{ secrets.FIREBASE_DB_URL }} + FIREBASE_PROJECT_ID: ${{ secrets.FIREBASE_PROJECT_ID }} + FIREBASE_STORAGE_BUCKET: ${{ secrets.FIREBASE_STORAGE_BUCKET }} + FIREBASE_MSG_SENDER_ID: ${{ secrets.FIREBASE_MSG_SENDER_ID }} + FIREBASE_APP_ID: ${{ secrets.FIREBASE_APP_ID }} + - name: Deploy to Firebase + uses: lowply/deploy-firebase@v0.0.2 + env: + FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }} + FIREBASE_PROJECT: ${{ secrets.FIREBASE_PROJECT_ID }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43d3420 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +.env.prod +.env.dev +dist/ +.firebase/ +package-lock.json +debug.log \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e0953a0 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "files.eol": "\n", + "editor.tabSize": 2 +} \ No newline at end of file diff --git a/404.html b/404.html new file mode 100644 index 0000000..797ffae --- /dev/null +++ b/404.html @@ -0,0 +1,16 @@ + + + + + + + + Salinaka - React Ecommerce Store + + + + +
+ + + \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/database.rules.json b/database.rules.json new file mode 100644 index 0000000..5dde067 --- /dev/null +++ b/database.rules.json @@ -0,0 +1,63 @@ +{ + "rules": { + ".read": false, + ".write": "auth !== null", + "users": { + "$user_id": { + ".read": "$user_id === auth.uid", + ".write": "$user_id === auth.uid", + "fullname": { + ".validate": "newData.isString()" + }, + "email": { + ".validate": "newData.isString()" + }, + "address": { + ".validate": "newData.isString()" + }, + "mobile": { + ".validate": "newData.isString()" + }, + "avatar": { + ".validate": "newData.isString()" + }, + "banner": { + ".validate": "newData.isString()" + }, + "dateJoined": { + ".validate": "newData.isString()" + } + } + }, + "products": { + ".read": true, + ".write": false, + "$product_id": { + "name": { + ".validate": "newData.isString()" + }, + "description": { + ".validate": "newData.isString()" + }, + "price": { + ".validate": "newData.isNumber()" + }, + "brand": { + ".validate": "newData.isString()" + }, + "image": { + ".validate": "newData.isString()" + }, + "maxQuantity": { + ".validate": "newData.isNumber()" + }, + "quantity": { + ".validate": "newData.isNumber()" + }, + "dateAdded": { + ".validate": "newData.isNumber()" + } + } + } + } +} \ No newline at end of file diff --git a/firebase.json b/firebase.json new file mode 100644 index 0000000..32bcf96 --- /dev/null +++ b/firebase.json @@ -0,0 +1,30 @@ +{ + "database": { + "rules": "database.rules.json" + }, + "hosting": { + "public": "dist", + "ignore": [ + "firebase.json", + "**/.*", + "**/node_modules/**" + ] + }, + "emulators": { + "firestore": { + "port": "4000" + } + }, + "storage": { + "rules": "storage.rules" + }, + "firestore": { + "rules": "firestore.rules", + "indexes": "firestore.indexes.json" + }, + "functions": { + "predeploy": [ + "npm --prefix \"$RESOURCE_DIR\" run lint" + ] + } +} \ No newline at end of file diff --git a/firestore-debug.log b/firestore-debug.log new file mode 100644 index 0000000..7e66e97 --- /dev/null +++ b/firestore-debug.log @@ -0,0 +1,7 @@ +API endpoint: http://localhost:4000 +If you are using a library that supports the FIRESTORE_EMULATOR_HOST environment variable, run: + + export FIRESTORE_EMULATOR_HOST=localhost:4000 + +Dev App Server is now running. + diff --git a/firestore.indexes.json b/firestore.indexes.json new file mode 100644 index 0000000..415027e --- /dev/null +++ b/firestore.indexes.json @@ -0,0 +1,4 @@ +{ + "indexes": [], + "fieldOverrides": [] +} diff --git a/firestore.rules b/firestore.rules new file mode 100644 index 0000000..accb8ae --- /dev/null +++ b/firestore.rules @@ -0,0 +1,27 @@ +rules_version = '2'; +service cloud.firestore { + match /databases/{database}/documents { + + // This rule allows anyone on the internet to view, edit, and delete + // all data in your Firestore database. It is useful for getting + // started, but it is configured to expire after 30 days because it + // leaves your app open to attackers. At that time, all client + // requests to your Firestore database will be denied. + // + // Make sure to write security rules for your app before that time, or else + // your app will lose access to your Firestore database + //match /{document=**} { + // allow read, write: if request.time < timestamp.date(2020, 4, 21); + //} + + match /users/{userId} { + allow read, update: if request.auth.uid != null && request.auth.uid == userId; + allow create: if request.auth.uid != null; + } + + match /products/{productId} { + allow create,update,delete: if request.auth.uid != null; + allow read: if true; + } + } +} \ No newline at end of file diff --git a/functions/.eslintrc.json b/functions/.eslintrc.json new file mode 100644 index 0000000..6b6beb6 --- /dev/null +++ b/functions/.eslintrc.json @@ -0,0 +1,123 @@ +{ + "parserOptions": { + // Required for certain syntax usages + "ecmaVersion": 2017 + }, + "plugins": [ + "promise" + ], + "extends": "eslint:recommended", + "rules": { + // Removed rule "disallow the use of console" from recommended eslint rules + "no-console": "off", + + // Removed rule "disallow multiple spaces in regular expressions" from recommended eslint rules + "no-regex-spaces": "off", + + // Removed rule "disallow the use of debugger" from recommended eslint rules + "no-debugger": "off", + + // Removed rule "disallow unused variables" from recommended eslint rules + "no-unused-vars": "off", + + // Removed rule "disallow mixed spaces and tabs for indentation" from recommended eslint rules + "no-mixed-spaces-and-tabs": "off", + + // Removed rule "disallow the use of undeclared variables unless mentioned in /*global */ comments" from recommended eslint rules + "no-undef": "off", + + // Warn against template literal placeholder syntax in regular strings + "no-template-curly-in-string": 1, + + // Warn if return statements do not either always or never specify values + "consistent-return": 1, + + // Warn if no return statements in callbacks of array methods + "array-callback-return": 1, + + // Require the use of === and !== + "eqeqeq": 2, + + // Disallow the use of alert, confirm, and prompt + "no-alert": 2, + + // Disallow the use of arguments.caller or arguments.callee + "no-caller": 2, + + // Disallow null comparisons without type-checking operators + "no-eq-null": 2, + + // Disallow the use of eval() + "no-eval": 2, + + // Warn against extending native types + "no-extend-native": 1, + + // Warn against unnecessary calls to .bind() + "no-extra-bind": 1, + + // Warn against unnecessary labels + "no-extra-label": 1, + + // Disallow leading or trailing decimal points in numeric literals + "no-floating-decimal": 2, + + // Warn against shorthand type conversions + "no-implicit-coercion": 1, + + // Warn against function declarations and expressions inside loop statements + "no-loop-func": 1, + + // Disallow new operators with the Function object + "no-new-func": 2, + + // Warn against new operators with the String, Number, and Boolean objects + "no-new-wrappers": 1, + + // Disallow throwing literals as exceptions + "no-throw-literal": 2, + + // Require using Error objects as Promise rejection reasons + "prefer-promise-reject-errors": 2, + + // Enforce β€œfor” loop update clause moving the counter in the right direction + "for-direction": 2, + + // Enforce return statements in getters + "getter-return": 2, + + // Disallow await inside of loops + "no-await-in-loop": 2, + + // Disallow comparing against -0 + "no-compare-neg-zero": 2, + + // Warn against catch clause parameters from shadowing variables in the outer scope + "no-catch-shadow": 1, + + // Disallow identifiers from shadowing restricted names + "no-shadow-restricted-names": 2, + + // Enforce return statements in callbacks of array methods + "callback-return": 2, + + // Require error handling in callbacks + "handle-callback-err": 2, + + // Warn against string concatenation with __dirname and __filename + "no-path-concat": 1, + + // Prefer using arrow functions for callbacks + "prefer-arrow-callback": 1, + + // Return inside each then() to create readable and reusable Promise chains. + // Forces developers to return console logs and http calls in promises. + "promise/always-return": 2, + + //Enforces the use of catch() on un-returned promises + "promise/catch-or-return": 2, + + // Warn against nested then() or catch() statements + "promise/no-nesting": 1 + } +} diff --git a/functions/.gitignore b/functions/.gitignore new file mode 100644 index 0000000..40b878d --- /dev/null +++ b/functions/.gitignore @@ -0,0 +1 @@ +node_modules/ \ No newline at end of file diff --git a/functions/index.js b/functions/index.js new file mode 100644 index 0000000..99910df --- /dev/null +++ b/functions/index.js @@ -0,0 +1,16 @@ +const functions = require('firebase-functions'); +const admin = require('firebase-admin'); + +admin.initializeApp(); + +exports.lowercaseProductName = functions.firestore.document('/products/{documentId}') + .onCreate((snap, context) => { + const name = snap.data().name; + + functions.logger.log('Lowercasing product name', context.params.documentId, name); + + const lowercaseName = name.toLowerCase(); + + return snap.ref.set({ name_lower: lowercaseName }, { merge: true }); + }); + diff --git a/functions/package.json b/functions/package.json new file mode 100644 index 0000000..e38a730 --- /dev/null +++ b/functions/package.json @@ -0,0 +1,26 @@ +{ + "name": "functions", + "description": "Cloud Functions for Firebase", + "scripts": { + "lint": "eslint .", + "serve": "firebase emulators:start --only functions", + "shell": "firebase functions:shell", + "start": "npm run shell", + "deploy": "firebase deploy --only functions", + "logs": "firebase functions:log" + }, + "engines": { + "node": "8" + }, + "main": "index.js", + "dependencies": { + "firebase-admin": "^9.2.0", + "firebase-functions": "^3.11.0" + }, + "devDependencies": { + "eslint": "^5.12.0", + "eslint-plugin-promise": "^4.0.1", + "firebase-functions-test": "^0.2.0" + }, + "private": true +} \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..797ffae --- /dev/null +++ b/index.html @@ -0,0 +1,16 @@ + + + + + + + + Salinaka - React Ecommerce Store + + + + +
+ + + \ No newline at end of file diff --git a/jest.config.json b/jest.config.json new file mode 100644 index 0000000..cf99e8c --- /dev/null +++ b/jest.config.json @@ -0,0 +1,9 @@ +{ + "setupFiles": [ + "raf/polyfill", + "/test/setup.js" + ], + "snapshotSerializers": [ + "enzyme-to-json/serializer" + ] +} \ No newline at end of file diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..78282a8 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "jsx": "react", + "baseUrl": "src", + "noUnusedLocals": true, + "noUnusedParameters": true + }, + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..7fda268 --- /dev/null +++ b/package.json @@ -0,0 +1,88 @@ +{ + "name": "ecommerce-react", + "version": "1.2.0", + "main": "index.js", + "author": "Julius Guevarra", + "keywords": [ + "react-webpack", + "boilerplate", + "react-webpack-boilerplate", + "react-boilerplate" + ], + "license": "MIT", + "scripts": { + "serve": "live-server dist/", + "build": "cross-env NODE_ENV=production webpack --config webpack/prod.config.js", + "dev-server": "webpack serve --config webpack/dev.config.js", + "test": "cross-env NODE_ENV=test jest --config=jest.config.json" + }, + "devDependencies": { + "@babel/core": "^7.14.0", + "@babel/plugin-proposal-class-properties": "^7.13.0", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/polyfill": "^7.11.5", + "@babel/preset-env": "^7.14.0", + "@babel/preset-react": "^7.13.13", + "babel-eslint": "^10.1.0", + "babel-loader": "^8.2.2", + "clean-webpack-plugin": "^3.0.0", + "copy-webpack-plugin": "^8.1.1", + "cross-env": "^7.0.3", + "css-loader": "^5.2.4", + "dotenv": "^8.2.0", + "enzyme": "^3.11.0", + "enzyme-adapter-react-16": "^1.15.6", + "enzyme-to-json": "^3.6.2", + "eslint": "^7.25.0", + "eslint-config-airbnb": "^18.2.1", + "eslint-loader": "^4.0.2", + "eslint-plugin-import": "^2.22.1", + "eslint-plugin-jsx-a11y": "^6.4.1", + "eslint-plugin-react": "^7.23.2", + "eslint-plugin-react-hooks": "^4.2.0", + "extract-text-webpack-plugin": "^4.0.0-beta.0", + "file-loader": "^6.2.0", + "group-css-media-queries-loader": "^3.0.2", + "html-webpack-plugin": "^5.3.1", + "jest": "^26.6.3", + "live-server": "^1.2.1", + "mini-css-extract-plugin": "^1.6.0", + "node-sass": "^5.0.0", + "optimize-css-assets-webpack-plugin": "^5.0.4", + "raf": "^3.4.1", + "sass-loader": "^11.0.1", + "uglifyjs-webpack-plugin": "^2.2.0", + "url-loader": "^4.1.1", + "webpack": "^5.36.2", + "webpack-cli": "^4.6.0", + "webpack-dev-server": "^3.11.2", + "webpack-merge": "^5.7.3", + "workbox-webpack-plugin": "^6.1.5" + }, + "dependencies": { + "@ant-design/icons": "^4.6.2", + "firebase": "^8.4.3", + "formik": "^2.2.6", + "history": "^4.10.0", + "moment": "^2.29.1", + "normalize.css": "^8.0.1", + "prop-types": "^15.7.2", + "react": "^17.0.2", + "react-compound-slider": "^3.3.1", + "react-dom": "^17.0.2", + "react-loading-skeleton": "^2.2.0", + "react-modal": "^3.13.1", + "react-phone-input-2": "^2.14.0", + "react-redux": "^7.2.4", + "react-router-dom": "^5.2.0", + "react-select": "^4.3.0", + "redux": "^4.1.0", + "redux-persist": "^6.0.0", + "redux-saga": "^1.1.3", + "redux-thunk": "^2.3.0", + "uuid": "^8.3.2", + "webfontloader": "^1.6.28", + "yup": "^0.32.9" + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..ff83fff --- /dev/null +++ b/public/index.html @@ -0,0 +1,65 @@ + + + + + + Welcome to Firebase Hosting + + + + + + + + + + + + + + +
+

Welcome

+

Firebase Hosting Setup Complete

+

You're seeing this because you've successfully setup Firebase Hosting. Now it's time to go build something extraordinary!

+ Open Hosting Documentation +
+

Firebase SDK Loading…

+ + + + diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000..21eba0f --- /dev/null +++ b/src/App.jsx @@ -0,0 +1,24 @@ +/* eslint-disable react/forbid-prop-types */ +import { Preloader } from 'components/common'; +import PropType from 'prop-types'; +import React, { StrictMode } from 'react'; +import { Provider } from 'react-redux'; +import { PersistGate } from 'redux-persist/integration/react'; +import AppRouter from 'routers/AppRouter'; + +const App = ({ store, persistor }) => ( + + + } persistor={persistor}> + + + + +); + +App.propTypes = { + store: PropType.any.isRequired, + persistor: PropType.any.isRequired +}; + +export default App; diff --git a/src/components/basket/Basket.jsx b/src/components/basket/Basket.jsx new file mode 100644 index 0000000..e65f50b --- /dev/null +++ b/src/components/basket/Basket.jsx @@ -0,0 +1,150 @@ +/* eslint-disable max-len */ +import { BasketItem, BasketToggle } from 'components/basket'; +import { Boundary, Modal } from 'components/common'; +import { CHECKOUT_STEP_1 } from 'constants/routes'; +import firebase from 'firebase/firebase'; +import { calculateTotal, displayMoney } from 'helpers/utils'; +import { useDidMount, useModal } from 'hooks'; +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useHistory, useLocation } from 'react-router-dom'; +import { clearBasket } from 'redux/actions/basketActions'; + +const Basket = () => { + const { isOpenModal, onOpenModal, onCloseModal } = useModal(); + const { basket, user } = useSelector((state) => ({ + basket: state.basket, + user: state.auth + })); + const history = useHistory(); + const { pathname } = useLocation(); + const dispatch = useDispatch(); + const didMount = useDidMount(); + + useEffect(() => { + if (didMount && firebase.auth.currentUser && basket.length !== 0) { + firebase.saveBasketItems(basket, firebase.auth.currentUser.uid) + .then(() => { + console.log('Item saved to basket'); + }) + .catch((e) => { + console.log(e); + }); + } + }, [basket.length]); + + const onCheckOut = () => { + if ((basket.length !== 0 && user)) { + document.body.classList.remove('is-basket-open'); + history.push(CHECKOUT_STEP_1); + } else { + onOpenModal(); + } + }; + + const onSignInClick = () => { + onCloseModal(); + document.body.classList.remove('basket-open'); + history.push(CHECKOUT_STEP_1); + }; + + const onClearBasket = () => { + if (basket.length !== 0) { + dispatch(clearBasket()); + } + }; + + return user && user.role === 'ADMIN' ? null : ( + + +

You must sign in to continue checking out

+
+
+ +   + +
+
+
+
+
+

+ My Basket   + + ( + {` ${basket.length} ${basket.length > 1 ? 'items' : 'item'}`} + ) + +

+ + {({ onClickToggle }) => ( + + Close + + )} + + +
+ {basket.length <= 0 && ( +
+
Your basket is empty
+
+ )} + {basket.map((product, i) => ( + + ))} +
+
+
+

Subtotal Amout:

+

+ {displayMoney(calculateTotal(basket.map((product) => product.price * product.quantity)))} +

+
+ +
+
+
+ ); +}; + +export default Basket; diff --git a/src/components/basket/BasketItem.jsx b/src/components/basket/BasketItem.jsx new file mode 100644 index 0000000..700fc5d --- /dev/null +++ b/src/components/basket/BasketItem.jsx @@ -0,0 +1,94 @@ +import { CloseOutlined } from '@ant-design/icons'; +import { BasketItemControl } from 'components/basket'; +import { ImageLoader } from 'components/common'; +import { displayMoney } from 'helpers/utils'; +import PropType from 'prop-types'; +import React from 'react'; +import { useDispatch } from 'react-redux'; +import { Link } from 'react-router-dom'; +import { removeFromBasket } from 'redux/actions/basketActions'; + +const BasketItem = ({ product }) => { + const dispatch = useDispatch(); + const onRemoveFromBasket = () => dispatch(removeFromBasket(product.id)); + + return ( +
+ +
+
+ +
+
+ document.body.classList.remove('is-basket-open')}> +

+ {product.name} +

+ +
+
+ Quantity +
{product.quantity}
+
+
+ Size +
+ {product.selectedSize} + {' '} + mm +
+
+
+ Color +
+
+
+
+
+

{displayMoney(product.price * product.quantity)}

+
+ +
+
+ ); +}; + +BasketItem.propTypes = { + product: PropType.shape({ + id: PropType.string, + name: PropType.string, + brand: PropType.string, + price: PropType.number, + quantity: PropType.number, + maxQuantity: PropType.number, + description: PropType.string, + keywords: PropType.arrayOf(PropType.string), + selectedSize: PropType.string, + selectedColor: PropType.string, + imageCollection: PropType.arrayOf(PropType.string), + sizes: PropType.arrayOf(PropType.number), + image: PropType.string, + imageUrl: PropType.string, + isFeatured: PropType.bool, + isRecommended: PropType.bool, + availableColors: PropType.arrayOf(PropType.string) + }).isRequired +}; + +export default BasketItem; diff --git a/src/components/basket/BasketItemControl.jsx b/src/components/basket/BasketItemControl.jsx new file mode 100644 index 0000000..bd8632e --- /dev/null +++ b/src/components/basket/BasketItemControl.jsx @@ -0,0 +1,66 @@ +import { MinusOutlined, PlusOutlined } from '@ant-design/icons'; +import PropType from 'prop-types'; +import React from 'react'; +import { useDispatch } from 'react-redux'; +import { addQtyItem, minusQtyItem } from 'redux/actions/basketActions'; + +const BasketItemControl = ({ product }) => { + const dispatch = useDispatch(); + + const onAddQty = () => { + if (product.quantity < product.maxQuantity) { + dispatch(addQtyItem(product.id)); + } + }; + + const onMinusQty = () => { + if ((product.maxQuantity >= product.quantity) && product.quantity !== 0) { + dispatch(minusQtyItem(product.id)); + } + }; + + return ( +
+ + +
+ ); +}; + +BasketItemControl.propTypes = { + product: PropType.shape({ + id: PropType.string, + name: PropType.string, + brand: PropType.string, + price: PropType.number, + quantity: PropType.number, + maxQuantity: PropType.number, + description: PropType.string, + keywords: PropType.arrayOf(PropType.string), + selectedSize: PropType.string, + selectedColor: PropType.string, + imageCollection: PropType.arrayOf(PropType.string), + sizes: PropType.arrayOf(PropType.number), + image: PropType.string, + imageUrl: PropType.string, + isFeatured: PropType.bool, + isRecommended: PropType.bool, + availableColors: PropType.arrayOf(PropType.string) + }).isRequired +}; + +export default BasketItemControl; diff --git a/src/components/basket/BasketToggle.jsx b/src/components/basket/BasketToggle.jsx new file mode 100644 index 0000000..7827d6c --- /dev/null +++ b/src/components/basket/BasketToggle.jsx @@ -0,0 +1,33 @@ +import PropType from 'prop-types'; + +const BasketToggle = ({ children }) => { + const onClickToggle = () => { + if (document.body.classList.contains('is-basket-open')) { + document.body.classList.remove('is-basket-open'); + } else { + document.body.classList.add('is-basket-open'); + } + }; + + document.addEventListener('click', (e) => { + const closest = e.target.closest('.basket'); + const toggle = e.target.closest('.basket-toggle'); + const closeToggle = e.target.closest('.basket-item-remove'); + + if (!closest && document.body.classList.contains('is-basket-open') && !toggle && !closeToggle) { + document.body.classList.remove('is-basket-open'); + } + }); + + return children({ onClickToggle }); +}; + +BasketToggle.propTypes = { + children: PropType.oneOfType([ + PropType.arrayOf(PropType.node), + PropType.func, + PropType.node + ]).isRequired +}; + +export default BasketToggle; diff --git a/src/components/basket/index.js b/src/components/basket/index.js new file mode 100644 index 0000000..f7fce28 --- /dev/null +++ b/src/components/basket/index.js @@ -0,0 +1,5 @@ +export { default as Basket } from './Basket'; +export { default as BasketItem } from './BasketItem'; +export { default as BasketItemControl } from './BasketItemControl'; +export { default as BasketToggle } from './BasketToggle'; + diff --git a/src/components/common/AdminNavigation.jsx b/src/components/common/AdminNavigation.jsx new file mode 100644 index 0000000..6aa9cdf --- /dev/null +++ b/src/components/common/AdminNavigation.jsx @@ -0,0 +1,34 @@ +import { ADMIN_DASHBOARD } from 'constants/routes'; +import logo from 'images/logo-full.png'; +import React from 'react'; +import { useSelector } from 'react-redux'; +import { Link } from 'react-router-dom'; +import UserAvatar from 'views/account/components/UserAvatar'; + +const AdminNavigation = () => { + const { isAuthenticating, profile } = useSelector((state) => ({ + isAuthenticating: state.app.isAuthenticating, + profile: state.profile + })); + + return ( + + ); +}; + +export default AdminNavigation; diff --git a/src/components/common/AdminSidePanel.jsx b/src/components/common/AdminSidePanel.jsx new file mode 100644 index 0000000..47d7b4c --- /dev/null +++ b/src/components/common/AdminSidePanel.jsx @@ -0,0 +1,24 @@ +import { ADMIN_PRODUCTS } from 'constants/routes'; +import React from 'react'; +import { NavLink } from 'react-router-dom'; + +const SideNavigation = () => ( + +); + +export default SideNavigation; diff --git a/src/components/common/Badge.jsx b/src/components/common/Badge.jsx new file mode 100644 index 0000000..8f9e125 --- /dev/null +++ b/src/components/common/Badge.jsx @@ -0,0 +1,19 @@ +import PropType from 'prop-types'; +import React from 'react'; + +const Badge = ({ count, children }) => ( +
+ {children} + {count >= 1 && {count}} +
+); + +Badge.propTypes = { + count: PropType.number.isRequired, + children: PropType.oneOfType([ + PropType.arrayOf(PropType.node), + PropType.node + ]).isRequired +}; + +export default Badge; diff --git a/src/components/common/Boundary.jsx b/src/components/common/Boundary.jsx new file mode 100644 index 0000000..b946108 --- /dev/null +++ b/src/components/common/Boundary.jsx @@ -0,0 +1,45 @@ +import PropType from 'prop-types'; +import React, { Component } from 'react'; + +class Boundary extends Component { + static getDerivedStateFromError() { + return { hasError: true }; + } + + constructor(props) { + super(props); + + this.state = { + hasError: false + }; + } + + + componentDidCatch(error) { + console.log(error); + } + + render() { + const { hasError } = this.state; + const { children } = this.props; + + if (hasError) { + return ( +
+

:( Something went wrong.

+
+ ); + } + + return children; + } +} + +Boundary.propTypes = { + children: PropType.oneOfType([ + PropType.arrayOf(PropType.node), + PropType.node + ]).isRequired +}; + +export default Boundary; diff --git a/src/components/common/ColorChooser.jsx b/src/components/common/ColorChooser.jsx new file mode 100644 index 0000000..7e179ba --- /dev/null +++ b/src/components/common/ColorChooser.jsx @@ -0,0 +1,31 @@ +import PropType from 'prop-types'; +import React, { useState } from 'react'; + +const ColorChooser = ({ availableColors, onSelectedColorChange }) => { + const [selectedColor, setSelectedColor] = useState(''); + + const setColor = (color) => { + setSelectedColor(color); + onSelectedColorChange(color); + }; + return ( +
+ {availableColors.map((color) => ( +
setColor(color)} + style={{ backgroundColor: color }} + role="presentation" + /> + ))} +
+ ); +}; + +ColorChooser.propTypes = { + availableColors: PropType.arrayOf(PropType.string).isRequired, + onSelectedColorChange: PropType.func.isRequired +}; + +export default ColorChooser; diff --git a/src/components/common/Filters.jsx b/src/components/common/Filters.jsx new file mode 100644 index 0000000..cd6fa16 --- /dev/null +++ b/src/components/common/Filters.jsx @@ -0,0 +1,166 @@ +/* eslint-disable no-nested-ternary */ +import { useDidMount } from 'hooks'; +import PropType from 'prop-types'; +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useHistory, withRouter } from 'react-router-dom'; +import { applyFilter, resetFilter } from 'redux/actions/filterActions'; +import { selectMax, selectMin } from 'selectors/selector'; +import PriceRange from './PriceRange'; + +const Filters = ({ closeModal }) => { + const { filter, isLoading, products } = useSelector((state) => ({ + filter: state.filter, + isLoading: state.app.loading, + products: state.products.items + })); + const [field, setFilter] = useState({ + brand: filter.brand, + minPrice: filter.minPrice, + maxPrice: filter.maxPrice, + sortBy: filter.sortBy + }); + const dispatch = useDispatch(); + const history = useHistory(); + const didMount = useDidMount(); + + const max = selectMax(products); + const min = selectMin(products); + + useEffect(() => { + if (didMount && window.screen.width <= 480) { + history.push('/'); + } + + if (didMount && closeModal) closeModal(); + + setFilter(filter); + window.scrollTo(0, 0); + }, [filter]); + + + const onPriceChange = (minVal, maxVal) => { + setFilter({ ...field, minPrice: minVal, maxPrice: maxVal }); + }; + + const onBrandFilterChange = (e) => { + const val = e.target.value; + + setFilter({ ...field, brand: val }); + }; + + const onSortFilterChange = (e) => { + setFilter({ ...field, sortBy: e.target.value }); + }; + + const onApplyFilter = () => { + const isChanged = Object.keys(field).some((key) => field[key] !== filter[key]); + + if (field.minPrice > field.maxPrice) { + return; + } + + if (isChanged) { + dispatch(applyFilter(field)); + } else { + closeModal(); + } + }; + + const onResetFilter = () => { + const filterFields = ['brand', 'minPrice', 'maxPrice', 'sortBy']; + + if (filterFields.some((key) => !!filter[key])) { + dispatch(resetFilter()); + } else { + closeModal(); + } + }; + + return ( +
+
+ Brand +
+
+ {products.length === 0 && isLoading ? ( +
Loading Filter
+ ) : ( + + )} +
+
+ Sort By +
+
+ +
+
+ Price Range +
+
+ {(products.length === 0 && isLoading) || max === 0 ? ( +
Loading Filter
+ ) : products.length === 1 ? ( +
No Price Range
+ ) : ( + + )} +
+
+ + +
+
+ ); +}; + +Filters.propTypes = { + closeModal: PropType.func.isRequired +}; + +export default withRouter(Filters); diff --git a/src/components/common/FiltersToggle.jsx b/src/components/common/FiltersToggle.jsx new file mode 100644 index 0000000..736a21b --- /dev/null +++ b/src/components/common/FiltersToggle.jsx @@ -0,0 +1,45 @@ +import { useModal } from 'hooks'; +import PropType from 'prop-types'; +import React from 'react'; +import Filters from './Filters'; +import Modal from './Modal'; + +const FiltersToggle = ({ children }) => { + const { isOpenModal, onOpenModal, onCloseModal } = useModal(); + + return ( + <> +
+ {children} +
+ +
+ +
+ +
+ + ); +}; + +FiltersToggle.propTypes = { + children: PropType.oneOfType([ + PropType.arrayOf(PropType.node), + PropType.node + ]).isRequired +}; + +export default FiltersToggle; diff --git a/src/components/common/Footer.jsx b/src/components/common/Footer.jsx new file mode 100644 index 0000000..59f7ab7 --- /dev/null +++ b/src/components/common/Footer.jsx @@ -0,0 +1,44 @@ +import * as Route from 'constants/routes'; +import logo from 'images/logo-full.png'; +import React from 'react'; +import { useLocation } from 'react-router-dom'; + +const Footer = () => { + const { pathname } = useLocation(); + + const visibleOnlyPath = [ + Route.HOME, + Route.SHOP + ]; + + return !visibleOnlyPath.includes(pathname) ? null : ( +
+
+ + + Developed by + {' '} + JULIUS GUEVARRA + + +
+
+ Footer logo +
+ ©  + {new Date().getFullYear()} +
+
+
+ + + Fork this project   + HERE + + +
+
+ ); +}; + +export default Footer; diff --git a/src/components/common/ImageLoader.jsx b/src/components/common/ImageLoader.jsx new file mode 100644 index 0000000..f004da1 --- /dev/null +++ b/src/components/common/ImageLoader.jsx @@ -0,0 +1,42 @@ +import { LoadingOutlined } from '@ant-design/icons'; +import PropType from 'prop-types'; +import React, { useState } from 'react'; + +const ImageLoader = ({ src, alt, className }) => { + const loadedImages = {}; + const [loaded, setLoaded] = useState(loadedImages[src]); + + const onLoad = () => { + loadedImages[src] = true; + setLoaded(true); + }; + + return ( + <> + {!loaded && ( + + )} + {alt + + ); +}; + +ImageLoader.defaultProps = { + className: 'image-loader' +}; + +ImageLoader.propTypes = { + src: PropType.string.isRequired, + alt: PropType.string.isRequired, + className: PropType.string +}; + +export default ImageLoader; diff --git a/src/components/common/MessageDisplay.jsx b/src/components/common/MessageDisplay.jsx new file mode 100644 index 0000000..00dba75 --- /dev/null +++ b/src/components/common/MessageDisplay.jsx @@ -0,0 +1,36 @@ +import PropType from 'prop-types'; +import React from 'react'; + +const MessageDisplay = ({ + message, description, buttonLabel, action +}) => ( +
+

{message || 'Message'}

+ {description && {description}} +
+ {action && ( + + )} +
+); + +MessageDisplay.defaultProps = { + description: undefined, + buttonLabel: 'Okay', + action: undefined +}; + +MessageDisplay.propTypes = { + message: PropType.string.isRequired, + description: PropType.string, + buttonLabel: PropType.string, + action: PropType.func +}; + +export default MessageDisplay; diff --git a/src/components/common/MobileNavigation.jsx b/src/components/common/MobileNavigation.jsx new file mode 100644 index 0000000..2557854 --- /dev/null +++ b/src/components/common/MobileNavigation.jsx @@ -0,0 +1,89 @@ +import { BasketToggle } from 'components/basket'; +import { HOME, SIGNIN } from 'constants/routes'; +import PropType from 'prop-types'; +import React from 'react'; +import { Link, useLocation } from 'react-router-dom'; +import UserNav from 'views/account/components/UserAvatar'; +import Badge from './Badge'; +import FiltersToggle from './FiltersToggle'; +import SearchBar from './SearchBar'; + +const Navigation = (props) => { + const { + isAuthenticating, basketLength, disabledPaths, user + } = props; + const { pathname } = useLocation(); + + const onClickLink = (e) => { + if (isAuthenticating) e.preventDefault(); + }; + + return ( + + ); +}; + +Navigation.propTypes = { + isAuthenticating: PropType.bool.isRequired, + basketLength: PropType.number.isRequired, + disabledPaths: PropType.arrayOf(PropType.string).isRequired, + user: PropType.oneOfType([ + PropType.bool, + PropType.object + ]).isRequired +}; + +export default Navigation; diff --git a/src/components/common/Modal.jsx b/src/components/common/Modal.jsx new file mode 100644 index 0000000..49f26da --- /dev/null +++ b/src/components/common/Modal.jsx @@ -0,0 +1,64 @@ +import PropType from 'prop-types'; +import React from 'react'; +import AppModal from 'react-modal'; + +const Modal = ({ + isOpen, + onRequestClose, + afterOpenModal, + overrideStyle, + children +}) => { + const defaultStyle = { + content: { + top: '50%', + left: '50%', + right: 'auto', + bottom: 'auto', + position: 'fixed', + padding: '50px 20px', + transition: 'all .5s ease', + zIndex: 9999, + marginRight: '-50%', + transform: 'translate(-50%, -50%)', + boxShadow: '0 5px 10px rgba(0, 0, 0, .1)', + animation: 'scale .3s ease', + ...overrideStyle + } + }; + + AppModal.setAppElement('#app'); + + return ( + + {children} + + ); +}; + +Modal.defaultProps = { + overrideStyle: {}, + afterOpenModal: () => { } +}; + +Modal.propTypes = { + isOpen: PropType.bool.isRequired, + onRequestClose: PropType.func.isRequired, + afterOpenModal: PropType.func, + // eslint-disable-next-line react/forbid-prop-types + overrideStyle: PropType.object, + children: PropType.oneOfType([ + PropType.arrayOf(PropType.node), + PropType.node + ]).isRequired +}; + +export default Modal; diff --git a/src/components/common/Navigation.jsx b/src/components/common/Navigation.jsx new file mode 100644 index 0000000..92f81b1 --- /dev/null +++ b/src/components/common/Navigation.jsx @@ -0,0 +1,138 @@ +/* eslint-disable indent */ +import { FilterOutlined, ShoppingOutlined } from '@ant-design/icons'; +import * as ROUTE from 'constants/routes'; +import logo from 'images/logo-full.png'; +import React, { useEffect, useRef } from 'react'; +import { useSelector } from 'react-redux'; +import { + Link, NavLink, useLocation +} from 'react-router-dom'; +import UserAvatar from 'views/account/components/UserAvatar'; +import BasketToggle from '../basket/BasketToggle'; +import Badge from './Badge'; +import FiltersToggle from './FiltersToggle'; +import MobileNavigation from './MobileNavigation'; +import SearchBar from './SearchBar'; + +const Navigation = () => { + const navbar = useRef(null); + const { pathname } = useLocation(); + + const store = useSelector((state) => ({ + basketLength: state.basket.length, + user: state.auth, + isAuthenticating: state.app.isAuthenticating, + isLoading: state.app.loading + })); + + const scrollHandler = () => { + if (navbar.current && window.screen.width > 480) { + if (window.pageYOffset >= 70) { + navbar.current.classList.add('is-nav-scrolled'); + } else { + navbar.current.classList.remove('is-nav-scrolled'); + } + } + }; + + useEffect(() => { + window.addEventListener('scroll', scrollHandler); + return () => window.removeEventListener('scroll', scrollHandler); + }, []); + + const onClickLink = (e) => { + if (store.isAuthenticating) e.preventDefault(); + }; + + // disable the basket toggle to these pathnames + const basketDisabledpathnames = [ + ROUTE.CHECKOUT_STEP_1, + ROUTE.CHECKOUT_STEP_2, + ROUTE.CHECKOUT_STEP_3, + ROUTE.SIGNIN, + ROUTE.SIGNUP, + ROUTE.FORGOT_PASSWORD + ]; + + if (store.user && store.user.role === 'ADMIN') { + return null; + } if (window.screen.width <= 800) { + return ( + + ); + } + return ( + + ); +}; + +export default Navigation; diff --git a/src/components/common/Preloader.jsx b/src/components/common/Preloader.jsx new file mode 100644 index 0000000..f288c0d --- /dev/null +++ b/src/components/common/Preloader.jsx @@ -0,0 +1,14 @@ +import React from 'react'; +import logoWordmark from '../../../static/logo-wordmark.png'; + +const Preloader = () => ( +
+ + + + + Salinaka logo wordmark +
+); + +export default Preloader; diff --git a/src/components/common/PriceRange/Handle.jsx b/src/components/common/PriceRange/Handle.jsx new file mode 100644 index 0000000..d9248da --- /dev/null +++ b/src/components/common/PriceRange/Handle.jsx @@ -0,0 +1,112 @@ +import PropType from 'prop-types'; +import React, { Component } from 'react'; + +class Handle extends Component { + constructor(props) { + super(props); + + this.state = { + mouseOver: false + }; + } + + + onMouseEnter() { + this.setState({ mouseOver: true }); + } + + onMouseLeave() { + this.setState({ mouseOver: false }); + } + + render() { + const { + domain: [min, max], + handle: { id, value, percent }, + isActive, + disabled, + getHandleProps + } = this.props; + const { mouseOver } = this.state; + + return ( + <> + {(mouseOver || isActive) && !disabled ? ( +
+
+ + Value: + {value} + +
+
+ ) : null} +
+
+ + ); + } +} + +Handle.propTypes = { + // eslint-disable-next-line react/forbid-prop-types + domain: PropType.array.isRequired, + handle: PropType.shape({ + id: PropType.string.isRequired, + value: PropType.number.isRequired, + percent: PropType.number.isRequired + }).isRequired, + getHandleProps: PropType.func.isRequired, + isActive: PropType.bool.isRequired, + disabled: PropType.bool +}; + +Handle.defaultProps = { + disabled: false +}; + +export default Handle; diff --git a/src/components/common/PriceRange/SliderRail.jsx b/src/components/common/PriceRange/SliderRail.jsx new file mode 100644 index 0000000..6be0e78 --- /dev/null +++ b/src/components/common/PriceRange/SliderRail.jsx @@ -0,0 +1,36 @@ +/* eslint-disable react/jsx-props-no-spreading */ +import PropType from 'prop-types'; +import React from 'react'; + +const railOuterStyle = { + position: 'absolute', + transform: 'translate(0%, -50%)', + width: '100%', + height: 42, + borderRadius: 7, + cursor: 'pointer' + // border: '1px solid grey', +}; + +const railInnerStyle = { + position: 'absolute', + width: '100%', + height: 14, + transform: 'translate(0%, -50%)', + borderRadius: 7, + pointerEvents: 'none', + backgroundColor: '#d0d0d0' +}; + +const SliderRail = ({ getRailProps }) => ( +
+
+
+
+); + +SliderRail.propTypes = { + getRailProps: PropType.func.isRequired +}; + +export default SliderRail; diff --git a/src/components/common/PriceRange/Tick.jsx b/src/components/common/PriceRange/Tick.jsx new file mode 100644 index 0000000..78836fc --- /dev/null +++ b/src/components/common/PriceRange/Tick.jsx @@ -0,0 +1,46 @@ +import PropType from 'prop-types'; +import React from 'react'; + +const Tick = ({ tick, count, format }) => ( +
+
+
+ {format(tick.value)} +
+
+); + +Tick.propTypes = { + tick: PropType.shape({ + id: PropType.string.isRequired, + value: PropType.number.isRequired, + percent: PropType.number.isRequired + }).isRequired, + count: PropType.number.isRequired, + format: PropType.func +}; + +Tick.defaultProps = { + format: (d) => d +}; + +export default Tick; diff --git a/src/components/common/PriceRange/TooltipRail.jsx b/src/components/common/PriceRange/TooltipRail.jsx new file mode 100644 index 0000000..8820553 --- /dev/null +++ b/src/components/common/PriceRange/TooltipRail.jsx @@ -0,0 +1,104 @@ +import PropType from 'prop-types'; +import React, { Component } from 'react'; + +const railStyle = { + position: 'absolute', + width: '100%', + transform: 'translate(0%, -50%)', + height: 20, + cursor: 'pointer', + zIndex: 300 + // border: '1px solid grey', +}; + +const railCenterStyle = { + position: 'absolute', + width: '100%', + transform: 'translate(0%, -50%)', + height: 14, + borderRadius: 7, + cursor: 'pointer', + pointerEvents: 'none', + backgroundColor: '#d0d0d0' +}; + +class TooltipRail extends Component { + constructor(props) { + super(props); + + this.state = { + value: null, + percent: null + }; + } + + onMouseEnter() { + document.addEventListener('mousemove', this.onMouseMove); + } + + onMouseLeave() { + this.setState({ value: null, percent: null }); + document.removeEventListener('mousemove', this.onMouseMove); + } + + onMouseMove(e) { + const { activeHandleID, getEventData } = this.props; + + if (activeHandleID) { + this.setState({ value: null, percent: null }); + } else { + this.setState(getEventData(e)); + } + } + + render() { + const { value, percent } = this.state; + const { activeHandleID, getRailProps } = this.props; + + return ( + <> + {!activeHandleID && value ? ( +
+
+ + Value: + {value} + +
+
+ ) : null} +
+
+ + ); + } +} + +TooltipRail.defaultProps = { + getEventData: undefined, + activeHandleID: undefined, + disabled: false +}; + +TooltipRail.propTypes = { + getEventData: PropType.func, + activeHandleID: PropType.string, + getRailProps: PropType.func.isRequired, + disabled: PropType.bool +}; + +export default TooltipRail; diff --git a/src/components/common/PriceRange/Track.jsx b/src/components/common/PriceRange/Track.jsx new file mode 100644 index 0000000..2e237e5 --- /dev/null +++ b/src/components/common/PriceRange/Track.jsx @@ -0,0 +1,44 @@ +import PropType from 'prop-types'; +import React from 'react'; + +const Track = ({ + source, target, getTrackProps, disabled +}) => ( +
+); + +Track.propTypes = { + source: PropType.shape({ + id: PropType.string.isRequired, + value: PropType.number.isRequired, + percent: PropType.number.isRequired + }).isRequired, + target: PropType.shape({ + id: PropType.string.isRequired, + value: PropType.number.isRequired, + percent: PropType.number.isRequired + }).isRequired, + getTrackProps: PropType.func.isRequired, + disabled: PropType.bool +}; + +Track.defaultProps = { + disabled: false +}; + + +export default Track; diff --git a/src/components/common/PriceRange/index.jsx b/src/components/common/PriceRange/index.jsx new file mode 100644 index 0000000..87d04b9 --- /dev/null +++ b/src/components/common/PriceRange/index.jsx @@ -0,0 +1,137 @@ +import PropType from 'prop-types'; +import React, { useState } from 'react'; +import { + Handles, Rail, Slider, Ticks, Tracks +} from 'react-compound-slider'; +import Handle from './Handle'; +import SliderRail from './SliderRail'; +import Tick from './Tick'; +import Track from './Track'; + +const sliderStyle = { + position: 'relative', + width: '100%' +}; + +const PriceRange = ({ + min, max, initMin, initMax, productsCount, onPriceChange +}) => { + const [state, setState] = useState({ + domain: [min, max], + values: [initMin || min, initMax || max], + update: [min, max].slice(), + inputMin: initMin || min, + inputMax: initMax || max, + inputError: false, + reversed: false + }); + + const onUpdate = (update) => { + setState(() => ({ + ...state, update, inputMin: update[0], inputMax: update[1] + })); + }; + + const onChange = (values) => { + setState(() => ({ + ...state, values, inputMin: values[0], inputMax: values[1] + })); + if (values[0] < values[1]) onPriceChange(...values); + }; + + + const inputClassName = () => (state.inputError ? 'price-range-input price-input-error' : 'price-range-input'); + + return ( +
+
+ + β€” + +
+ + + {({ getRailProps }) => } + + + {({ handles, activeHandleID, getHandleProps }) => ( +
+ {handles.map((handle) => ( + + ))} +
+ )} +
+ + {({ tracks, getTrackProps }) => ( +
+ {tracks.map(({ id, source, target }) => ( + + ))} +
+ )} +
+ + {({ ticks }) => ( +
+ {ticks.map((tick) => ( + + ))} +
+ )} +
+
+
+ ); +}; + +PriceRange.defaultProps = { + initMin: undefined, + initMax: undefined +}; + +PriceRange.propTypes = { + initMin: PropType.number, + initMax: PropType.number, + min: PropType.number.isRequired, + max: PropType.number.isRequired, + productsCount: PropType.number.isRequired, + onPriceChange: PropType.func.isRequired +}; + +export default PriceRange; diff --git a/src/components/common/SearchBar.jsx b/src/components/common/SearchBar.jsx new file mode 100644 index 0000000..796fa7e --- /dev/null +++ b/src/components/common/SearchBar.jsx @@ -0,0 +1,121 @@ +/* eslint-disable react/no-array-index-key */ +import { SearchOutlined } from '@ant-design/icons'; +import React, { useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useHistory } from 'react-router-dom'; +import { clearRecentSearch, removeSelectedRecent } from 'redux/actions/filterActions'; + +const SearchBar = () => { + const [searchInput, setSearchInput] = useState(''); + const { filter, isLoading } = useSelector((state) => ({ + filter: state.filter, + isLoading: state.app.loading + })); + const searchbarRef = useRef(null); + const history = useHistory(); + + const dispatch = useDispatch(); + const isMobile = window.screen.width <= 800; + + const onSearchChange = (e) => { + const val = e.target.value.trimStart(); + setSearchInput(val); + }; + + const onKeyUp = (e) => { + if (e.keyCode === 13) { + // dispatch(setTextFilter(searchInput)); + e.target.blur(); + searchbarRef.current.classList.remove('is-open-recent-search'); + + if (isMobile) { + history.push('/'); + } + + history.push(`/search/${searchInput.trim().toLowerCase()}`); + } + }; + + const recentSearchClickHandler = (e) => { + const searchBar = e.target.closest('.searchbar'); + + if (!searchBar) { + searchbarRef.current.classList.remove('is-open-recent-search'); + document.removeEventListener('click', recentSearchClickHandler); + } + }; + + const onFocusInput = (e) => { + e.target.select(); + + if (filter.recent.length !== 0) { + searchbarRef.current.classList.add('is-open-recent-search'); + document.addEventListener('click', recentSearchClickHandler); + } + }; + + const onClickRecentSearch = (keyword) => { + // dispatch(setTextFilter(keyword)); + searchbarRef.current.classList.remove('is-open-recent-search'); + history.push(`/search/${keyword.trim().toLowerCase()}`); + }; + + const onClearRecent = () => { + dispatch(clearRecentSearch()); + }; + + return ( + <> +
+ + + {filter.recent.length !== 0 && ( +
+
+
Recent Search
+
+ Clear +
+
+ {filter.recent.map((item, index) => ( +
+
onClickRecentSearch(item)} + role="presentation" + > + {item} +
+ dispatch(removeSelectedRecent(item))} + role="presentation" + > + X + +
+ ))} +
+ )} +
+ + ); +}; + +export default SearchBar; diff --git a/src/components/common/SocialLogin.jsx b/src/components/common/SocialLogin.jsx new file mode 100644 index 0000000..cffa554 --- /dev/null +++ b/src/components/common/SocialLogin.jsx @@ -0,0 +1,60 @@ +import { FacebookOutlined, GithubFilled, GoogleOutlined } from '@ant-design/icons'; +import PropType from 'prop-types'; +import React from 'react'; +import { useDispatch } from 'react-redux'; +import { signInWithFacebook, signInWithGithub, signInWithGoogle } from 'redux/actions/authActions'; + +const SocialLogin = ({ isLoading }) => { + const dispatch = useDispatch(); + + const onSignInWithGoogle = () => { + dispatch(signInWithGoogle()); + }; + + const onSignInWithFacebook = () => { + dispatch(signInWithFacebook()); + }; + + const onSignInWithGithub = () => { + dispatch(signInWithGithub()); + }; + + return ( +
+ + + +
+ ); +}; + +SocialLogin.propTypes = { + isLoading: PropType.bool.isRequired +}; + +export default SocialLogin; diff --git a/src/components/common/index.js b/src/components/common/index.js new file mode 100644 index 0000000..49da568 --- /dev/null +++ b/src/components/common/index.js @@ -0,0 +1,18 @@ +export { default as AdminNavigation } from './AdminNavigation'; +export { default as AdminSideBar } from './AdminSidePanel'; +export { default as Badge } from './Badge'; +export { default as Boundary } from './Boundary'; +export { default as ColorChooser } from './ColorChooser'; +export { default as Filters } from './Filters'; +export { default as FiltersToggle } from './FiltersToggle'; +export { default as Footer } from './Footer'; +export { default as ImageLoader } from './ImageLoader'; +export { default as MessageDisplay } from './MessageDisplay'; +export { default as MobileNavigation } from './MobileNavigation'; +export { default as Modal } from './Modal'; +export { default as Navigation } from './Navigation'; +export { default as Preloader } from './Preloader'; +export { default as PriceRange } from './PriceRange'; +export { default as SearchBar } from './SearchBar'; +export { default as SocialLogin } from './SocialLogin'; + diff --git a/src/components/formik/CustomColorInput.jsx b/src/components/formik/CustomColorInput.jsx new file mode 100644 index 0000000..f4a7b66 --- /dev/null +++ b/src/components/formik/CustomColorInput.jsx @@ -0,0 +1,87 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable react/forbid-prop-types */ +import PropType from 'prop-types'; +import React from 'react'; + +const InputColor = (props) => { + const { + name, form, push, remove + } = props; + const [selectedColor, setSelectedColor] = React.useState(''); + + const handleColorChange = (e) => { + const val = e.target.value; + setSelectedColor(val); + }; + + const handleAddSelectedColor = () => { + if (!form.values[name].includes(selectedColor)) { + push(selectedColor); + setSelectedColor(''); + } + }; + + return ( +
+
+
+ {form.touched[name] && form.errors[name] ? ( + {form.errors[name]} + ) : ( + + )} + {selectedColor && ( + <> +
+

+ + Add Selected Color +

+ + )} +
+ +
+
+ Selected Color(s) +
+ {form.values[name]?.map((color, index) => ( +
remove(index)} + className="color-item color-item-deletable" + title={`Remove ${color}`} + style={{ backgroundColor: color }} + role="presentation" + /> + ))} +
+
+
+ ); +}; + +InputColor.propTypes = { + name: PropType.string.isRequired, + form: PropType.shape({ + values: PropType.object, + touched: PropType.object, + errors: PropType.object + }).isRequired, + push: PropType.func.isRequired, + remove: PropType.func.isRequired +}; + +export default InputColor; diff --git a/src/components/formik/CustomCreatableSelect.jsx b/src/components/formik/CustomCreatableSelect.jsx new file mode 100644 index 0000000..77da22e --- /dev/null +++ b/src/components/formik/CustomCreatableSelect.jsx @@ -0,0 +1,88 @@ +/* eslint-disable react/forbid-prop-types */ +import { useField } from 'formik'; +import PropType from 'prop-types'; +import React from 'react'; +import CreatableSelect from 'react-select/creatable'; + +const CustomCreatableSelect = (props) => { + const [field, meta, helpers] = useField(props); + const { + options, defaultValue, label, placeholder, isMulti, type, iid + } = props; + const { touched, error } = meta; + const { setValue } = helpers; + + const handleChange = (newValue) => { + if (Array.isArray(newValue)) { + const arr = newValue.map((fieldKey) => fieldKey.value); + setValue(arr); + } else { + setValue(newValue.value); + } + }; + + const handleKeyDown = (e) => { + if (type === 'number') { + const { key } = e.nativeEvent; + if (/\D/.test(key) && key !== 'Backspace') { + e.preventDefault(); + } + } + }; + + return ( +
+ {touched && error ? ( + {error} + ) : ( + + )} + ({ + ...provided, + zIndex: 10 + }), + container: (provided) => ({ + ...provided, marginBottom: '1.2rem' + }), + control: (provided) => ({ + ...provided, + border: touched && error ? '1px solid red' : '1px solid #cacaca' + }) + }} + /> +
+ ); +}; + +CustomCreatableSelect.defaultProps = { + isMulti: false, + placeholder: '', + iid: '', + options: [], + type: 'string' +}; + +CustomCreatableSelect.propTypes = { + options: PropType.arrayOf(PropType.object), + defaultValue: PropType.oneOfType([ + PropType.object, + PropType.array + ]).isRequired, + label: PropType.string.isRequired, + placeholder: PropType.string, + isMulti: PropType.bool, + type: PropType.string, + iid: PropType.string +}; + +export default CustomCreatableSelect; diff --git a/src/components/formik/CustomInput.jsx b/src/components/formik/CustomInput.jsx new file mode 100644 index 0000000..27dfec0 --- /dev/null +++ b/src/components/formik/CustomInput.jsx @@ -0,0 +1,40 @@ +/* eslint-disable react/jsx-props-no-spreading */ +/* eslint-disable react/forbid-prop-types */ +import PropType from 'prop-types'; +import React from 'react'; + +const CustomInput = ({ + field, form: { touched, errors }, label, inputRef, ...props +}) => ( +
+ {touched[field.name] && errors[field.name] ? ( + {errors[field.name]} + ) : ( + + )} + +
+); + +CustomInput.defaultProps = { + inputRef: undefined +}; + +CustomInput.propTypes = { + label: PropType.string.isRequired, + field: PropType.object.isRequired, + form: PropType.object.isRequired, + inputRef: PropType.oneOfType([ + PropType.func, + PropType.shape({ current: PropType.instanceOf(Element) }) + ]) +}; + +export default CustomInput; diff --git a/src/components/formik/CustomMobileInput.jsx b/src/components/formik/CustomMobileInput.jsx new file mode 100644 index 0000000..e78a307 --- /dev/null +++ b/src/components/formik/CustomMobileInput.jsx @@ -0,0 +1,58 @@ +/* eslint-disable react/forbid-prop-types */ +import { useField } from 'formik'; +import PropType from 'prop-types'; +import React from 'react'; +import PhoneInput from 'react-phone-input-2'; + +const CustomMobileInput = (props) => { + const [field, meta, helpers] = useField(props); + const { label, placeholder, defaultValue } = props; + const { touched, error } = meta; + const { setValue } = helpers; + + const handleChange = (value, data) => { + const mob = { + dialCode: data.dialCode, + countryCode: data.countryCode, + country: data.name, + value + }; + + setValue(mob); + }; + + return ( +
+ {touched && error ? ( + {error?.value || error?.dialCode} + ) : ( + + )} + +
+ ); +}; + +CustomMobileInput.defaultProps = { + label: 'Mobile Number', + placeholder: '09254461351' +}; + +CustomMobileInput.propTypes = { + label: PropType.string, + placeholder: PropType.string, + defaultValue: PropType.object.isRequired +}; + +export default CustomMobileInput; diff --git a/src/components/formik/CustomTextarea.jsx b/src/components/formik/CustomTextarea.jsx new file mode 100644 index 0000000..6225568 --- /dev/null +++ b/src/components/formik/CustomTextarea.jsx @@ -0,0 +1,33 @@ +/* eslint-disable react/jsx-props-no-spreading */ +/* eslint-disable react/forbid-prop-types */ +import PropType from 'prop-types'; +import React from 'react'; + +const CustomTextarea = ({ + field, form: { touched, errors }, label, ...props +}) => ( +
+ {touched[field.name] && errors[field.name] ? ( + {errors[field.name]} + ) : ( + + )} +