diff --git a/.gitignore b/.gitignore index 29dadaf..5d5730a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ **/node_modules **/.env **/.husky -**/.eslintcache \ No newline at end of file +**/.eslintcache +**/dist \ No newline at end of file diff --git a/README.md b/README.md index cad76f6..48af3df 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,17 @@ Back-end repositorie is available [here](https://github.com/SilviaPabon/buenavida-backend). +## Requirements + +1. Running golang API (Back-end repository) +2. `.env` file created at `src/` with the following variables: + +``` +VITE_API_HOST=yourhost +``` + +If you are running the project in local environment, then, `VITE_API_HOST`is `localhost:3030` + ## Avilable node scripts Run in development / watch mode diff --git a/index.html b/index.html index e0d1c84..f5f1d40 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - Vite + React + TS + Buenavida
diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 0000000..ca22269 --- /dev/null +++ b/netlify.toml @@ -0,0 +1,24 @@ +[[redirects]] + from = "/" + to = "/index.html" + status = 200 + +[[redirects]] + from = "/login" + to = "/index.html" + status = 200 + +[[redirects]] + from = "/signup" + to = "/index.html" + status = 200 + +[[redirects]] + from = "/*" + to = "/index.html" + status = 404 + +[[redirects]] + from = "/cart" + to = "/index.html" + status = 200 diff --git a/package-lock.json b/package-lock.json index 2488c19..e84f4fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,13 @@ "name": "buenavida-frontend", "version": "0.0.0", "dependencies": { + "axios": "^1.1.3", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-router-dom": "6.4.2" + "react-icons": "4.6.0", + "react-paginate": "^8.1.3", + "react-router-dom": "6.4.2", + "react-toastify": "^9.1.1" }, "devDependencies": { "@types/react": "^18.0.17", @@ -1091,6 +1095,21 @@ "node": ">=8" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "node_modules/axios": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.1.3.tgz", + "integrity": "sha512-00tXVRwKx/FZr/IDVFt4C+f9FYairX517WoGCL6dpOntqLkZofjhu43F/Xl44UOpqa+9sLFDrG/XAnFsUYgkDA==", + "dependencies": { + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1236,6 +1255,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -1257,6 +1284,17 @@ "integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==", "dev": true }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "9.4.1", "resolved": "https://registry.npmjs.org/commander/-/commander-9.4.1.tgz", @@ -1337,6 +1375,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -2307,6 +2353,38 @@ "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", "dev": true }, + "node_modules/follow-redirects": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", + "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -3315,6 +3393,25 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-fn": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", @@ -3409,7 +3506,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -3733,13 +3829,17 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "node_modules/punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", @@ -3792,11 +3892,29 @@ "react": "^18.2.0" } }, + "node_modules/react-icons": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.6.0.tgz", + "integrity": "sha512-rR/L9m9340yO8yv1QT1QurxWQvWpbNHqVX0fzMln2HEb9TEIrQRGsqiNFQfiv9/JEUbyHmHPlNTB2LWm2Ttz0g==", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/react-paginate": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/react-paginate/-/react-paginate-8.1.3.tgz", + "integrity": "sha512-zBp80DBRcaeBnAeHUfbGKD0XHfbGNUolQ+S60Ymfs8o7rusYaJYZMAt1j93ADDNLlzRmJ0tMF/NeTlcdKf7dlQ==", + "dependencies": { + "prop-types": "^15.6.1" + }, + "peerDependencies": { + "react": "^16 || ^17 || ^18" + } }, "node_modules/react-refresh": { "version": "0.14.0", @@ -3837,6 +3955,18 @@ "react-dom": ">=16.8" } }, + "node_modules/react-toastify": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-9.1.1.tgz", + "integrity": "sha512-pkFCla1z3ve045qvjEmn2xOJOy4ZciwRXm1oMPULVkELi5aJdHCN/FHnuqXq8IwGDLB7PPk2/J6uP9D8ejuiRw==", + "dependencies": { + "clsx": "^1.1.1" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/regexp.prototype.flags": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz", @@ -5368,6 +5498,21 @@ "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", "dev": true }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "axios": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.1.3.tgz", + "integrity": "sha512-00tXVRwKx/FZr/IDVFt4C+f9FYairX517WoGCL6dpOntqLkZofjhu43F/Xl44UOpqa+9sLFDrG/XAnFsUYgkDA==", + "requires": { + "follow-redirects": "^1.15.0", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -5463,6 +5608,11 @@ "string-width": "^5.0.0" } }, + "clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==" + }, "color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -5484,6 +5634,14 @@ "integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==", "dev": true }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "requires": { + "delayed-stream": "~1.0.0" + } + }, "commander": { "version": "9.4.1", "resolved": "https://registry.npmjs.org/commander/-/commander-9.4.1.tgz", @@ -5544,6 +5702,11 @@ "object-keys": "^1.1.1" } }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" + }, "dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -6182,6 +6345,21 @@ "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", "dev": true }, + "follow-redirects": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", + "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==" + }, + "form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -6885,6 +7063,19 @@ "picomatch": "^2.3.1" } }, + "mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + }, + "mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "requires": { + "mime-db": "1.52.0" + } + }, "mimic-fn": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", @@ -6950,8 +7141,7 @@ "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" }, "object-inspect": { "version": "1.12.2", @@ -7163,13 +7353,17 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "requires": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, + "proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", @@ -7199,11 +7393,24 @@ "scheduler": "^0.23.0" } }, + "react-icons": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.6.0.tgz", + "integrity": "sha512-rR/L9m9340yO8yv1QT1QurxWQvWpbNHqVX0fzMln2HEb9TEIrQRGsqiNFQfiv9/JEUbyHmHPlNTB2LWm2Ttz0g==", + "requires": {} + }, "react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "react-paginate": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/react-paginate/-/react-paginate-8.1.3.tgz", + "integrity": "sha512-zBp80DBRcaeBnAeHUfbGKD0XHfbGNUolQ+S60Ymfs8o7rusYaJYZMAt1j93ADDNLlzRmJ0tMF/NeTlcdKf7dlQ==", + "requires": { + "prop-types": "^15.6.1" + } }, "react-refresh": { "version": "0.14.0", @@ -7228,6 +7435,14 @@ "react-router": "6.4.2" } }, + "react-toastify": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-9.1.1.tgz", + "integrity": "sha512-pkFCla1z3ve045qvjEmn2xOJOy4ZciwRXm1oMPULVkELi5aJdHCN/FHnuqXq8IwGDLB7PPk2/J6uP9D8ejuiRw==", + "requires": { + "clsx": "^1.1.1" + } + }, "regexp.prototype.flags": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz", diff --git a/package.json b/package.json index fe0c947..7152b78 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,13 @@ "lint": "npx eslint ." }, "dependencies": { + "axios": "^1.1.3", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-router-dom": "6.4.2" + "react-icons": "4.6.0", + "react-paginate": "^8.1.3", + "react-router-dom": "6.4.2", + "react-toastify": "^9.1.1" }, "devDependencies": { "@types/react": "^18.0.17", @@ -37,9 +41,10 @@ } }, "lint-staged": { - "*.{tsx,jsx,ts,js,css}": [ + "*.{tsx,jsx,ts,js}": [ "eslint --cache --fix", "prettier --write" - ] + ], + "*.css": "prettier --write" } } diff --git a/public/fonts/Inter.ttf b/public/fonts/Inter.ttf new file mode 100644 index 0000000..ec3164e Binary files /dev/null and b/public/fonts/Inter.ttf differ diff --git a/public/fonts/Inter.woff b/public/fonts/Inter.woff new file mode 100644 index 0000000..37ae329 Binary files /dev/null and b/public/fonts/Inter.woff differ diff --git a/public/fonts/Inter.woff2 b/public/fonts/Inter.woff2 new file mode 100644 index 0000000..becbf35 Binary files /dev/null and b/public/fonts/Inter.woff2 differ diff --git a/public/images/logo.jpg b/public/images/logo.jpg new file mode 100644 index 0000000..0080afd Binary files /dev/null and b/public/images/logo.jpg differ diff --git a/src/components/CartDialog/CartDialog.module.css b/src/components/CartDialog/CartDialog.module.css new file mode 100644 index 0000000..686e10a --- /dev/null +++ b/src/components/CartDialog/CartDialog.module.css @@ -0,0 +1,75 @@ +.dialog { + z-index: 10; + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + + width: clamp(240px, 40vw, 440px); + max-height: 80vh; + overflow-y: auto; + + padding: 24px; + background-color: white; + border-radius: 12px; + box-shadow: 4px 0px 16px var(--box-shadows); +} + +.dialog__header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.dialog__title { + color: var(--accented-green); + font-size: var(--lg); + text-transform: uppercase; +} + +.dialog__body { + display: flex; + flex-direction: column; + gap: 4px; +} + +.dialog__subtotal, +.dialog__total { + display: flex; + justify-content: space-between; + margin-block: 12px; +} + +.dialog__subtotal { + font-size: var(--sm); + color: var(--texts-light); +} + +.dialog__total { + font-size: var(--md); + font-weight: bolder; +} + +.dialog__total .rowText { + color: var(--accented-green); +} + +.dialog__button { + text-decoration: none; + text-align: center; + display: block; + width: 100%; + margin-block: 16px 12px; + font-size: var(--sm); + background-color: var(--accented-green); + padding: 0.8em; + outline: none; + color: white; + border: 1px solid transparent; + border-radius: 12px; +} + +.dialog__close, +.dialog__button { + cursor: pointer; +} diff --git a/src/components/CartDialog/CartDialog.tsx b/src/components/CartDialog/CartDialog.tsx new file mode 100644 index 0000000..9702a17 --- /dev/null +++ b/src/components/CartDialog/CartDialog.tsx @@ -0,0 +1,108 @@ +import Styles from './CartDialog.module.css'; +import { useContext } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { SessionContext } from '../../context/SessionContext'; +import { FiX } from 'react-icons/fi'; +import { CartDialogRow } from './CartDialogRow/CartDialogRow'; +import { toast } from 'react-toastify'; + +interface IProps { + closeCallback: () => void; +} + +export function CartDialog(props: IProps) { + const { isLoggedIn, cart, makeOrder } = useContext(SessionContext); + const navigate = useNavigate(); + + const GetCartTotal = () => { + const price = cart.reduce((acc, curr) => { + return acc + curr.price * curr.quantity; + }, 0); + + return price.toFixed(2); + }; + + return ( +
+
+

Mi carrito

+ props.closeCallback()} + /> +
+
+ {cart.map((item, index) => { + return ; + })} +
+ {/*
+

Subtotal

+

38,40

+
+ */} +
+

Total

+

{GetCartTotal()}

+
+ + Ir al carrito + + +
+ ); +} diff --git a/src/components/CartDialog/CartDialogRow/CartDialogRow.module.css b/src/components/CartDialog/CartDialogRow/CartDialogRow.module.css new file mode 100644 index 0000000..4a1ca62 --- /dev/null +++ b/src/components/CartDialog/CartDialogRow/CartDialogRow.module.css @@ -0,0 +1,51 @@ +.cartItem { + display: flex; + border-bottom: 1px solid var(--box-shadows); +} + +.cartItem__image { + width: 20%; + min-width: 120px; + object-fit: contain; +} + +.cartItem__title { + font-size: var(--md); + font-weight: 300; + color: var(--texts); +} + +.cartItem__content { + display: flex; + flex-direction: column; + justify-content: center; + gap: 8px; + padding: 0px 12px; +} + +.cartItem__footer { + display: flex; + align-items: center; + justify-content: space-between; +} + +.cartItem__price { + font-size: var(--md); +} + +.cartItem__amount, +.cartItem__inputGroup label { + font-weight: bolder; + display: inline; + margin-inline-end: 8px; + font-size: var(--sm); + color: var(--texts-light); +} + +.cartItem__inputGroup input { + width: 50%; + border: 1px solid var(--box-shadows); + padding: 8px; + border-radius: 4px; + outline: none; +} diff --git a/src/components/CartDialog/CartDialogRow/CartDialogRow.tsx b/src/components/CartDialog/CartDialogRow/CartDialogRow.tsx new file mode 100644 index 0000000..908d222 --- /dev/null +++ b/src/components/CartDialog/CartDialogRow/CartDialogRow.tsx @@ -0,0 +1,47 @@ +import { useContext } from 'react'; +import { SessionContext } from '../../../context/SessionContext'; +import Styles from './CartDialogRow.module.css'; + +interface IProduct { + id: string; + image: string; + name: string; + units: string; + quantity: number; + price: number; +} + +interface Iprops { + product: IProduct; +} + +export function CartDialogRow(props: Iprops) { + const { updateCart } = useContext(SessionContext); + + return ( +
+ {props.product.name} +
+

{props.product.name}

+ {props.product.units} +
+
+ + { + // Update value on session + const value = e.target.value; + updateCart({ id: props.product.id, amount: value }); + }} + > +
+ {props.product.price} +
+
+
+ ); +} diff --git a/src/components/dynamicForm/DynamicForm.module.css b/src/components/dynamicForm/DynamicForm.module.css new file mode 100644 index 0000000..b4b1f66 --- /dev/null +++ b/src/components/dynamicForm/DynamicForm.module.css @@ -0,0 +1,62 @@ +.form { + width: clamp(240px, 80vw, 480px); + padding: 8px 16px; + margin: 16px auto; + border: 1px solid var(--box-shadows); + box-shadow: 4px 0px 16px var(--box-shadows); +} + +.form__title { + text-transform: uppercase; + font-weight: bolder; + text-align: center; + margin-block: 16px; + font-size: var(--lg); +} + +.form__group { + display: block; + margin-block: 8px 16px; +} + +.form__label { + display: block; + margin-block-end: 12px; + font-size: var(--sm); +} + +.form__input, +.form__submit { + outline: none; + border: 1px solid transparent; +} + +.form__input { + width: 100%; + padding: 8px; + border: 1px solid var(--box-shadows); + background-color: var(--inputs-bg); +} + +.form__error { + display: none; + margin-block: 8px; + list-style: none; + font-size: var(--sm); + color: var(--accented-red); +} + +.form__error__active { + display: block; +} + +.form__submit { + cursor: pointer; + display: block; + margin-block: 12px; + width: 100%; + padding: 0.6em; + color: white; + background-color: var(--accented-green); + font-size: var(--sm); +} diff --git a/src/components/dynamicForm/DynamicForm.tsx b/src/components/dynamicForm/DynamicForm.tsx new file mode 100644 index 0000000..32b3954 --- /dev/null +++ b/src/components/dynamicForm/DynamicForm.tsx @@ -0,0 +1,156 @@ +import { useState, ChangeEvent } from 'react'; +import Styles from './DynamicForm.module.css'; +import { toast } from 'react-toastify'; + +// Props interfaces +interface Field { + label: string; + name: string; + type: string; + placeholder: string; + minlength?: number; +} + +interface Rule { + name: string; + regexp: RegExp; + message: string; + done: boolean; +} + +interface Props { + title: string; + fields: Array; + submitLabel: string; + // Callback to handle input submit + // eslint-disable-next-line + callback: (payload: any) => Promise; + // Optional field to validate with regular expressions + rules?: Array; +} + +type TValues = { + [key: string]: string; +}; + +export function DynamicForm(props: Props) { + // Store form current values + const init: TValues = {}; + const [values, setValues] = useState(init); + + // Update form values state when some input change + const handleInputChange = (event: ChangeEvent) => { + // Update value on state + const key = event.target.name; + const value = event.target.value; + setValues({ ...values, [key]: value }); + + // Return if no validation rules were given + if (!props.rules) return; + + // Get the field rule + const rule: Rule = props.rules.filter((rule) => rule.name === key)[0]; + // Return if the field has no rule to validate it + if (!rule) return; + + const ruleIndex: number = props.rules.indexOf(rule); + const correct = rule.regexp.test(value); + + if (!correct) { + const err: HTMLElement | null = document.getElementById(`error-${key}`); + if (err != null) { + err.textContent = rule.message; + err.classList.add(`${Styles.form__error__active}`); + + // Update the done field on the rule to false + props.rules[ruleIndex].done = false; + } + } else { + const err: HTMLElement | null = document.getElementById(`error-${key}`); + if (err != null) err.classList.remove(`${Styles.form__error__active}`); + + // Update the done field on the rule to false + props.rules[ruleIndex].done = true; + } + }; + + // Verify all the fields with the regular expressions + // and use the callback + const handleSubmit = () => { + let allFieldsOk = true; + const isSignup = props.fields.some((field) => field.name === 'password2'); + + if (isSignup) { + if (values['password'] != values['password2']) { + toast.error('Passords are not equals', { + position: 'top-right', + autoClose: 2500, + pauseOnHover: true, + theme: 'light', + }); + + return; + } + } + + if (props.rules) { + props.rules.forEach((rule) => { + if (!rule.done) { + allFieldsOk = false; + return; // Breaks the foreach + } + }); + + if (!allFieldsOk) { + // Show an information alert + toast.warn('Please, fill all the fields correctly before sending again.', { + position: 'top-right', + autoClose: 2500, + pauseOnHover: true, + theme: 'light', + }); + } else { + props.callback(values); + } + } else { + props.callback(values); + } + }; + + // Generate fields + const generateFields = props.fields.map((field, index) => { + return ( +
+ + +

+
+ ); + }); + + return ( +
{ + e.preventDefault(); + handleSubmit(); + }} + > +

{props.title}

+ {generateFields} + +
+ ); +} diff --git a/src/components/modalproducts/ModalProducts.tsx b/src/components/modalproducts/ModalProducts.tsx new file mode 100644 index 0000000..7c15fb9 --- /dev/null +++ b/src/components/modalproducts/ModalProducts.tsx @@ -0,0 +1,52 @@ +import Styles from './modalproducts.module.css'; +import { Iproduct } from '../../interfaces/interfaces'; +import { FiShoppingCart, FiHeart, FiXCircle } from 'react-icons/fi'; +//import { useNavigate } from 'react-router-dom'; + +interface props { + product: Iproduct; + // eslint-disable-next-line no-unused-vars + CerrarCallBack: (product: Iproduct) => void; +} + +export function ModalProduct(props: props) { + return ( +
+
+ props.CerrarCallBack(props.product)} + className={Styles.modal_btn_cerrar} + color={'#21a746'} + size={'1.4em'} + /> + + + +
+
+

{props.product.name}

+

{props.product.units}

+

{props.product.price} €

+

{props.product.description}

+ +
+ + {/* + */} +
+
+
+ ); +} diff --git a/src/components/modalproducts/modalproducts.module.css b/src/components/modalproducts/modalproducts.module.css new file mode 100644 index 0000000..745331d --- /dev/null +++ b/src/components/modalproducts/modalproducts.module.css @@ -0,0 +1,86 @@ +.modal_product { + display: grid; + position: fixed; + top: 50%; + left: 50%; + width: clamp(240px, 60vw, 500px); + transform: translate(-50%, -50%); + background-color: white; + box-shadow: 0px 4px 20px var(--box-shadows); + padding: 32px; + border-radius: 12px; + max-height: 80vh; + overflow-y: auto; + z-index: 10; +} +.corazon { + cursor: pointer; + position: absolute; + top: 16px; + right: 54px; +} + +.modal_btn_cerrar { + cursor: pointer; + position: absolute; + top: 16px; + right: 16px; + border: none; +} + +@media screen and (max-width: 768px) { + .imagen { + width: 40%; + margin: 0px auto; + } +} + +.imagen { + width: 100%; + max-height: 250px; + object-fit: contain; +} + +.titulo { + font-size: var(--lg); + color: #000000; + margin-block: 8px; +} + +.unidades { + font-size: var(--sm); + font-weight: bolder; + color: var(--texts-light); + margin-block: 24px; + margin-block: 8px; +} + +.price { + font-size: var(--lg); + font-weight: bolder; + color: var(--accented-green); + margin-block: 8px; +} + +.description { + color: var(--texts); + line-height: 1.4; + margin-block: 32px; + font-weight: 200px; +} + +.boton { + display: flex; + gap: 12px; + cursor: pointer; + border: 1px solid transparent; + border-radius: 10px; + padding: 0.7em 1em; + font-size: var(--sm); + font-weight: bold; + min-width: max-content; + text-align: center; + border: none; + background-color: var(--accented-green); + color: white; +} diff --git a/src/components/navbar/Navbar.module.css b/src/components/navbar/Navbar.module.css new file mode 100644 index 0000000..0704d32 --- /dev/null +++ b/src/components/navbar/Navbar.module.css @@ -0,0 +1,144 @@ +.navbarBlock { + display: flex; + align-items: center; + padding: 16px 8px; + /* margin-block-end: 24px; */ + width: 100%; + height: 72px; + background-color: white; + box-shadow: 4px 0px 16px var(--box-shadows); +} + +.navbarContainer { + display: flex; + gap: 16px; + align-items: center; +} + +.navbar__brand img { + max-width: 140px; +} + +.navbar__right { + display: flex; + justify-content: space-between; + align-items: center; + gap: 24px; + flex-grow: 1; +} + +.navbar__search { + cursor: pointer; +} + +/* Search bar */ +.navbar__inputContainer { + position: relative; + flex-grow: 1; +} + +.navbar__inputContainer input { + padding: 12px 16px; + border: 1px solid var(--accented-green); + border-radius: 12px; + outline: none; + font-size: var(--sm); + width: 100%; +} + +#searchIcon { + position: absolute; + /*Center vertically*/ + top: 50%; + transform: translateY(-50%); + right: 16px; +} + +/* Navigation options */ +.navigation { + display: flex; + gap: 32px; + list-style: none; +} + +.navigation__item { + position: relative; + display: flex; + flex-direction: column; + gap: 4px; + align-items: center; + color: var(--accented-green); + text-decoration: none; +} + +.navigation__float { + z-index: 2; + position: absolute; + right: 0; + top: 100%; + + visibility: hidden; + opacity: 0; + display: flex; + flex-direction: column; + + width: max-content; + min-width: 240px; + + list-style: none; + background-color: white; + color: #2f2f2f; + box-shadow: 0px 4px 16px var(--box-shadows); + + transition: opacity 0.3s ease-in-out; +} + +.navigation__floatVisible { + visibility: visible; + opacity: 1; +} + +.navigation__floatItem { + display: flex; + gap: 12px; + + text-decoration: none; + font-weight: bold; + color: #2f2f2f; + padding: 16px; +} + +.navigation__floatItem { + border-bottom: 1px solid var(--box-shadows); +} + +/* Responsive */ +@media screen and (max-width: 768px) { + .navbarBlock { + height: max-content; + padding: 16px 24px; + } + + .navbarContainer { + flex-direction: column; + } + + .navbar__right { + width: 100%; + } +} + +@media screen and (max-width: 576px) { + .navbar__right { + flex-direction: column; + } + + .navbar__inputContainer { + width: 100%; + } + + .navigation { + width: 100%; + justify-content: space-between; + } +} diff --git a/src/components/navbar/Navbar.tsx b/src/components/navbar/Navbar.tsx new file mode 100644 index 0000000..6ae64df --- /dev/null +++ b/src/components/navbar/Navbar.tsx @@ -0,0 +1,177 @@ +import Styles from './Navbar.module.css'; +import { useContext, ChangeEvent, useRef, useState } from 'react'; +import { NavLink, Link, useNavigate } from 'react-router-dom'; +import { SessionContext } from '../../context/SessionContext'; +import { + FiHeart, + FiUser, + FiShoppingCart, + FiSearch, + FiLock, + FiUserCheck, + FiUserX, +} from 'react-icons/fi'; +import { FilterContext } from '../../context/FilterContext'; +import { CartDialog } from '../CartDialog/CartDialog'; + +export function Navbar() { + // Fucntion from the provider + const { setCriteria, filterProducts } = useContext(FilterContext); + const [openCartDialog, setOpenCartDialog] = useState(false); + const { isLoggedIn, logout } = useContext(SessionContext); + + const floatingOptions = useRef(null); + const navigate = useNavigate(); + + // Update provider's criteria + const handleInputChange = (e: ChangeEvent) => { + const value = e.target.value; + setCriteria(value); + }; + + // Const handle dropdown click + const handleDropDownClick = () => { + if (!floatingOptions) return; + if (!floatingOptions.current) return; + + floatingOptions.current.classList.toggle(`${Styles.navigation__floatVisible}`); + }; + + // Float options for logged in user + const LoggedInOptions = () => { + return ( + <> +
  • + + + Mi Cuenta + +
  • +
  • + + + Mis favoritos + +
  • +
  • + + + Mi Carrito + +
  • +
  • +
    { + logout(); + }} + > + + Cerrar sesión +
    +
  • + + ); + }; + + // Float options for not logged in user + const NotLoggedInOptions = () => { + return ( + <> +
  • + + + Entrar + +
  • +
  • + + + Crear cuenta + +
  • + + ); + }; + + return ( + <> + + {openCartDialog ? ( + { + setOpenCartDialog(false); + }} + /> + ) : ( + '' + )} + + ); +} diff --git a/src/components/productCard/ProductCard.module.css b/src/components/productCard/ProductCard.module.css new file mode 100644 index 0000000..a2c914b --- /dev/null +++ b/src/components/productCard/ProductCard.module.css @@ -0,0 +1,95 @@ +.product { + position: relative; + width: 320px; + border-radius: 4px; + padding: 4px 8px; + overflow: hidden; + border: 1px solid var(--box-shadows); + + transition: all 0.3s ease; +} + +.product__heart { + position: absolute; + top: 12px; + right: 12px; +} + +.product__image { + width: 100%; + object-fit: contain; +} + +.product__name { + font-size: var(--lg); + font-weight: bold; + text-align: center; + margin-block-end: 24px; + + /*Max text width*/ + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.product__units, +.product__price { + display: block; + text-align: center; + margin-block: 16px; +} + +.product__units { + font-size: var(--sm); + color: var(--texts); +} + +.product__price { + font-size: var(--md); + font-weight: bold; + color: var(--accented-green); +} + +.product__button { + /*Center text and icon*/ + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + opacity: 0; + + margin-block: 12px; + padding: 0.8em; + outline: none; + border: 1px solid transparent; + border-radius: 4px; + font-size: var(--sm); + + cursor: pointer; + background-color: var(--accented-yellow); + color: white; + + transition: opacity 0.3s ease; +} + +/*Hover effects*/ +.product:hover { + scale: 1.02; + box-shadow: 0px 4px 16px var(--box-shadows); +} + +.product:hover .product__name { + white-space: normal; +} + +.product:hover .product__button { + opacity: 1; +} + +/* Responsive */ +@media screen and (max-width: 768px) { + .product { + width: 240px; + } +} diff --git a/src/components/productCard/ProductCard.tsx b/src/components/productCard/ProductCard.tsx new file mode 100644 index 0000000..fa67622 --- /dev/null +++ b/src/components/productCard/ProductCard.tsx @@ -0,0 +1,119 @@ +import Styles from './ProductCard.module.css'; +import { Iproduct, ICartItem } from '../../interfaces/interfaces'; +import { FiShoppingCart, FiHeart } from 'react-icons/fi'; +import { FaHeart } from 'react-icons/fa'; +import { useContext } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { SessionContext } from '../../context/SessionContext'; +import { toast } from 'react-toastify'; + +interface props { + product: Iproduct; + // eslint-disable-next-line no-unused-vars + CallBack: (product: Iproduct) => void; +} + +// producto: producto +// dialogcallback: funcion para abrir el modal +export function ProductCard(props: props) { + const { isLoggedIn, addToCart, favorites, addToFavorites, removeFromFavorites } = + useContext(SessionContext); + + const navigate = useNavigate(); + + const HandleAddToCart = async () => { + const CartItem: ICartItem = { + id: props.product.id, + name: props.product.name, + units: props.product.units, + quantity: 0, + price: props.product.price, + image: props.product.image, + }; + + const wasAdded = await addToCart(CartItem); + + if (wasAdded) { + toast.success(`Successfully added ${props.product.name} to the cart`, { + position: 'top-right', + autoClose: 2500, + pauseOnHover: true, + theme: 'light', + }); + } else { + toast.error(`Unable to add ${props.product.name} to the cart. Try again.`, { + position: 'top-right', + autoClose: 2500, + pauseOnHover: true, + theme: 'light', + }); + } + }; + + return ( +
    + {favorites.some((id) => id === props.product.id) ? ( + { + removeFromFavorites(props.product.id); + }} + /> + ) : ( + { + if (isLoggedIn) { + addToFavorites(props.product.id); + } else { + navigate('/login'); + + toast.warn('Log in to manage your favorites', { + position: 'top-right', + autoClose: 2500, + pauseOnHover: true, + theme: 'light', + }); + } + }} + /> + )} + { + props.CallBack(props.product); + }} + className={Styles.product__image} + src={props.product.image} + alt={props.product.name} + /> +

    {props.product.name}

    + {props.product.units !== '' && ( + {props.product.units} + )} + {props.product.price + '€'} + +
    + ); +} diff --git a/src/components/slider/Slider.module.css b/src/components/slider/Slider.module.css new file mode 100644 index 0000000..f6d7a6a --- /dev/null +++ b/src/components/slider/Slider.module.css @@ -0,0 +1,52 @@ +.filterContainer { + display: flex; + flex-direction: column; + align-items: center; + margin: auto; + padding: 10px 32px; + justify-content: center; +} + +.slider { + width: 100%; +} + +.rangeContainer { + width: 100%; +} + +.inputPrices { + display: flex; + gap: 16px; + justify-content: space-between; +} + +.inputPrices input { + margin-block: 12px; + padding: 0.8em; + width: 100%; +} + +.inputPrices div { + display: flex; + width: 50%; + flex-direction: column; +} + +.filterButton { + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + + margin-block: 12px; + padding: 0.8em; + outline: none; + border: 1px solid transparent; + border-radius: 4px; + font-size: var(--sm); + + cursor: pointer; + background-color: var(--accented-green); + color: white; +} diff --git a/src/components/slider/Slider.tsx b/src/components/slider/Slider.tsx new file mode 100644 index 0000000..cda7632 --- /dev/null +++ b/src/components/slider/Slider.tsx @@ -0,0 +1,140 @@ +import Styles from './Slider.module.css'; +/* import { getProductsFiltrated } from '../../services/products.service'; + */ +import { FilterContext } from '../../context/FilterContext'; +import { useContext, useRef } from 'react'; + +export function Slider() { + // Value from the provider + const { min, max, filterProducts, setMax, setMin } = useContext(FilterContext); + + const rangeInput = useRef(null); + const firstNumInput = useRef(null); + const secndNumInput = useRef(null); + + let timeout = -1; + let timeout_2 = -1; + + //adding event to range input + const onRangeListener = () => { + if (!firstNumInput.current || !secndNumInput.current || !rangeInput.current) return; + + const minVal = rangeInput.current.value; + + //if value from inputdesde is >= to inputhasta + if (parseFloat(rangeInput.current.value) >= parseFloat(secndNumInput.current.value)) { + clearTimeout(timeout); + timeout = setTimeout(function () { + if (!firstNumInput.current || !secndNumInput.current || !rangeInput.current) return; + + firstNumInput.current.value = `${parseFloat(secndNumInput.current.value) - 1}`; + rangeInput.current.value = `${parseFloat(secndNumInput.current.value) - 1}`; + setMin(parseFloat(firstNumInput.current.value)); + setMax(parseFloat(secndNumInput.current.value)); + }, 820); + } else { + firstNumInput.current.value = minVal; + setMin(parseFloat(firstNumInput.current.value)); + setMax(parseFloat(secndNumInput.current.value)); + } + }; + + //adding event to inputs desde and hasta + const onInputListener = () => { + // string + if (!firstNumInput.current || !secndNumInput.current || !rangeInput.current) return; + const minVal = parseFloat(firstNumInput.current.value); + + clearTimeout(timeout_2); + if ( + parseFloat(firstNumInput.current.value) >= parseFloat(secndNumInput.current.value) && + parseFloat(secndNumInput.current.value) != 0 + ) { + timeout_2 = setTimeout(function () { + if (!firstNumInput.current || !secndNumInput.current || !rangeInput.current) return; + rangeInput.current.value = `${parseFloat(secndNumInput.current.value) - 1}`; + firstNumInput.current.value = `${parseFloat(secndNumInput.current.value) - 1}`; + setMin(parseFloat(firstNumInput.current.value)); + setMax(parseFloat(secndNumInput.current.value)); + }, 820); + } else { + rangeInput.current.value = `${minVal}`; + setMin(parseFloat(rangeInput.current.value)); + setMax(parseFloat(secndNumInput.current.value)); + } + + if (parseFloat(secndNumInput.current.value) > 81.8) { + secndNumInput.current.value = `${81.8}`; + setMax(parseFloat(secndNumInput.current.value)); + } + + if (parseFloat(firstNumInput.current.value) < 0) { + firstNumInput.current.value = `${0}`; + setMin(parseFloat(firstNumInput.current.value)); + } + }; + + return ( +
    +
    { + e.preventDefault(); + filterProducts(); + }} + > +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + +
    +
    + ); +} diff --git a/src/config/config.ts b/src/config/config.ts new file mode 100644 index 0000000..61442dc --- /dev/null +++ b/src/config/config.ts @@ -0,0 +1,3 @@ +export const GLOBALS = { + API_HOST: import.meta.env.VITE_API_HOST || 'http://localhost:3030', +}; diff --git a/src/context/FilterContext.tsx b/src/context/FilterContext.tsx new file mode 100644 index 0000000..77841cb --- /dev/null +++ b/src/context/FilterContext.tsx @@ -0,0 +1,109 @@ +import { useState, createContext, useEffect, ReactNode } from 'react'; +import { getProducts, getProductImage } from '../services/products.service'; +import { Iproduct } from '../interfaces/interfaces'; +import { getProductsFiltrated } from '../services/products.service'; + +interface Props { + children: ReactNode; +} + +//add function filter, return axios response +interface IFilterContext { + criteria: string; + min: number; + max: number; + // eslint-disable-next-line no-unused-vars + setCriteria: (criteria: string) => void; + // eslint-disable-next-line no-unused-vars + setMin: (min: number) => void; + // eslint-disable-next-line no-unused-vars + setMax: (max: number) => void; + inventory: Array; + products: Array; + // eslint-disable-next-line no-unused-vars + setProducts: (products: Array) => void; + // eslint-disable-next-line no-unused-vars + filterProducts: () => void; +} + +//default value return of function +// Create context with default values +export const FilterContext = createContext({ + criteria: '', + min: 0, + max: 81.8, + // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-empty-function + setCriteria: (criteria: string) => {}, + // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-empty-function + setMin: (min: number) => {}, + // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-empty-function + setMax: (max: number) => {}, + inventory: [], + products: [], + // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-empty-function + setProducts: (products: Array) => {}, + // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-empty-function + filterProducts: () => {}, +}); + +// Component +export const FilterContextProvider = ({ children }: Props) => { + const [inventory, setInventory] = useState(new Array()); + const [products, setProducts] = useState(new Array()); + + const [criteria, setCriteria] = useState(''); + const [min, setMin] = useState(0); + const [max, setMax] = useState(81.8); + + useEffect(() => { + const load = async () => { + const response = await getProducts(); + const items: Array = response.products; + const products: Array = []; + + for (let i = 0; i < items.length; i++) { + const imageReply = await getProductImage(items[i].serial); + //console.log(imageReply); + products.push({ ...items[i], image: imageReply.image }); + } + + setInventory(products); + }; + + load(); + }, []); + + //updating products to show from filter + const filterProducts = async () => { + const response = await getProductsFiltrated(min, max, criteria); + const items: Array = response.products; + const products: Array = []; + + if (items != undefined) { + for (let i = 0; i < items.length; i++) { + const imageReply = await getProductImage(items[i].serial); + products.push({ ...items[i], image: imageReply.image }); + } + setInventory(products); + } + }; + + return ( + + {children} + + ); +}; diff --git a/src/context/SessionContext.tsx b/src/context/SessionContext.tsx new file mode 100644 index 0000000..cf0f8b9 --- /dev/null +++ b/src/context/SessionContext.tsx @@ -0,0 +1,290 @@ +import { AxiosResponse } from 'axios'; +import { createContext, useState, useEffect, ReactNode } from 'react'; +import { IUser, ICartItem } from '../interfaces/interfaces'; +import { IUpdateCartPayload } from '../interfaces/interfaces.services'; +import { UserTemplate } from '../templates/user'; + +import { OrderService, UpdateCartItemAmount } from '../services/shop.services'; +import { GetProductImageFromEndpointService } from '../services/products.service'; +import { + WhoamiService, + LogoutService, + GetCartService, + RemoveFromCartService, + AddToCartService, + GetFavoritesService, + RemoveFromFavoritesService, + AddToFavoritesService, +} from '../services/session.services'; + +interface Props { + children: ReactNode; +} + +interface ISessionCTX { + user: IUser; + isLoggedIn: boolean; + isSessionLoading: boolean; + cart: Array; + favorites: Array; + // eslint-disable-next-line no-unused-vars + login: (response: AxiosResponse) => Promise; + logout: () => Promise; + // eslint-disable-next-line no-unused-vars + removeFromCart: (id: string) => Promise; + // eslint-disable-next-line no-unused-vars + addToCart: (item: ICartItem) => Promise; + // eslint-disable-next-line no-unused-vars + updateCart: (payload: IUpdateCartPayload) => Promise; + // eslint-disable-next-line no-unused-vars + removeFromFavorites: (id: string) => Promise; + // eslint-disable-next-line no-unused-vars + addToFavorites: (id: string) => Promise; + makeOrder: () => Promise; +} + +// Here we define the default values for each +// field of the ISessionCTX interface +export const SessionContext = createContext({ + user: UserTemplate, + isLoggedIn: false, + isSessionLoading: false, + cart: [], + favorites: [], + // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-empty-function + login: async function (payload: AxiosResponse) {}, + // eslint-disable-next-line @typescript-eslint/no-empty-function + logout: async function () {}, + // eslint-disable-next-line no-unused-vars + removeFromCart: async function (id: string) { + return true; + }, + // eslint-disable-next-line no-unused-vars + addToCart: async function (item: ICartItem) { + return true; + }, + // eslint-disable-next-line no-unused-vars + updateCart: async function (payload: IUpdateCartPayload) { + return true; + }, + // eslint-disable-next-line no-unused-vars, + removeFromFavorites: async function (id: string) { + return true; + }, + // eslint-disable-next-line no-unused-vars, + addToFavorites: async function (id: string) { + return true; + }, + makeOrder: async function () { + return true; + }, +}); + +// Session provider +export const SessionContextProvider = ({ children }: Props) => { + const [user, setUser] = useState(UserTemplate); + const [cart, setCart] = useState(Array); + const [favorites, setFavorites] = useState(Array); + const [isSessionLoading, setIsSessionLoading] = useState(false); + const [isLoggedIn, setIsLoggedIn] = useState(false); + + // Try to get the user information on reload + // or new start + useEffect(() => { + const recover = async () => { + setIsSessionLoading(true); + + const reply = await WhoamiService(1); // First iteration + + if (!reply) { + setIsSessionLoading(false); + return; + } + + if (reply.status === 200) { + setUser(reply.data.user); + setIsLoggedIn(true); + } + + setIsSessionLoading(false); + }; + + recover(); + }, []); + + // Get user cart and favorites on load + useEffect(() => { + const getUserCart = async () => { + const reply = await GetCartService(1); // First try + if (!reply?.data) return; + + const items = reply.data.products; + const products: Array = []; + + for (let i = 0; i < items.length; i++) { + const response = await GetProductImageFromEndpointService(items[i].image); + products.push({ ...items[i], image: response.image }); + } + + setCart(products); + }; + + const getUserFavorites = async () => { + const reply = await GetFavoritesService(1); // First try + if (!reply?.data) return; + setFavorites(reply.data.favorites); + }; + + if (isLoggedIn) { + // Only get when the user have an active session + getUserCart(); + getUserFavorites(); + } + }, [isLoggedIn]); + + // Actual value for login function + const login = async (response: AxiosResponse) => { + setIsSessionLoading(true); + setUser(response.data.user); + setIsLoggedIn(true); + setIsSessionLoading(false); + }; + + // Remove item from cart + const removeFromCart = async (id: string) => { + const wasDeleted = await RemoveFromCartService(1, id); + + if (wasDeleted) { + const newCart = cart.filter((product) => product.id != id); + setCart(newCart); + return true; + } + + return false; + }; + + // Update amount for some item on cart + const updateCart = async (payload: IUpdateCartPayload) => { + if (payload.amount === '') return false; + + const newCart = cart.map((product) => { + if (product.id === payload.id) { + return { ...product, quantity: parseInt(payload.amount) }; + } else { + return product; + } + }); + + // Update on database + const wasUpdated = await UpdateCartItemAmount(1, payload); + + if (wasUpdated) { + setCart(newCart); + return true; + } + + return false; + }; + + // Logout + const logout = async () => { + setIsSessionLoading(true); + const wasSessionClosed = await LogoutService(1); + + if (wasSessionClosed) { + setIsLoggedIn(false); + setUser(UserTemplate); + setCart([]); + setFavorites([]); + } + + setIsSessionLoading(false); + }; + + // Add item to cart + const addToCart = async (item: ICartItem) => { + const wasAdded = await AddToCartService(1, item.id); + + if (wasAdded) { + const exists = cart.some((product) => product.id === item.id); + + if (exists) { + const newCart = cart.map((product) => { + if (product.id === item.id) { + return { ...product, quantity: product.quantity + 1 }; + } else { + return product; + } + }); + + setCart(newCart); + } else { + setCart([...cart, { ...item, quantity: 1 }]); + } + + return true; + } + + return false; + }; + + // Remove item from favorites + const removeFromFavorites = async (id: string) => { + const wasDeleted = await RemoveFromFavoritesService(1, id); + console.log(wasDeleted); + + if (wasDeleted) { + const newFavorites = favorites.filter((fid) => fid !== id); + setFavorites(newFavorites); + return true; + } + + return false; + }; + + // Add item to favorites + const addToFavorites = async (id: string) => { + const wasAdded = await AddToFavoritesService(1, id); + + if (wasAdded) { + setFavorites([...favorites, id]); + return true; + } + + return false; + }; + + // Create order from cart + const makeOrder = async () => { + const wasCreated = await OrderService(1); + + if (wasCreated) { + setCart([]); // Empty cart + return true; + } + + return false; + }; + + return ( + + {children} + + ); +}; diff --git a/src/global.css b/src/global.css new file mode 100644 index 0000000..ce7d322 --- /dev/null +++ b/src/global.css @@ -0,0 +1,48 @@ +/*Configure global fonts*/ +@font-face { + font-family: Inter; + src: url('fonts/Inter.woff2') format('woff2'), url('fonts/Inter.woff') format('woff'), + url('fonts/Inter.ttf') format('truetype'); + font-display: swap; +} + +/* Global variables */ +:root { + /*Colors*/ + --opaque-yellow: #f3b24a; + --accented-yellow: #efa229; + + --opaque-red: #ef2947; + --accented-red: #e80729; + + --opaque-blue: #74a4ec; + --accented-blue: #507bbb; + + --opaque-green: #58d17a; + --accented-green: #21a746; + + --dark-green: #006414; + --inputs-bg: #f7f6fb; + --box-shadows: #e1e1e1; + --texts-light: #a5a5a5; + --texts: #4e4e4e; + + /*Font sizes*/ + --sm: 1rem; + --md: 1.2rem; + --lg: 1.4rem; +} + +/* Global styles */ +* { + font-family: Inter, sans-serif; + box-sizing: border-box; + margin: 0px; + padding: 0px; + accent-color: var(--accented-green); +} + +.container { + width: 1400px; + margin-inline: auto; +} diff --git a/src/interfaces/interfaces.services.ts b/src/interfaces/interfaces.services.ts new file mode 100644 index 0000000..fc36e29 --- /dev/null +++ b/src/interfaces/interfaces.services.ts @@ -0,0 +1,16 @@ +export interface ILoginPayload { + email: string; + password: string; +} + +export interface ISignupPayload { + firstname: string; + lastname: string; + email: string; + password: string; +} + +export interface IUpdateCartPayload { + id: string; + amount: string; +} diff --git a/src/interfaces/interfaces.ts b/src/interfaces/interfaces.ts new file mode 100644 index 0000000..9e619f8 --- /dev/null +++ b/src/interfaces/interfaces.ts @@ -0,0 +1,43 @@ +export interface Iproduct { + id: string; + serial: number; + name: string; + image: string; + units: string; + annotations: string; + discount: number; + price: number; + description: string; +} + +export interface ICartItem { + id: string; + name: string; + units: string; + quantity: number; + price: number; + image: string; +} + +export interface ModalProduct { + id: string; + name: string; + image: string; + units: string; + annotations: string; + discount: number; + price: number; + description: string; +} + +export interface IUser { + id: number; + email: string; + firstname: string; + lastname: string; +} + +// Session provider interface +export interface ISessionProvider { + user: IUser; +} diff --git a/src/main.tsx b/src/main.tsx index 41bf61e..a4b2713 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,15 +2,42 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { ToastContainer } from 'react-toastify'; +import 'react-toastify/dist/ReactToastify.css'; + +// Global styles +import './global.css'; + +// Contexts +import { FilterContextProvider } from './context/FilterContext'; +import { SessionContextProvider } from './context/SessionContext'; + // Components -import { Starter } from './pages/Starter'; +import { Navbar } from './components/navbar/Navbar'; +import { ProductsGrid } from './pages/productsGrid/ProductsGrid'; +import { Favorites } from './pages/Favorites/Favorites'; +import { Login } from './pages/login/Login'; +import { Signup } from './pages/signup/Signup'; +import { ShopCart } from './pages/shopcart/ShopCart'; ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( - - }> - + + + + + + }> + + + + }> + }> + }> + }> + + , ); diff --git a/src/pages/Favorites/Favorites.module.css b/src/pages/Favorites/Favorites.module.css new file mode 100644 index 0000000..a4d7eab --- /dev/null +++ b/src/pages/Favorites/Favorites.module.css @@ -0,0 +1,11 @@ +/*Cards container*/ +.products { + padding-block-start: 16px; + padding-block-end: 70px; + height: max-content; + display: flex; + justify-content: center; + align-items: stretch; + flex-wrap: wrap; + gap: 24px; +} diff --git a/src/pages/Favorites/Favorites.tsx b/src/pages/Favorites/Favorites.tsx new file mode 100644 index 0000000..b935613 --- /dev/null +++ b/src/pages/Favorites/Favorites.tsx @@ -0,0 +1,91 @@ +import Styles from './Favorites.module.css'; +import { useEffect, useState, useContext } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { SessionContext } from '../../context/SessionContext'; +import { GetDetailedFavoritesService } from '../../services/user.services'; +import { GetProductImageFromEndpointService } from '../../services/products.service'; +import { Iproduct } from '../../interfaces/interfaces'; +import { ProductCard } from '../../components/productCard/ProductCard'; +import { ModalProduct } from '../../components/modalproducts/ModalProducts'; + +import { toast } from 'react-toastify'; + +export function Favorites() { + const { isSessionLoading, isLoggedIn } = useContext(SessionContext); + const [favorites, setFavorites] = useState(Array); + const [viewModal, setViewModal] = useState(false); + const navigate = useNavigate(); + + // Redirect to home if is not logged in + useEffect(() => { + console.log(isSessionLoading, isLoggedIn); + if (!isSessionLoading && !isLoggedIn) { + // Show an information alert + toast.warn('Please, log in to see your favorites', { + position: 'top-right', + autoClose: 2500, + pauseOnHover: true, + theme: 'light', + }); + + // Redirect to heme because there is an active session + navigate('/login'); + } + }, [isSessionLoading, isLoggedIn]); + + // Datos del producto actual que es mostrado en el modal + const [modalData, setModalData] = useState({ + id: '', + serial: 0, + name: '', + image: '', + units: '', + annotations: '', + discount: 0, + price: 0, + description: '', + }); + + useEffect(() => { + const getCurrentFavorites = async () => { + const response = await GetDetailedFavoritesService(1); + const items = response.data.favorites; + const products: Array = []; + + if (response.status === 200) { + for (let i = 0; i < items.length; i++) { + const ireply = await GetProductImageFromEndpointService(items[i].image); + products.push({ ...items[i], image: ireply.image }); + } + + setFavorites(products); + } + }; + + getCurrentFavorites(); + }, []); + + // Funcion para abrir el modal + const handleAbrir = (data: Iproduct) => { + // Se cambian los datos del modal con los datos del producto + setModalData({ ...data }); + // Se cambia la vista del modal a true + setViewModal(true); + }; + + // Funcion para cerrar el modal + const handleCerrar = () => { + setViewModal(false); + }; + + return ( + <> +
    + {favorites.map((product, index) => { + return ; + })} +
    + {viewModal ? : ''} + + ); +} diff --git a/src/pages/Starter.tsx b/src/pages/Starter.tsx deleted file mode 100644 index 3b5f962..0000000 --- a/src/pages/Starter.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export function Starter() { - return

    Hello World

    ; -} diff --git a/src/pages/login/Login.tsx b/src/pages/login/Login.tsx new file mode 100644 index 0000000..1a1106f --- /dev/null +++ b/src/pages/login/Login.tsx @@ -0,0 +1,96 @@ +import { useContext, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { SessionContext } from '../../context/SessionContext'; +import { DynamicForm } from '../../components/dynamicForm/DynamicForm'; +import { ILoginPayload } from '../../interfaces/interfaces.services'; +import { LoginService } from '../../services/session.services'; + +import { toast } from 'react-toastify'; + +export function Login() { + // Get login function from provider + const { login, isLoggedIn, isSessionLoading } = useContext(SessionContext); + const navigate = useNavigate(); + + // Redirect to home if is logged in + useEffect(() => { + if (!isSessionLoading && isLoggedIn) { + // Show an information alert + toast.warn('You already have an active session', { + position: 'top-right', + autoClose: 2500, + pauseOnHover: true, + theme: 'light', + }); + + // Redirect to heme because there is an active session + navigate('/'); + } + }, [isSessionLoading, isLoggedIn]); + + // Prepare callback + const HandleLoginSubmit = async (payload: ILoginPayload) => { + // Get response from backk-end + const response = await LoginService(payload); + const data = response?.data; + + if (response?.status !== 200) { + // Shows an error alert + toast.error(data.message, { + position: 'top-right', + autoClose: 2500, + pauseOnHover: true, + theme: 'light', + }); + } else { + // Update the session on the provider component + login(response); + + toast.success('Login successfully completed', { + position: 'top-right', + autoClose: 2500, + pauseOnHover: true, + theme: 'light', + }); + + navigate('/'); + } + }; + + const loginFields = [ + { + label: 'Email', + name: 'email', + type: 'email', + placeholder: 'foo@bar.com', + }, + { + label: 'Password', + name: 'password', + type: 'password', + placeholder: '********', + minlength: 8, + }, + ]; + + // Regular expressions to validate fields + const loginRules = [ + { + name: 'password', + done: false, + regexp: /^(?=.*[A-Za-z])(?=.*\d)(?=.*[$!%*#?&/%])[A-Za-z\d$!%*#?&/%]{8,}$/, + message: + 'Password must have minimal length of 8 characters, contains at least one number, one letter and one special character', + }, + ]; + + return ( + + ); +} diff --git a/src/pages/productsGrid/ProductsGrid.module.css b/src/pages/productsGrid/ProductsGrid.module.css new file mode 100644 index 0000000..9f62da9 --- /dev/null +++ b/src/pages/productsGrid/ProductsGrid.module.css @@ -0,0 +1,59 @@ +.gridLayout { + display: grid; + grid-template-columns: 400px 1fr; + min-height: calc(100vh - 72px); +} + +/* Todo: Filters styles by @SilviaPabo/n */ +.productsFilters { + box-shadow: 0px 4px 16px var(--box-shadows); + min-height: calc(100vh - 72px); +} + +/*Cards container*/ +.products { + padding-block-start: 16px; + padding-block-end: 70px; + height: max-content; + display: flex; + justify-content: center; + align-items: stretch; + flex-wrap: wrap; + gap: 24px; +} + +/* Responsive */ +@media screen and (max-width: 768px) { + .gridLayout { + grid-template-columns: 1fr; + } + + .productsFilters { + height: 200px; + } +} + +.pagination { + position: fixed; + right: 32px; + bottom: 0px; + margin-block: 16px; +} + +.pagination__item { + cursor: pointer; + margin-inline: 4px; + padding: 8px 12px; + font-size: var(--md); + font-weight: bold; + background-color: white; + color: var(--texts); + border: 1px solid #bebebe; + box-shadow: 0px 0px 8px var(--box-shadows); + list-style-type: none; + display: inline-block; +} + +.pagination__active { + color: var(--accented-green); +} diff --git a/src/pages/productsGrid/ProductsGrid.tsx b/src/pages/productsGrid/ProductsGrid.tsx new file mode 100644 index 0000000..258b241 --- /dev/null +++ b/src/pages/productsGrid/ProductsGrid.tsx @@ -0,0 +1,84 @@ +import Styles from './ProductsGrid.module.css'; +import { useState, useContext, useEffect } from 'react'; +import { Iproduct } from '../../interfaces/interfaces'; +import { ProductCard } from '../../components/productCard/ProductCard'; +import { ModalProduct } from '../../components/modalproducts/ModalProducts'; +import ReactPaginate from 'react-paginate'; +import { Slider } from '../../components/slider/Slider'; + +import { FilterContext } from '../../context/FilterContext'; + +export function ProductsGrid() { + const { products, inventory, setProducts } = useContext(FilterContext); + + // Estado que guarda los datos del producto + const [modalData, setModalData] = useState({ + id: '', + serial: 0, + name: '', + image: '', + units: '', + annotations: '', + discount: 0, + price: 0, + description: '', + }); + + // Estado que indica si el modal esta abierto o no + const [viewModal, setViewModal] = useState(false); + + // Funcion para abrir el modal + const handleAbrir = (data: Iproduct) => { + // Se cambian los datos del modal con los datos del producto + setModalData({ ...data }); + // Se cambia la vista del modal a true + setViewModal(true); + }; + + // Funcion para cerrar el modal + const handleCerrar = () => { + setViewModal(false); + }; + + //PAGINATION + + const handlePageClick = async (data: { selected: number }) => { + const start: number = data.selected * 12; + const end: number = start + 12; + const page: Array = inventory.slice(start, end); + setProducts(page); + }; + + //setinventario y setcontext setproducts vienen del contexto + useEffect(() => { + setProducts(inventory.slice(0, 12)); + }, [inventory]); + + return ( +
    + +
    + {products.map((product, index) => { + return ; + })} +
    +
    + '} + pageCount={Math.ceil(inventory.length / 12)} + breakLabel={'...'} + onPageChange={handlePageClick} + containerClassName={Styles.pagination} + pageClassName={Styles.pagination__item} + previousClassName={Styles.pagination__item} + nextClassName={Styles.pagination__item} + activeClassName={Styles.pagination__active} + /> +
    + {viewModal ? : ''} +
    + ); +} diff --git a/src/pages/shopcart/ShopCart.module.css b/src/pages/shopcart/ShopCart.module.css new file mode 100644 index 0000000..3bf3a51 --- /dev/null +++ b/src/pages/shopcart/ShopCart.module.css @@ -0,0 +1,85 @@ +.cartContainer { + display: grid; + grid-template-columns: 1fr 540px; + min-height: calc(100vh - 72px); +} + +.resumeContainer { + box-shadow: 0px 4px 16px var(--box-shadows); + min-height: calc(100vh - 72px); + padding: 8px 24px; +} + +.cart__items { + padding: 8px 16px; + height: max-content; +} + +.cart__title { + text-align: center; + margin: 16px; + text-transform: uppercase; + color: var(--accented-green); +} + +.cart__subtitle { + color: var(--texts); +} + +.cart__grid { + display: flex; + flex-direction: column; + gap: 16px; +} + +.containerprice { + margin: 0px; + padding: 50px; +} + +.ResumeTitle { + text-align: center; + margin: 16px; + text-transform: uppercase; + color: var(#4e4e4e); +} + +.subtotal { + display: flex; + justify-content: space-between; + color: var(--texts-light); + margin-block: 10px; + font-size: var(--sm); +} + +.total { + display: flex; + justify-content: space-between; + margin-block: 10px; + font-size: var(--sm); + font-weight: bold; + color: var(--texts); +} + +.iva { + font-size: var(--sm); + color: var(--texts-light); +} + +.dialog__button { + display: block; + width: 100%; + margin-block: 16px 12px; + font-size: var(--sm); + background-color: var(--accented-green); + padding: 0.8em; + outline: none; + color: white; + border: 1px solid transparent; + border-radius: 12px; + cursor: pointer; +} + +.pregunta { + color: var(--accented-green); +} diff --git a/src/pages/shopcart/ShopCart.tsx b/src/pages/shopcart/ShopCart.tsx new file mode 100644 index 0000000..fb5e539 --- /dev/null +++ b/src/pages/shopcart/ShopCart.tsx @@ -0,0 +1,116 @@ +import Styles from './ShopCart.module.css'; +import { Link, useNavigate } from 'react-router-dom'; +import { ShopCartPageRow } from './ShopCartPageRow/ShopCartPageRow'; +import { useContext, useEffect } from 'react'; +import { SessionContext } from '../../context/SessionContext'; + +import { toast } from 'react-toastify'; + +export function ShopCart() { + const { isSessionLoading, isLoggedIn, cart, makeOrder } = useContext(SessionContext); + const navigate = useNavigate(); + + // Redirect to home if is not logged in + useEffect(() => { + if (!isSessionLoading && !isLoggedIn) { + // Show an information alert + toast.warn('Please, log in to manage your cart', { + position: 'top-right', + autoClose: 2500, + pauseOnHover: true, + theme: 'light', + }); + + // Redirect to heme because there is an active session + navigate('/'); + } + }, [isSessionLoading, isLoggedIn]); + + const GetCartTotal = () => { + const price = cart.reduce((acc, curr) => { + return acc + curr.price * curr.quantity; + }, 0); + + return price.toFixed(2); + }; + + const GetCartTotalIva = () => { + const price = cart.reduce((acc, curr) => { + return acc + curr.price * curr.quantity * 0.19 + curr.price * curr.quantity; + }, 0); + + return price.toFixed(2); + }; + + return ( +
    +
    +

    Esta es tu cesta de la compra

    +

    {cart.length} Products:

    +
    + {cart.map((item, index) => { + return ; + })} +
    +
    + +
    + ); +} diff --git a/src/pages/shopcart/ShopCartPageRow/ShopCartPageRow.module.css b/src/pages/shopcart/ShopCartPageRow/ShopCartPageRow.module.css new file mode 100644 index 0000000..5e5d3e1 --- /dev/null +++ b/src/pages/shopcart/ShopCartPageRow/ShopCartPageRow.module.css @@ -0,0 +1,50 @@ +.product { + display: grid; + margin-inline-start: 12px; + grid-template-columns: 0.8fr 2fr 2fr 1fr 1fr; + gap: 12px; +} + +.product__image { + width: 100%; + object-fit: contain; +} + +.product__title { + font-size: var(--lg); + color: var(--texts); + margin-block-end: 8px; +} + +.product__units { + color: var(--texts-light); +} + +.product__details, +.product__col { + display: flex; + flex-direction: column; + justify-content: center; +} + +.product__group { + display: flex; + align-items: center; +} + +.product__col { + align-items: center; +} + +.product__price { + font-size: var(--sm); + font-weight: bolder; + color: var(--texts); +} + +.product__input { + padding: 0.8em; + margin-inline: 16px; + border: 1px solid var(--texts-light); + border-radius: 8px; +} diff --git a/src/pages/shopcart/ShopCartPageRow/ShopCartPageRow.tsx b/src/pages/shopcart/ShopCartPageRow/ShopCartPageRow.tsx new file mode 100644 index 0000000..536aa92 --- /dev/null +++ b/src/pages/shopcart/ShopCartPageRow/ShopCartPageRow.tsx @@ -0,0 +1,81 @@ +import Styles from './ShopCartPageRow.module.css'; +import { useContext } from 'react'; +import { SessionContext } from '../../../context/SessionContext'; +import { FiTrash2 } from 'react-icons/fi'; +import { toast } from 'react-toastify'; + +interface IProduct { + id: string; + image: string; + name: string; + units: string; + quantity: number; + price: number; +} + +interface IProps { + product: IProduct; +} + +export function ShopCartPageRow(props: IProps) { + const { removeFromCart, updateCart } = useContext(SessionContext); + + const HandleRemoveFromCart = async () => { + const wasDeleted = await removeFromCart(props.product.id); + + if (wasDeleted) { + toast.success(`Successfully remove ${props.product.name} from the cart`, { + position: 'top-right', + autoClose: 2500, + pauseOnHover: true, + theme: 'light', + }); + } else { + toast.error(`Unable to remove ${props.product.name} from the cart. Try again.`, { + position: 'top-right', + autoClose: 2500, + pauseOnHover: true, + theme: 'light', + }); + } + }; + + return ( +
    + {props.product.name} +
    +

    {props.product.name}

    +

    Unidades: {props.product.units}

    +
    +
    + + { + // Update value on session + const value = e.target.value; + updateCart({ id: props.product.id, amount: value }); + }} + /> +
    +
    +

    {props.product.price}€

    +
    +
    + { + HandleRemoveFromCart(); + }} + /> +
    +
    + ); +} diff --git a/src/pages/signup/Signup.tsx b/src/pages/signup/Signup.tsx new file mode 100644 index 0000000..d52762e --- /dev/null +++ b/src/pages/signup/Signup.tsx @@ -0,0 +1,116 @@ +import { useContext, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { SessionContext } from '../../context/SessionContext'; + +import { SignupService } from '../../services/user.services'; +import { ISignupPayload } from '../../interfaces/interfaces.services'; +import { DynamicForm } from '../../components/dynamicForm/DynamicForm'; + +import { toast } from 'react-toastify'; + +export function Signup() { + const { isLoggedIn, isSessionLoading } = useContext(SessionContext); + const navigate = useNavigate(); + + // Redirect to heme if is logged in + useEffect(() => { + if (!isSessionLoading && isLoggedIn) { + // Show an information alert + toast.warn('You already have an active session', { + position: 'top-right', + autoClose: 2500, + pauseOnHover: true, + theme: 'light', + }); + } + }, [isSessionLoading, isLoggedIn]); + + const HandleSignupSubmit = async (payload: ISignupPayload) => { + // Get response from back-end + const response = await SignupService(payload); + const data = response?.data; + + if (response?.status !== 200) { + // Shows an error alert + toast.error(data.message, { + position: 'top-right', + autoClose: 2500, + pauseOnHover: true, + theme: 'light', + }); + } else { + toast.success('User was created successfully', { + position: 'top-right', + autoClose: 2500, + pauseOnHover: true, + theme: 'light', + }); + + navigate('/login'); + } + }; + + const signupRules = [ + { + name: 'password', + done: false, + regexp: /^(?=.*[A-Za-z])(?=.*\d)(?=.*[$!%*#?&/%])[A-Za-z\d$!%*#?&/%]{8,}$/, + message: + 'Password must have minimal length of 8 characters, contains at least one number, one letter and one special character', + }, + { + name: 'password2', + done: false, + regexp: /^(?=.*[A-Za-z])(?=.*\d)(?=.*[$!%*#?&/%])[A-Za-z\d$!%*#?&/%]{8,}$/, + message: + 'Password must have minimal length of 8 characters, contains at least one number, one letter and one special character', + }, + ]; + + const signupFields = [ + { + label: 'First name', + name: 'firstName', + type: 'text', + placeholder: 'Foo', + minlength: 5, + }, + { + label: 'Last name', + name: 'lastName', + type: 'text', + placeholder: 'Bar', + minlength: 5, + }, + { + label: 'Email', + name: 'email', + type: 'email', + placeholder: 'foo@bar.com', + }, + { + label: 'Password', + name: 'password', + type: 'password', + placeholder: '********', + minlength: 8, + }, + { + label: 'Confirm Password', + name: 'password2', + type: 'password', + placeholder: '********', + minlength: 8, + }, + ]; + + return ( + + ); +} diff --git a/src/services/products.service.ts b/src/services/products.service.ts new file mode 100644 index 0000000..5f9da9a --- /dev/null +++ b/src/services/products.service.ts @@ -0,0 +1,31 @@ +import axios from 'axios'; +import { GLOBALS } from '../config/config'; + +export const getProducts = async () => { + const response = await axios.get(`${GLOBALS.API_HOST}/api/products`); + return response.data; +}; + +export const getProductsFiltrated = async (from: number, to: number, criteria: string) => { + const response = await axios.post(`${GLOBALS.API_HOST}/api/products/filter`, { + from: from, + to: to, + search_criteria: criteria, + }); + return response.data; +}; + +// export const getPageproducts = async (page: number) => { +// const response = await axios.get(`${GLOBALS.API_HOST}/api/products/${page}`); +// return response.data; +// }; + +export const getProductImage = async (serial: number) => { + const response = await axios.get(`${GLOBALS.API_HOST}/api/products/image/${serial}`); + return response.data; +}; + +export const GetProductImageFromEndpointService = async (endpoint: string) => { + const response = await axios.get(`${GLOBALS.API_HOST}/api${endpoint}`); + return response.data; +}; diff --git a/src/services/session.services.ts b/src/services/session.services.ts new file mode 100644 index 0000000..dd42647 --- /dev/null +++ b/src/services/session.services.ts @@ -0,0 +1,225 @@ +import axios from 'axios'; +import { GLOBALS } from '../config/config'; +import { ILoginPayload } from '../interfaces/interfaces.services'; + +// Create the login request to the API to obtain the +// session tokens +// withCredentials field is required +export const LoginService = async (payload: ILoginPayload) => { + try { + const response = await axios.post(`${GLOBALS.API_HOST}/api/session/login`, payload, { + withCredentials: true, + }); + + return response; + } catch (err) { + // Return the body of the error + if (axios.isAxiosError(err)) return err.response; + // Creates an empty error + else return new axios.AxiosError().response; + } +}; + +// Get expired tokens +export const LogoutService = async (it: number): Promise => { + if (it > 2) return false; + + try { + await axios.delete(`${GLOBALS.API_HOST}/api/session/logout`, { + withCredentials: true, + }); + return true; + } catch (err) { + if (axios.isAxiosError(err)) { + if (err.response?.status === 403) { + await RefreshTokenService(); + return await LogoutService(++it); + } + + return false; + } + return false; + } +}; + +// Get a new access-token +export const RefreshTokenService = async (): Promise => { + try { + await axios.get(`${GLOBALS.API_HOST}/api/session/refresh`, { + withCredentials: true, + }); + + return true; + } catch (err) { + return false; + } +}; + +// Recover the session user data from the api +// from the access token sended as a cookie +// withCredentials field is required + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const WhoamiService = async (it: number): Promise => { + if (it > 2) return new axios.AxiosError().response; + + try { + const response = await axios.get(`${GLOBALS.API_HOST}/api/session/whoami`, { + withCredentials: true, + }); + + return response; + } catch (err) { + if (axios.isAxiosError(err)) { + // Error caused because of no access-token provided + if (err.response?.status === 403) { + await RefreshTokenService(); + return await WhoamiService(++it); // Try one more time + } + + return err.response; + } + return new axios.AxiosError().response; + } +}; + +// Get user cart from api +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const GetCartService = async (it: number): Promise => { + if (it > 2) return new axios.AxiosError().response; + + try { + const response = await axios.get(`${GLOBALS.API_HOST}/api/cart`, { + withCredentials: true, + }); + return response; + } catch (err) { + if (axios.isAxiosError(err)) { + // Error caused because of no access-token provided + if (err.response?.status === 403) { + await RefreshTokenService(); + return await GetCartService(++it); // Try one more time + } + + return err.response; + } + return new axios.AxiosError().response; + } +}; + +// Ger user favorites on load +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const GetFavoritesService = async (it: number): Promise => { + if (it > 2) return new axios.AxiosError().response; + + try { + const response = await axios.get(`${GLOBALS.API_HOST}/api/user/favorites/list`, { + withCredentials: true, + }); + + return response; + } catch (err) { + if (axios.isAxiosError(err)) { + // Error caused because of no access-token provided + if (err.response?.status === 403) { + await RefreshTokenService(); + return await GetFavoritesService(++it); // Try one more time + } + + return err.response; + } + return new axios.AxiosError().response; + } +}; + +// Add to user cart +export const AddToCartService = async (it: number, id: string): Promise => { + if (it > 2) return false; + + try { + const payload = { id }; + const response = await axios.post(`${GLOBALS.API_HOST}/api/cart`, payload, { + withCredentials: true, + }); + + return response.status === 200 ? true : false; + } catch (err) { + if (axios.isAxiosError(err)) { + if (err.response?.status === 403) { + await RefreshTokenService(); + return await AddToCartService(++it, id); + } + return false; + } + return false; + } +}; + +// Add to favorites +export const AddToFavoritesService = async (it: number, id: string): Promise => { + if (it > 2) return false; + + try { + const payload = { id }; + const response = await axios.post(`${GLOBALS.API_HOST}/api/user/favorites`, payload, { + withCredentials: true, + }); + + return response.status === 200 ? true : false; + } catch (err) { + if (axios.isAxiosError(err)) { + if (err.response?.status === 403) { + await RefreshTokenService(); + return await AddToFavoritesService(++it, id); + } + return false; + } + return false; + } +}; + +// Remove item from user cart +export const RemoveFromCartService = async (it: number, id: string): Promise => { + if (it > 2) return false; + + try { + const response = await axios.delete(`${GLOBALS.API_HOST}/api/cart/${id}`, { + withCredentials: true, + }); + + return response.status === 200 ? true : false; + } catch (err) { + if (axios.isAxiosError(err)) { + // Error caused because of no access-token provided + if (err.response?.status === 403) { + await RefreshTokenService(); + return await RemoveFromCartService(++it, id); // Try one more time + } + return false; + } + + return false; + } +}; + +// Remove from favorites +export const RemoveFromFavoritesService = async (it: number, id: string): Promise => { + if (it > 2) return false; + + try { + const response = await axios.delete(`${GLOBALS.API_HOST}/api/user/favorites/${id}`, { + withCredentials: true, + }); + + return response.status === 200 ? true : false; + } catch (err) { + if (axios.isAxiosError(err)) { + if (err.response?.status === 403) { + await RefreshTokenService(); + return await RemoveFromFavoritesService(++it, id); + } + return false; + } + return false; + } +}; diff --git a/src/services/shop.services.ts b/src/services/shop.services.ts new file mode 100644 index 0000000..55e8286 --- /dev/null +++ b/src/services/shop.services.ts @@ -0,0 +1,59 @@ +import axios from 'axios'; +import { GLOBALS } from '../config/config'; +import { RefreshTokenService } from './session.services'; +import { IUpdateCartPayload } from '../interfaces/interfaces.services'; + +// Make an order from the items on cart +export const OrderService = async (it: number): Promise => { + if (it > 2) return false; + + try { + await axios.post( + `${GLOBALS.API_HOST}/api/order`, + {}, + { + withCredentials: true, + }, + ); + + return true; + } catch (err) { + if (axios.isAxiosError(err)) { + if (err.response?.status === 403) { + await RefreshTokenService(); + return await OrderService(++it); + } + return false; + } + return false; + } +}; + +// Update amount for items on cart +export const UpdateCartItemAmount = async ( + it: number, + payload: IUpdateCartPayload, +): Promise => { + if (it > 2) return false; + + try { + await axios.put( + `${GLOBALS.API_HOST}/api/cart`, + { ...payload, amount: parseInt(payload.amount) }, + { + withCredentials: true, + }, + ); + + return true; + } catch (err) { + if (axios.isAxiosError(err)) { + if (err.response?.status === 403) { + await RefreshTokenService(); + return await UpdateCartItemAmount(++it, payload); + } + return false; + } + return false; + } +}; diff --git a/src/services/user.services.ts b/src/services/user.services.ts new file mode 100644 index 0000000..1052686 --- /dev/null +++ b/src/services/user.services.ts @@ -0,0 +1,41 @@ +import axios from 'axios'; +import { GLOBALS } from '../config/config'; +import { ISignupPayload } from '../interfaces/interfaces.services'; +import { RefreshTokenService } from './session.services'; + +// Create a new user on database +export const SignupService = async (payload: ISignupPayload) => { + try { + const response = await axios.post(`${GLOBALS.API_HOST}/api/user`, payload); + + return response; + } catch (err) { + if (axios.isAxiosError(err)) return err.response; + return new axios.AxiosError().response; + } +}; + +// Get detailed favorites (For /favorites route) +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const GetDetailedFavoritesService = async (it: number): Promise => { + if (it > 2) return new axios.AxiosError().response; + + try { + const response = await axios.get(`${GLOBALS.API_HOST}/api/user/favorites/detailed`, { + withCredentials: true, + }); + + return response; + } catch (err) { + if (axios.isAxiosError(err)) { + // Error caused because of no access-token provided + if (err.response?.status === 403) { + await RefreshTokenService(); + return await GetDetailedFavoritesService(++it); // Try one more time + } + + return err.response; + } + return new axios.AxiosError().response; + } +}; diff --git a/src/templates/user.ts b/src/templates/user.ts new file mode 100644 index 0000000..6ed6883 --- /dev/null +++ b/src/templates/user.ts @@ -0,0 +1,8 @@ +import { IUser } from '../interfaces/interfaces'; + +export const UserTemplate: IUser = { + id: 0, + email: 'foo@bar.com', + firstname: 'foo', + lastname: 'bar', +}; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..9fde9cf --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +declare module '*.css'; + +interface ImportMeta { + env: { + API_HOST: 'localhost:3030'; + }; +}