diff --git a/index.html b/index.html index 095fb3a4537..f79ea9ecdba 100644 --- a/index.html +++ b/index.html @@ -1,12 +1,16 @@ - - - - Vite + React + TS - - -
- - + + + + + + Nice Gadgets + + + +
+ + + diff --git a/package-lock.json b/package-lock.json index 836b9e63b46..000753a8bfe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,25 +11,30 @@ "license": "GPL-3.0", "dependencies": { "@fortawesome/fontawesome-free": "^6.5.2", + "@reduxjs/toolkit": "^2.8.2", "bulma": "^1.0.1", "classnames": "^2.5.1", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.25.1", - "react-transition-group": "^4.4.5" + "react-redux": "^9.2.0", + "react-router-dom": "^6.30.1", + "react-slick": "^0.30.3", + "react-transition-group": "^4.4.5", + "slick-carousel": "^1.8.1" }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^2.1.3", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", + "@types/react-slick": "^0.23.13", "@types/react-transition-group": "^4.4.10", "@typescript-eslint/parser": "^7.16.0", "@vitejs/plugin-react": "^4.3.1", - "cypress": "^13.13.0", + "cypress": "^15.3.0", "eslint": "^8.57.0", "eslint-config-airbnb-typescript": "^18.0.0", "eslint-config-prettier": "^9.1.0", @@ -365,12 +370,10 @@ } }, "node_modules/@babel/runtime": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.8.tgz", - "integrity": "sha512-5F7SDGs1T72ZczbRwbGO9lQi0NLjQxzl6i4lJxLxfW9U5UluCSyEJeniWvnhl3/euNiqQVbo8zruhsDfid0esA==", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.3.tgz", + "integrity": "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==", + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -424,16 +427,6 @@ "node": ">=6.9.0" } }, - "node_modules/@colors/colors": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", - "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", - "dev": true, - "optional": true, - "engines": { - "node": ">=0.1.90" - } - }, "node_modules/@csstools/css-parser-algorithms": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-2.7.1.tgz", @@ -539,10 +532,11 @@ } }, "node_modules/@cypress/request": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.1.tgz", - "integrity": "sha512-TWivJlJi8ZDx2wGOw1dbLuHJKUYX7bWySw377nlnGOW3hP9/MUKIsEdXT/YngWxVdgNCHRBmFlBipE+5/2ZZlQ==", + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.9.tgz", + "integrity": "sha512-I3l7FdGRXluAS44/0NguwWlO83J18p0vlr2FYHrJkWdNYhgVoiYo61IXPqaOsL+vNxU1ZqMACzItGK3/KKDsdw==", "dev": true, + "license": "Apache-2.0", "dependencies": { "aws-sign2": "~0.7.0", "aws4": "^1.8.0", @@ -550,16 +544,16 @@ "combined-stream": "~1.0.6", "extend": "~3.0.2", "forever-agent": "~0.6.1", - "form-data": "~2.3.2", - "http-signature": "~1.3.6", + "form-data": "~4.0.4", + "http-signature": "~1.4.0", "is-typedarray": "~1.0.0", "isstream": "~0.1.2", "json-stringify-safe": "~5.0.1", "mime-types": "~2.1.19", "performance-now": "^2.1.0", - "qs": "6.10.4", + "qs": "6.14.0", "safe-buffer": "^5.1.2", - "tough-cookie": "^4.1.3", + "tough-cookie": "^5.0.0", "tunnel-agent": "^0.6.0", "uuid": "^8.3.2" }, @@ -1184,10 +1178,11 @@ } }, "node_modules/@mate-academy/scripts": { - "version": "1.8.5", - "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.8.5.tgz", - "integrity": "sha512-mHRY2FkuoYCf5U0ahIukkaRo5LSZsxrTSgMJheFoyf3VXsTvfM9OfWcZIDIDB521kdPrScHHnRp+JRNjCfUO5A==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-2.1.3.tgz", + "integrity": "sha512-a07wHTj/1QUK2Aac5zHad+sGw4rIvcNl5lJmJpAD7OxeSbnCdyI6RXUHwXhjF5MaVo9YHrJ0xVahyERS2IIyBQ==", "dev": true, + "license": "MIT", "dependencies": { "@octokit/rest": "^17.11.2", "@types/get-port": "^4.2.0", @@ -1875,10 +1870,37 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.8.2.tgz", + "integrity": "sha512-MYlOhQ0sLdw4ud48FoC5w0dH9VfWQjtCjreKwYTT3l+r427qYC5Y8PihNutepr8XrNaBUDQo9khWUwQxZaqt5A==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^10.0.3", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@remix-run/router": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.18.0.tgz", - "integrity": "sha512-L3jkqmqoSVBVKHfpGZmLrex0lxR5SucGA0sUfFzGctehw+S/ggL9L/0NnC5mw6P8HUWpFZ3nQw3cRApjjWx9Sw==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", + "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==", + "license": "MIT", "engines": { "node": ">=14.0.0" } @@ -2126,6 +2148,18 @@ "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", "dev": true }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2216,13 +2250,13 @@ "version": "15.7.12", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", - "dev": true + "devOptional": true }, "node_modules/@types/react": { "version": "18.3.3", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz", "integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==", - "dev": true, + "devOptional": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -2237,6 +2271,16 @@ "@types/react": "*" } }, + "node_modules/@types/react-slick": { + "version": "0.23.13", + "resolved": "https://registry.npmjs.org/@types/react-slick/-/react-slick-0.23.13.tgz", + "integrity": "sha512-bNZfDhe/L8t5OQzIyhrRhBr/61pfBcWaYJoq6UDqFtv5LMwfg4NsVDD2J8N01JqdAdxLjOt66OZEp6PX+dGs/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-transition-group": { "version": "4.4.10", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz", @@ -2258,6 +2302,19 @@ "integrity": "sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg==", "dev": true }, + "node_modules/@types/tmp": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@types/tmp/-/tmp-0.2.6.tgz", + "integrity": "sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -2819,6 +2876,7 @@ "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", "dev": true, + "license": "MIT", "dependencies": { "safer-buffer": "~2.1.0" } @@ -2828,6 +2886,7 @@ "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.8" } @@ -2857,7 +2916,8 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/at-least-node": { "version": "1.0.0", @@ -2888,15 +2948,17 @@ "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "*" } }, "node_modules/aws4": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.0.tgz", - "integrity": "sha512-3AungXC4I8kKsS9PuS4JH2nc+0bVY/mjgrephHTIi8fpEeGsTHBUJeosp0Wc1myYMElmD0B3Oc4XL/HVJ4PV2g==", - "dev": true + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "dev": true, + "license": "MIT" }, "node_modules/axe-core": { "version": "4.9.1", @@ -2947,6 +3009,7 @@ "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "tweetnacl": "^0.14.3" } @@ -3108,6 +3171,37 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -3163,9 +3257,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001642", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001642.tgz", - "integrity": "sha512-3XQ0DoRgLijXJErLSl+bLnJ+Et4KqV1PY6JJBGAFlsNsz31zeAIncyeZfLCabHK/jtSh+671RM9YMldxjUPZtA==", + "version": "1.0.30001746", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001746.tgz", + "integrity": "sha512-eA7Ys/DGw+pnkWWSE/id29f2IcPHVoE8wxtvE5JdvD2V28VTDPy1yEeo11Guz0sJ4ZeGRcm3uaTcAqK1LXaphA==", "dev": true, "funding": [ { @@ -3180,13 +3274,15 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/caseless": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", - "dev": true + "dev": true, + "license": "Apache-2.0" }, "node_modules/chalk": { "version": "2.4.2", @@ -3202,15 +3298,6 @@ "node": ">=4" } }, - "node_modules/check-more-types": { - "version": "2.24.0", - "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz", - "integrity": "sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -3248,9 +3335,9 @@ } }, "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz", + "integrity": "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==", "dev": true, "funding": [ { @@ -3258,6 +3345,7 @@ "url": "https://github.com/sponsors/sibiraj-s" } ], + "license": "MIT", "engines": { "node": ">=8" } @@ -3265,7 +3353,8 @@ "node_modules/classnames": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", - "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" }, "node_modules/clean-stack": { "version": "2.2.0", @@ -3289,10 +3378,11 @@ } }, "node_modules/cli-table3": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", - "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.1.tgz", + "integrity": "sha512-w0q/enDHhPLq44ovMGdQeeDLvwxwavsJX7oQGYt/LrBlYsyaxyDnp6z3QzFut/6kLLKnlcUVJLrpB7KBfgG/RA==", "dev": true, + "license": "MIT", "dependencies": { "string-width": "^4.2.0" }, @@ -3300,7 +3390,7 @@ "node": "10.* || >= 12.*" }, "optionalDependencies": { - "@colors/colors": "1.5.0" + "colors": "1.4.0" } }, "node_modules/cli-truncate": { @@ -3358,11 +3448,23 @@ "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", "dev": true }, + "node_modules/colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, "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==", "dev": true, + "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" }, @@ -3416,7 +3518,8 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/cosmiconfig": { "version": "9.0.0", @@ -3516,25 +3619,27 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "node_modules/cypress": { - "version": "13.13.0", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.13.0.tgz", - "integrity": "sha512-ou/MQUDq4tcDJI2FsPaod2FZpex4kpIK43JJlcBgWrX8WX7R/05ZxGTuxedOuZBfxjZxja+fbijZGyxiLP6CFA==", + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-15.3.0.tgz", + "integrity": "sha512-g9rDhoK9y8wW4Vx3Ppr8dtfvThXxPL3mJsV5e98fG+6EerrhXKmeRT2sL86cvNRtEZouXJfsuVL1lqiMuGNGcg==", "dev": true, "hasInstallScript": true, + "license": "MIT", "dependencies": { - "@cypress/request": "^3.0.0", + "@cypress/request": "^3.0.9", "@cypress/xvfb": "^1.2.4", "@types/sinonjs__fake-timers": "8.1.1", "@types/sizzle": "^2.3.2", + "@types/tmp": "^0.2.3", "arch": "^2.2.0", "blob-util": "^2.0.2", "bluebird": "^3.7.2", "buffer": "^5.7.1", "cachedir": "^2.3.0", "chalk": "^4.1.0", - "check-more-types": "^2.24.0", + "ci-info": "^4.1.0", "cli-cursor": "^3.1.0", - "cli-table3": "~0.6.1", + "cli-table3": "0.6.1", "commander": "^6.2.1", "common-tags": "^1.8.0", "dayjs": "^1.10.4", @@ -3546,10 +3651,8 @@ "extract-zip": "2.0.1", "figures": "^3.2.0", "fs-extra": "^9.1.0", - "getos": "^3.2.1", - "is-ci": "^3.0.1", + "hasha": "5.2.2", "is-installed-globally": "~0.4.0", - "lazy-ass": "^1.6.0", "listr2": "^3.8.3", "lodash": "^4.17.21", "log-symbols": "^4.0.0", @@ -3559,9 +3662,11 @@ "process": "^0.11.10", "proxy-from-env": "1.0.0", "request-progress": "^3.0.0", - "semver": "^7.5.3", + "semver": "^7.7.1", "supports-color": "^8.1.1", - "tmp": "~0.2.3", + "systeminformation": "5.27.7", + "tmp": "~0.2.4", + "tree-kill": "1.2.2", "untildify": "^4.0.0", "yauzl": "^2.10.0" }, @@ -3569,7 +3674,7 @@ "cypress": "bin/cypress" }, "engines": { - "node": "^16.0.0 || ^18.0.0 || >=20.0.0" + "node": "^20.1.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/cypress/node_modules/ansi-styles": { @@ -3677,6 +3782,7 @@ "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", "dev": true, + "license": "MIT", "dependencies": { "assert-plus": "^1.0.0" }, @@ -3894,6 +4000,7 @@ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.4.0" } @@ -3955,11 +4062,27 @@ "node": ">=10" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ecc-jsbn": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", "dev": true, + "license": "MIT", "dependencies": { "jsbn": "~0.1.0", "safer-buffer": "^2.1.0" @@ -3992,6 +4115,12 @@ "once": "^1.4.0" } }, + "node_modules/enquire.js": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/enquire.js/-/enquire.js-2.1.6.tgz", + "integrity": "sha512-/KujNpO+PT63F7Hlpu4h3pE3TokKRHN26JYmQpPyjkRD/N57R7bPDNojMXdi7uveAKjYB7yQnartCxZnFWr0Xw==", + "license": "MIT" + }, "node_modules/enquirer": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", @@ -4084,13 +4213,11 @@ } }, "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "dev": true, - "dependencies": { - "get-intrinsic": "^1.2.4" - }, + "license": "MIT", "engines": { "node": ">= 0.4" } @@ -4150,10 +4277,11 @@ } }, "node_modules/es-object-atoms": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", - "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "dev": true, + "license": "MIT", "dependencies": { "es-errors": "^1.3.0" }, @@ -4162,14 +4290,16 @@ } }, "node_modules/es-set-tostringtag": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", - "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "dev": true, + "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.4", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", - "hasown": "^2.0.1" + "hasown": "^2.0.2" }, "engines": { "node": ">= 0.4" @@ -4990,7 +5120,8 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/extract-zip": { "version": "2.0.1", @@ -5019,7 +5150,8 @@ "dev": true, "engines": [ "node >=0.6.0" - ] + ], + "license": "MIT" }, "node_modules/fast-deep-equal": { "version": "3.1.3", @@ -5248,22 +5380,26 @@ "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", "dev": true, + "license": "Apache-2.0", "engines": { "node": "*" } }, "node_modules/form-data": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", - "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "dev": true, + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", - "combined-stream": "^1.0.6", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { - "node": ">= 0.12" + "node": ">= 6" } }, "node_modules/fs-extra": { @@ -5362,16 +5498,22 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "dev": true, + "license": "MIT", "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -5392,6 +5534,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", @@ -5424,20 +5580,12 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/getos": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/getos/-/getos-3.2.1.tgz", - "integrity": "sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q==", - "dev": true, - "dependencies": { - "async": "^3.2.0" - } - }, "node_modules/getpass": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", "dev": true, + "license": "MIT", "dependencies": { "assert-plus": "^1.0.0" } @@ -5715,12 +5863,13 @@ "dev": true }, "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "dev": true, - "dependencies": { - "get-intrinsic": "^1.1.3" + "license": "MIT", + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5791,10 +5940,11 @@ } }, "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -5817,6 +5967,33 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hasha": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", + "integrity": "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-stream": "^2.0.0", + "type-fest": "^0.8.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hasha/node_modules/type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -5885,14 +6062,15 @@ } }, "node_modules/http-signature": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.3.6.tgz", - "integrity": "sha512-3adrsD6zqo4GsTqtO7FyrejHNv+NgiIfAfv68+jVlFmSr9OGy7zrxONceFRLKvnnZA5jbxQBX1u9PpB6Wi32Gw==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.4.0.tgz", + "integrity": "sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==", "dev": true, + "license": "MIT", "dependencies": { "assert-plus": "^1.0.0", "jsprim": "^2.0.2", - "sshpk": "^1.14.1" + "sshpk": "^1.18.0" }, "engines": { "node": ">=0.10" @@ -5936,6 +6114,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", + "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/immutable": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.6.tgz", @@ -6131,18 +6319,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-ci": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", - "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", - "dev": true, - "dependencies": { - "ci-info": "^3.2.0" - }, - "bin": { - "is-ci": "bin.js" - } - }, "node_modules/is-core-module": { "version": "2.14.0", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.14.0.tgz", @@ -6456,7 +6632,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/is-unicode-supported": { "version": "0.1.0", @@ -6538,7 +6715,8 @@ "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/iterator.prototype": { "version": "1.1.2", @@ -6553,6 +6731,13 @@ "set-function-name": "^2.0.1" } }, + "node_modules/jquery": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", + "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==", + "license": "MIT", + "peer": true + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6574,7 +6759,8 @@ "version": "0.1.1", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/jsesc": { "version": "2.5.2", @@ -6604,7 +6790,8 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", - "dev": true + "dev": true, + "license": "(AFL-2.1 OR BSD-3-Clause)" }, "node_modules/json-schema-traverse": { "version": "0.4.1", @@ -6624,6 +6811,15 @@ "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", "dev": true }, + "node_modules/json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", + "license": "MIT", + "dependencies": { + "string-convert": "^0.2.0" + } + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -6656,6 +6852,7 @@ "engines": [ "node >=0.6.0" ], + "license": "MIT", "dependencies": { "assert-plus": "1.0.0", "extsprintf": "1.3.0", @@ -6726,15 +6923,6 @@ "node": ">=0.10" } }, - "node_modules/lazy-ass": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz", - "integrity": "sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw==", - "dev": true, - "engines": { - "node": "> 0.8" - } - }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -6802,6 +6990,12 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, "node_modules/lodash.get": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", @@ -7087,6 +7281,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mathml-tag-names": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz", @@ -7148,6 +7352,7 @@ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -7157,6 +7362,7 @@ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, + "license": "MIT", "dependencies": { "mime-db": "1.52.0" }, @@ -7948,10 +8154,11 @@ } }, "node_modules/object-inspect": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", - "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -8310,7 +8517,8 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/picocolors": { "version": "1.0.1", @@ -8617,12 +8825,6 @@ "integrity": "sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==", "dev": true }, - "node_modules/psl": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", - "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", - "dev": true - }, "node_modules/pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -8643,12 +8845,13 @@ } }, "node_modules/qs": { - "version": "6.10.4", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.4.tgz", - "integrity": "sha512-OQiU+C+Ds5qiH91qh/mg0w+8nwQuLjM4F4M/PbmhDOoYehPh+Fb0bDjtR1sOvy7YKxvj28Y/M0PhP5uVX0kB+g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.0.4" + "side-channel": "^1.1.0" }, "engines": { "node": ">=0.6" @@ -8657,12 +8860,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "dev": true - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -8734,6 +8931,29 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", @@ -8744,11 +8964,12 @@ } }, "node_modules/react-router": { - "version": "6.25.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.25.1.tgz", - "integrity": "sha512-u8ELFr5Z6g02nUtpPAggP73Jigj1mRePSwhS/2nkTrlPU5yEkH1vYzWNyvSnSzeeE2DNqWdH+P8OhIh9wuXhTw==", + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz", + "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==", + "license": "MIT", "dependencies": { - "@remix-run/router": "1.18.0" + "@remix-run/router": "1.23.0" }, "engines": { "node": ">=14.0.0" @@ -8758,12 +8979,13 @@ } }, "node_modules/react-router-dom": { - "version": "6.25.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.25.1.tgz", - "integrity": "sha512-0tUDpbFvk35iv+N89dWNrJp+afLgd+y4VtorJZuOCXK0kkCWjEvb3vTJM++SYvMEpbVwXKf3FjeVveVEb6JpDQ==", + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz", + "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==", + "license": "MIT", "dependencies": { - "@remix-run/router": "1.18.0", - "react-router": "6.25.1" + "@remix-run/router": "1.23.0", + "react-router": "6.30.1" }, "engines": { "node": ">=14.0.0" @@ -8773,6 +8995,23 @@ "react-dom": ">=16.8" } }, + "node_modules/react-slick": { + "version": "0.30.3", + "resolved": "https://registry.npmjs.org/react-slick/-/react-slick-0.30.3.tgz", + "integrity": "sha512-B4x0L9GhkEWUMApeHxr/Ezp2NncpGc+5174R02j+zFiWuYboaq98vmxwlpafZfMjZic1bjdIqqmwLDcQY0QaFA==", + "license": "MIT", + "dependencies": { + "classnames": "^2.2.5", + "enquire.js": "^2.1.6", + "json2mq": "^0.2.0", + "lodash.debounce": "^4.0.8", + "resize-observer-polyfill": "^1.5.0" + }, + "peerDependencies": { + "react": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -8893,6 +9132,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.6.tgz", @@ -8914,11 +9168,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" - }, "node_modules/regexp.prototype.flags": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", @@ -8970,11 +9219,17 @@ "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", "dev": true }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" }, "node_modules/resolve": { "version": "1.22.8", @@ -9216,7 +9471,8 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/sass": { "version": "1.77.8", @@ -9244,10 +9500,11 @@ } }, "node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "dev": true, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -9325,15 +9582,73 @@ } }, "node_modules/side-channel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", - "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "dev": true, + "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -9453,6 +9768,15 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/slick-carousel": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/slick-carousel/-/slick-carousel-1.8.1.tgz", + "integrity": "sha512-XB9Ftrf2EEKfzoQXt3Nitrt/IPbT+f1fgqBdoxO3W/+JYvtEOW6EgxnWfr9GH6nmULv7Y2tPmEX3koxThVmebA==", + "license": "MIT", + "peerDependencies": { + "jquery": ">=1.8.0" + } + }, "node_modules/source-map-js": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", @@ -9503,6 +9827,7 @@ "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", "dev": true, + "license": "MIT", "dependencies": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", @@ -9535,6 +9860,12 @@ "node": ">= 0.4" } }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==", + "license": "MIT" + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -9946,6 +10277,33 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/systeminformation": { + "version": "5.27.7", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.27.7.tgz", + "integrity": "sha512-saaqOoVEEFaux4v0K8Q7caiauRwjXC4XbD2eH60dxHXbpKxQ8kH9Rf7Jh+nryKpOUSEFxtCdBlSUx0/lO6rwRg==", + "dev": true, + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32", + "freebsd", + "openbsd", + "netbsd", + "sunos", + "android" + ], + "bin": { + "systeminformation": "lib/cli.js" + }, + "engines": { + "node": ">=8.0.0" + }, + "funding": { + "type": "Buy me a coffee", + "url": "https://www.buymeacoffee.com/systeminfo" + } + }, "node_modules/table": { "version": "6.8.2", "resolved": "https://registry.npmjs.org/table/-/table-6.8.2.tgz", @@ -10070,11 +10428,32 @@ "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", "dev": true }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/tmp": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", - "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", "dev": true, + "license": "MIT", "engines": { "node": ">=14.14" } @@ -10101,27 +10480,16 @@ } }, "node_modules/tough-cookie": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", - "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" + "tldts": "^6.1.32" }, "engines": { - "node": ">=6" - } - }, - "node_modules/tough-cookie/node_modules/universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "dev": true, - "engines": { - "node": ">= 4.0.0" + "node": ">=16" } }, "node_modules/tr46": { @@ -10211,6 +10579,7 @@ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", "dev": true, + "license": "Apache-2.0", "dependencies": { "safe-buffer": "^5.0.1" }, @@ -10222,7 +10591,8 @@ "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", - "dev": true + "dev": true, + "license": "Unlicense" }, "node_modules/type-check": { "version": "0.4.0", @@ -10428,14 +10798,13 @@ "punycode": "^2.1.0" } }, - "node_modules/url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "dev": true, - "dependencies": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" + "node_modules/use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/util-deprecate": { @@ -10481,6 +10850,7 @@ "engines": [ "node >=0.6.0" ], + "license": "MIT", "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", diff --git a/package.json b/package.json index ae251685c8b..1d636d0e677 100644 --- a/package.json +++ b/package.json @@ -1,31 +1,36 @@ { "name": "react_phone-catalog", - "homepage": "react_phone-catalog", + "homepage": "/react_phone-catalog/", "version": "0.1.0", "keywords": [], "author": "Mate Academy", "license": "GPL-3.0", "dependencies": { "@fortawesome/fontawesome-free": "^6.5.2", + "@reduxjs/toolkit": "^2.8.2", "bulma": "^1.0.1", "classnames": "^2.5.1", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.25.1", - "react-transition-group": "^4.4.5" + "react-redux": "^9.2.0", + "react-router-dom": "^6.30.1", + "react-slick": "^0.30.3", + "react-transition-group": "^4.4.5", + "slick-carousel": "^1.8.1" }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^2.1.3", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", + "@types/react-slick": "^0.23.13", "@types/react-transition-group": "^4.4.10", "@typescript-eslint/parser": "^7.16.0", "@vitejs/plugin-react": "^4.3.1", - "cypress": "^13.13.0", + "cypress": "^15.3.0", "eslint": "^8.57.0", "eslint-config-airbnb-typescript": "^18.0.0", "eslint-config-prettier": "^9.1.0", diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 00000000000..7ce7553a33c Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/img/category-phone.png b/public/img/category-phone.png new file mode 100644 index 00000000000..e8d4eec47f6 Binary files /dev/null and b/public/img/category-phone.png differ diff --git a/src/App.scss b/src/App.scss deleted file mode 100644 index 71bc413aade..00000000000 --- a/src/App.scss +++ /dev/null @@ -1 +0,0 @@ -// not empty diff --git a/src/App.tsx b/src/App.tsx index 372e4b42066..c2e22f8f61f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,6 @@ -import './App.scss'; +import React from 'react'; +import { AppRoutes } from './routes/AppRoutes'; -export const App = () => ( -
-

Product Catalog

-
-); +export const App: React.FC = () => { + return ; +}; diff --git a/src/app/hooks.ts b/src/app/hooks.ts new file mode 100644 index 00000000000..d658a5efd12 --- /dev/null +++ b/src/app/hooks.ts @@ -0,0 +1,5 @@ +import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; +import type { RootState, AppDispatch } from './store'; + +export const useAppDispatch = () => useDispatch(); +export const useAppSelector: TypedUseSelectorHook = useSelector; diff --git a/src/app/store.ts b/src/app/store.ts new file mode 100644 index 00000000000..d5dcc690090 --- /dev/null +++ b/src/app/store.ts @@ -0,0 +1,15 @@ +import { configureStore } from '@reduxjs/toolkit'; +import cartReducer from '../features/cart/cartSlice'; +import favoritesReducer from '../features/favorites/favoritesSlice'; +import productsReducer from '../features/products/productSlice'; + +export const store = configureStore({ + reducer: { + cart: cartReducer, + favorites: favoritesReducer, + products: productsReducer, + }, +}); + +export type AppDispatch = typeof store.dispatch; +export type RootState = ReturnType; diff --git a/src/assets/fonts/Mont-Bold.woff2 b/src/assets/fonts/Mont-Bold.woff2 new file mode 100644 index 00000000000..577dc166956 Binary files /dev/null and b/src/assets/fonts/Mont-Bold.woff2 differ diff --git a/src/assets/fonts/Mont-Regular.woff2 b/src/assets/fonts/Mont-Regular.woff2 new file mode 100644 index 00000000000..d1ff40ae492 Binary files /dev/null and b/src/assets/fonts/Mont-Regular.woff2 differ diff --git a/src/assets/fonts/Mont-SemiBold.woff2 b/src/assets/fonts/Mont-SemiBold.woff2 new file mode 100644 index 00000000000..f46b0e3794d Binary files /dev/null and b/src/assets/fonts/Mont-SemiBold.woff2 differ diff --git a/src/assets/icons/icon-logo.png b/src/assets/icons/icon-logo.png new file mode 100644 index 00000000000..2eefb4372f6 Binary files /dev/null and b/src/assets/icons/icon-logo.png differ diff --git a/src/assets/icons/logo-light.png b/src/assets/icons/logo-light.png new file mode 100644 index 00000000000..f56b1790083 Binary files /dev/null and b/src/assets/icons/logo-light.png differ diff --git a/src/assets/icons/logo.png b/src/assets/icons/logo.png new file mode 100644 index 00000000000..8afb6b352ba Binary files /dev/null and b/src/assets/icons/logo.png differ diff --git a/src/assets/images/accessories.png b/src/assets/images/accessories.png new file mode 100644 index 00000000000..d0a3fec473e Binary files /dev/null and b/src/assets/images/accessories.png differ diff --git a/src/assets/images/banner-1.png b/src/assets/images/banner-1.png new file mode 100644 index 00000000000..2111d9ebdc2 Binary files /dev/null and b/src/assets/images/banner-1.png differ diff --git a/src/assets/images/phones.png b/src/assets/images/phones.png new file mode 100644 index 00000000000..bd8bb96044b Binary files /dev/null and b/src/assets/images/phones.png differ diff --git a/src/assets/images/tablets.png b/src/assets/images/tablets.png new file mode 100644 index 00000000000..506aed77f1d Binary files /dev/null and b/src/assets/images/tablets.png differ diff --git a/src/components/BackLink/BackLink.module.scss b/src/components/BackLink/BackLink.module.scss new file mode 100644 index 00000000000..06961432621 --- /dev/null +++ b/src/components/BackLink/BackLink.module.scss @@ -0,0 +1,23 @@ +@use '../../styles' as s; + +.backLink { + display: inline-flex; + align-items: center; + gap: 4px; + width: fit-content; + padding: 0; + border: 0; + background: transparent; + color: var(--text-color-secondary); + cursor: pointer; + font: inherit; + font-size: 12px; + font-weight: 700; + + text-decoration: none; + transition: color s.$effect-duration ease; + + &:hover { + color: var(--button-main-color); + } +} diff --git a/src/components/BackLink/BackLink.tsx b/src/components/BackLink/BackLink.tsx new file mode 100644 index 00000000000..3004e3b12da --- /dev/null +++ b/src/components/BackLink/BackLink.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +import { ChevronIcon } from '../iconsSVG'; +import styles from './BackLink.module.scss'; + +type Props = { + to?: string; + onClick?: () => void; + label?: string; +}; + +export const BackLink: React.FC = ({ to, onClick, label = 'Back' }) => { + const content = ( + <> + + {label} + + ); + + if (to) { + return ( + + {content} + + ); + } + + return ( + + ); +}; diff --git a/src/components/BackLink/index.ts b/src/components/BackLink/index.ts new file mode 100644 index 00000000000..907b5bb3c82 --- /dev/null +++ b/src/components/BackLink/index.ts @@ -0,0 +1 @@ +export { BackLink } from './BackLink'; diff --git a/src/components/BreadCrumbs/BreadCrumbs.module.scss b/src/components/BreadCrumbs/BreadCrumbs.module.scss new file mode 100644 index 00000000000..82bf6905472 --- /dev/null +++ b/src/components/BreadCrumbs/BreadCrumbs.module.scss @@ -0,0 +1,87 @@ +@use '../../styles' as s; + +.breadcrumb { + margin: 24px 0; + + @include s.on-tablet { + margin: 24px 0 40px; + } + + &__inner { + display: flex; + align-items: center; + width: 100%; + box-sizing: border-box; + } + + &__list { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; + } + + &__item { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + padding: 0; + } + + &__separator { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + flex: 0 0 auto; + color: var(--text-main-color); + } + + &__home { + display: inline-flex; + align-items: center; + justify-content: center; + text-decoration: none; + color: var(--text-main-color); + padding: 0; + } + + &__icon { + display: block; + height: 16px; + width: 16px; + } + + &__link { + display: block; + font-size: 12px; + color: var(--text-main-color); + text-decoration: none; + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 140px; + + transition: color s.$effect-duration ease; + + &:hover { + outline: none; + text-decoration: none; + } + + @include s.on-tablet { + max-width: 300px; + } + } + + &__link--active { + color: var(--text-color-secondary); + font-weight: 700; + text-decoration: none; + } +} diff --git a/src/components/BreadCrumbs/BreadCrumbs.tsx b/src/components/BreadCrumbs/BreadCrumbs.tsx new file mode 100644 index 00000000000..34f0a2381f0 --- /dev/null +++ b/src/components/BreadCrumbs/BreadCrumbs.tsx @@ -0,0 +1,155 @@ +import React, { useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import cn from 'classnames'; +import type { Product } from '../../types/product'; +import { ChevronIcon, HomeIcon } from '../iconsSVG'; +import styles from './BreadCrumbs.module.scss'; + +type Crumb = { label: string; to?: string }; + +type SimpleProduct = Partial>; + +type Props = { + location?: string[]; + product?: SimpleProduct; + category?: string; + name?: string; + overrideCrumbs?: Crumb[]; +}; + +function categoryToRoute(cat?: string): { + route: string | null; + label: string | null; +} { + if (!cat) { + return { route: null, label: null }; + } + + const categoryName = String(cat).toLowerCase(); + + if (categoryName.includes('phone')) { + return { route: 'phones', label: 'Phones' }; + } + + if (categoryName.includes('tablet')) { + return { route: 'tablets', label: 'Tablets' }; + } + + if (categoryName.includes('accessory')) { + return { route: 'accessories', label: 'Accessories' }; + } + + if (categoryName.includes('favourites')) { + return { route: 'favourites', label: 'Favourites' }; + } + + const label = String(cat) + .replace(/[-_]/g, ' ') + .replace(/\b\w/g, ch => String(ch).toUpperCase()); + const route = String(cat).toLowerCase().replace(/\s+/g, '-'); + + return { route, label }; +} + +function formatBreadcrumbLabel(str: string) { + if (!str) { + return ''; + } + + const pretty = str.replace(/[-_]/g, ' '); + + return pretty.charAt(0).toUpperCase() + pretty.slice(1); +} + +export const BreadCrumbs: React.FC = ({ + location, + product, + category, + name, + overrideCrumbs, +}) => { + const crumbs = useMemo(() => { + if (overrideCrumbs && overrideCrumbs.length) { + return overrideCrumbs; + } + + if (location && location.length) { + const result: Crumb[] = [{ label: 'Home', to: '/' }]; + + location.forEach(loc => { + result.push({ label: formatBreadcrumbLabel(loc), to: `/${loc}` }); + }); + + return result; + } + + const result: Crumb[] = [{ label: 'Home', to: '/' }]; + const rawCategory = product?.category ?? category ?? ''; + const { route, label } = categoryToRoute(rawCategory); + + if (route && label) { + result.push({ label, to: `/${route}` }); + } + + const productName = product?.name ?? name; + + if (productName) { + result.push({ label: String(productName) }); + } + + return result; + }, [category, location, name, overrideCrumbs, product]); + + if (crumbs.length === 0) { + return null; + } + + return ( + + ); +}; diff --git a/src/components/BreadCrumbs/index.ts b/src/components/BreadCrumbs/index.ts new file mode 100644 index 00000000000..8ffa35f61fd --- /dev/null +++ b/src/components/BreadCrumbs/index.ts @@ -0,0 +1 @@ +export * from './BreadCrumbs'; diff --git a/src/components/CartItem/CartItem.module.scss b/src/components/CartItem/CartItem.module.scss new file mode 100644 index 00000000000..7eca7c0447f --- /dev/null +++ b/src/components/CartItem/CartItem.module.scss @@ -0,0 +1,163 @@ +@use '../../styles' as s; + +.cartItemWrap { + list-style: none; + margin: 0; +} + +.cartItem { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 16px; + padding: 16px; + background: var(--bg-card-color); + border: 1px solid var(--border-card); + box-sizing: border-box; + + @include s.on-tablet { + flex-direction: row; + align-items: center; + justify-content: space-between; + min-height: 128px; + padding: 24px; + } + + &__remove { + align-self: center; + appearance: none; + border: 0; + background: transparent; + color: var(--text-color-secondary); + cursor: pointer; + margin-right: 6px; + margin-bottom: 0; + } + + &__left { + display: flex; + align-items: center; + gap: 16px; + min-width: 0; + + @include s.on-tablet { + gap: 24px; + } + } + + &__media { + width: 80px; + height: 80px; + flex: 0 0 80px; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + } + + &__img { + max-width: 66px; + max-height: 66px; + object-fit: contain; + display: block; + } + + &__info { + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; + } + + &__title { + margin: 0; + font-size: 14px; + font-weight: 500; + color: var(--text-main-color); + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: normal; + } + + &__controls { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + width: 100%; + box-sizing: border-box; + + @include s.on-tablet { + flex: 0 0 176px; + justify-content: flex-start; + align-items: center; + gap: 24px; + } + } + + &__qty { + display: inline-flex; + align-items: center; + gap: 14px; + border: 0; + padding: 0; + background: transparent; + } + + &__qtybtn { + width: 32px; + height: 32px; + appearance: none; + border: 1px solid var(--border-color); + background: transparent; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + padding: 0; + color: var(--text-main-color); + line-height: 0; + + &:disabled { + color: var(--text-color-secondary); + cursor: default; + opacity: 0.45; + } + + svg { + display: block; + margin: auto; + } + } + + &__qtyval { + min-width: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--text-main-color); + font-weight: 700; + } + + &__price { + text-align: right; + min-width: 80px; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; + } + + &__old { + font-size: 12px; + color: var(--text-color-secondary); + text-decoration: line-through; + } + + &__current { + font-weight: 800; + font-size: 22px; + color: var(--text-main-color); + } +} diff --git a/src/components/CartItem/CartItem.tsx b/src/components/CartItem/CartItem.tsx new file mode 100644 index 00000000000..184cf553751 --- /dev/null +++ b/src/components/CartItem/CartItem.tsx @@ -0,0 +1,123 @@ +import React, { useMemo } from 'react'; +import { useAppDispatch } from '../../app/hooks'; +import type { CartItem as CartItemType } from '../../features/cart/cartSlice'; +import { changeQuantity, removeFromCart } from '../../features/cart/cartSlice'; +import { CloseIcon, MinusIcon, PlusIcon } from '../iconsSVG'; +import styles from './CartItem.module.scss'; + +interface Props { + item: CartItemType; +} + +export const CartItem: React.FC = ({ item }) => { + const dispatch = useAppDispatch(); + const { product, quantity } = item; + const hasDiscount = + product.fullPrice !== undefined && product.fullPrice > product.price; + + const formattedPrice = useMemo( + () => + `$${product.price.toLocaleString(undefined, { + minimumFractionDigits: 0, + maximumFractionDigits: 0, + })}`, + [product.price], + ); + + const formattedOldPrice = useMemo(() => { + if (!hasDiscount) { + return null; + } + + return `$${product.fullPrice?.toLocaleString(undefined, { + minimumFractionDigits: 0, + maximumFractionDigits: 0, + })}`; + }, [hasDiscount, product.fullPrice]); + + const handleDecrease = () => { + if (quantity > 1) { + dispatch(changeQuantity({ itemId: product.itemId, delta: -1 })); + } + }; + + const handleIncrease = () => { + dispatch(changeQuantity({ itemId: product.itemId, delta: 1 })); + }; + + const handleRemove = () => { + dispatch(removeFromCart(product.itemId)); + }; + + return ( +
  • +
    +
    + + +
    + {product.name} +
    + +
    +

    + {product.name} +

    +
    +
    + +
    +
    + + + + {quantity} + + + +
    + +
    + {hasDiscount && ( +
    {formattedOldPrice}
    + )} +
    {formattedPrice}
    +
    +
    +
    +
  • + ); +}; diff --git a/src/components/CartItem/index.ts b/src/components/CartItem/index.ts new file mode 100644 index 00000000000..37a05535402 --- /dev/null +++ b/src/components/CartItem/index.ts @@ -0,0 +1 @@ +export * from './CartItem'; diff --git a/src/components/ConfirmModal/ConfirmModal.module.scss b/src/components/ConfirmModal/ConfirmModal.module.scss new file mode 100644 index 00000000000..bb7d2437844 --- /dev/null +++ b/src/components/ConfirmModal/ConfirmModal.module.scss @@ -0,0 +1,60 @@ +@use '../../styles' as s; + +.confirmModal { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: s.$black; + + &__content { + width: 100%; + max-width: 320px; + padding: 24px; + background: var(--bg-card-color); + border: 1px solid var(--border-color); + color: var(--text-main-color); + } + + &__message { + margin-bottom: 16px; + font-size: 16px; + font-weight: 600; + } + + &__actions { + display: flex; + justify-content: flex-end; + gap: 8px; + } + + &__confirm, + &__cancel { + padding: 8px 16px; + border: 1px solid var(--border-color); + cursor: pointer; + transition: + background s.$effect-duration ease, + color s.$effect-duration ease, + border-color s.$effect-duration ease; + } + + &__confirm { + background: var(--button-main-color); + color: var(--button-text-color); + + &:hover { + background: var(--button-main-color-hover); + } + } + + &__cancel { + background: transparent; + color: var(--text-main-color); + + &:hover { + border-color: var(--text-main-color); + } + } +} diff --git a/src/components/ConfirmModal/ConfirmModal.tsx b/src/components/ConfirmModal/ConfirmModal.tsx new file mode 100644 index 00000000000..f24df1ca5b2 --- /dev/null +++ b/src/components/ConfirmModal/ConfirmModal.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import styles from './ConfirmModal.module.scss'; + +interface ConfirmModalProps { + isOpen: boolean; + message: string; + onConfirm: () => void; + onCancel: () => void; +} + +export const ConfirmModal: React.FC = ({ + isOpen, + message, + onConfirm, + onCancel, +}) => { + if (!isOpen) { + return null; + } + + return ( +
    +
    e.stopPropagation()} + role="dialog" + aria-modal="true" + > +

    {message}

    +
    + + +
    +
    +
    + ); +}; diff --git a/src/components/ConfirmModal/index.ts b/src/components/ConfirmModal/index.ts new file mode 100644 index 00000000000..353d4b1ef86 --- /dev/null +++ b/src/components/ConfirmModal/index.ts @@ -0,0 +1 @@ +export * from './ConfirmModal'; diff --git a/src/components/Footer/Footer.module.scss b/src/components/Footer/Footer.module.scss new file mode 100644 index 00000000000..68bf294fa38 --- /dev/null +++ b/src/components/Footer/Footer.module.scss @@ -0,0 +1,122 @@ +@use '../../styles/' as s; + +.footer { + border-top: 1px solid var(--border-color); + margin-top: 56px; + + @include s.on-tablet { + margin-top: 64px; + } + + @include s.on-desktop { + margin-top: 80px; + } + + &__container { + @include s.page-container; + } + + &__content { + display: flex; + flex-direction: column; + justify-content: space-between; + + padding: 32px 0; + gap: 32px; + + @include s.on-tablet { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + + padding: 32px 0; + gap: 24px; + } + } + + &__logo { + img { + width: 64px; + height: 22px; + display: block; + } + } + + &__nav { + display: flex; + flex-direction: column; + gap: 16px; + + @include s.on-tablet { + flex-direction: row; + align-items: center; + gap: 14px; + justify-content: center; + } + + @include s.on-desktop { + gap: 107px; + } + + } + + &__link { + font-size: 12px; + font-weight: 800; + line-height: 11px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-main-color); + text-decoration: none; + + transition: color s.$effect-duration ease; + + &:hover { + color: var(--button-main-color); + } + } + + &__top { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + + @include s.on-desktop { + display: inline-flex; + align-items: center; + white-space: nowrap; + } + } + + &__top-text { + font-size: 12px; + font-weight: 600; + color: var(--text-color-secondary); + } + + &__top-btn { + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--added-button); + color: var(--text-main-color); + border: 1px solid var(--fav-btn-border); + cursor: pointer; + + transition: + border-color s.$effect-duration ease, + transform s.$effect-duration ease; + + &:hover { + border-color: var(--icons-hover); + } + + &:active { + transform: scale(0.96); + } + } +} diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 00000000000..4fdeb2bee72 --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import cn from 'classnames'; +import styles from './Footer.module.scss'; +import { scrollToTop } from '../../utils/scrollToTop'; +import { Link } from 'react-router-dom'; + +import logo from '../../assets/icons/logo.png'; +import logoLight from '../../assets/icons/logo-light.png'; +import { useTheme } from '../../context/ThemeContext'; +import { ChevronIcon } from '../iconsSVG'; + +export const Footer: React.FC = () => { + const { theme } = useTheme(); + + return ( + + ); +}; diff --git a/src/components/Footer/index.ts b/src/components/Footer/index.ts new file mode 100644 index 00000000000..ddcc5a9cd18 --- /dev/null +++ b/src/components/Footer/index.ts @@ -0,0 +1 @@ +export * from './Footer'; diff --git a/src/components/Header/Header.module.scss b/src/components/Header/Header.module.scss new file mode 100644 index 00000000000..f09b2219064 --- /dev/null +++ b/src/components/Header/Header.module.scss @@ -0,0 +1,404 @@ +@use '../../styles' as s; + +.header { + position: sticky; + top: 0; + width: 100%; + z-index: 1; + background-color: var(--theme-background); + + &__container { + margin: 0 auto; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: flex-start; + border-bottom: 1px solid var(--border-color); + } + + &__logo { + img { + width: 64px; + height: 22px; + display: block; + padding: 13px 16px; + margin-right: 0; + + @include s.on-tablet { + margin-right: 24px; + } + + @include s.on-desktop { + padding: 18px 24px; + } + } + } + + &__nav { + display: none; + + @include s.on-tablet { + display: flex; + gap: 32px; + height: 48px; + align-items: stretch; + } + + @include s.on-desktop { + height: 64px; + gap: 64px; + } + } + + &__link { + position: relative; + display: inline-flex; + align-items: center; + color: var(--text-color-secondary); + text-decoration: none; + text-transform: uppercase; + font-size: 12px; + font-weight: 800; + letter-spacing: .04em; + line-height: 11px; + transition: color s.$effect-duration ease; + + &::after { + content: ''; + position: absolute; + left: 0; + bottom: 0; + width: 100%; + height: 3px; + background: var(--text-main-color); + transform-origin: left center; + transform: scaleX(0); + transition: + transform s.$effect-duration cubic-bezier(.2, .9, .3, 1), + opacity s.$effect-duration linear; + pointer-events: none; + z-index: 0; + opacity: 1; + } + + &:hover, + &:focus { + color: var(--text-main-color); + + &::after { + transform: scaleX(1); + } + } + + &--active { + color: var(--text-main-color); + + &::after { + background: var(--text-main-color); + transform: scaleX(1); + opacity: 1; + } + } + } + + &__actions { + margin-left: auto; + display: flex; + align-items: center; + flex: 1 1 auto; + gap: 4px; + min-width: 0; + + @include s.on-tablet { + gap: 12px; + justify-content: flex-end; + } + } + + &__search { + position: relative; + display: flex; + align-items: center; + flex: 1 1 auto; + margin-left: 0; + width: auto; + min-width: 0; + height: 48px; + box-sizing: border-box; + + @include s.on-tablet { + flex: 0 1 280px; + height: 48px; + } + + @include s.on-desktop { + flex-basis: 320px; + height: 64px; + } + } + + &__searchIcon { + position: absolute; + left: 4px; + color: var(--text-color-secondary); + pointer-events: none; + + @include s.on-tablet { + left: 16px; + } + } + + &__searchInput { + width: 100%; + height: 100%; + box-sizing: border-box; + padding: 0 20px; + border: 0; + border-left: 1px solid var(--border-color); + background: transparent; + color: var(--text-main-color); + font: inherit; + font-size: 12px; + outline: none; + transition: + border-color s.$effect-duration ease, + background-color s.$effect-duration ease; + + @include s.on-tablet { + padding-left: 44px; + padding-right: 44px; + } + + &::placeholder { + color: var(--text-color-secondary); + } + + &::-webkit-search-cancel-button { + appearance: none; + } + + &:hover, + &:focus { + border-color: var(--text-main-color); + background-color: var(--button-main-color-selected); + } + } + + &__searchPlaceholder { + position: absolute; + left: 26px; + right: 28px; + overflow: hidden; + color: var(--text-color-secondary); + font-size: 12px; + line-height: 1; + pointer-events: none; + text-overflow: ellipsis; + white-space: nowrap; + + @include s.on-desktop { + left: 44px; + right: 44px; + } + + &--full { + display: none; + + @include s.on-tablet { + display: block; + } + } + + &--compact { + display: block; + + @include s.on-tablet { + display: none; + } + } + } + + &__searchClear { + position: absolute; + right: 1px; + display: inline-flex; + align-items: center; + justify-content: right; + width: 24px; + height: 24px; + padding: 0; + border: 0; + background: transparent; + color: var(--text-color-secondary); + cursor: pointer; + + @include s.on-tablet { + right: 14px; + } + + &:hover { + color: var(--text-main-color); + } + + svg { + width: 14px; + height: 14px; + } + } + + &__icons { + display: flex; + align-items: center; + margin-left: auto; + + @include s.on-tablet { + margin-left: 0; + } + } + + &__theme { + display: flex; + align-items: center; + margin-right: 0; + + @include s.on-tablet { + margin-right: 6px; + } + } + + &__iconWrap { + position: relative; + width: 40px; + height: 40px; + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + + @include s.on-desktop { + width: 64px; + height: 64px; + padding: 12px; + } + } + + &__iconBtn { + position: relative; + display: inline-block; + align-items: center; + justify-content: center; + width: 47px; + height: 48px; + text-decoration: none; + color: var(--text-main-color); + background: transparent; + border: none; + cursor: pointer; + padding: 0; + + &::after { + content: ''; + position: absolute; + left: 50%; + transform: translateX(-50%); + bottom: 0; + width: 100%; + height: 3px; + background: transparent; + opacity: 0; + transition: + background s.$effect-duration ease, + opacity s.$effect-duration ease; + pointer-events: none; + z-index: 0; + } + + &:hover { + &::after { + background: var(--text-main-color); + opacity: 1; + } + } + + @include s.on-mobile { + display: none; + } + + @include s.on-tablet { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + background: none; + border-left: 1px solid var(--border-color); + cursor: pointer; + text-decoration: none; + } + + @include s.on-desktop { + width: 63px; + height: 64px; + } + + &--active { + color: var(--text-main-color); + + &::after { + background: var(--text-main-color); + opacity: 1; + } + } + } + + &__badge { + position: absolute; + top: 0; + right: 0; + transform: translate(-50%, 50%); + + display: inline-flex; + align-items: center; + justify-content: center; + + width: 16px; + height: 16px; + padding: 0 6px; + box-sizing: border-box; + + background: s.$red; + color: s.$white; + font-size: 9px; + font-weight: 700; + line-height: 1; + border-radius: 999px; + border: 1px solid var(--theme-background); + white-space: nowrap; + pointer-events: none; + + @include s.on-desktop { + transform: translate(-100%, 100%); + } + } + + &__burger { + width: 48px; + height: 48px; + padding: 0; + display: inline-block; + background: none; + border: none; + cursor: pointer; + border-left: 1px solid var(--border-color); + color: var(--text-main-color); + + @include s.on-tablet { + display: none; + } + + svg { + height: 16px; + padding: 16px; + display: block; + } + } +} diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 00000000000..9eddb40cbae --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,256 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Link, NavLink, useLocation, useSearchParams } from 'react-router-dom'; +import cn from 'classnames'; +import { useAppSelector } from '../../app/hooks'; +import { selectCartTotalQuantity } from '../../features/cart/cartSlice'; +import { selectFavoritesCount } from '../../features/favorites/favoritesSlice'; +import { Menu } from './Menu'; +import { ThemeToggle } from '../ThemeToggle/ThemeToggle'; +import { useTheme } from '../../context/ThemeContext'; +import { useDebounce } from '../../utils/debounce'; + +import styles from './Header.module.scss'; +import logo from '../../assets/icons/logo.png'; +import logoLight from '../../assets/icons/logo-light.png'; +import { + BurgerIcon, + CartIcon, + CloseIcon, + HeartIcon, + SearchIcon, +} from '../iconsSVG'; + +const PRODUCT_LIST_PATHS = [ + '/phones', + '/tablets', + '/accessories', + '/favorites', +]; +const SEARCH_DEBOUNCE_DELAY = 300; + +export const Header: React.FC = () => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const cartCount = useAppSelector(selectCartTotalQuantity); + const favCount = useAppSelector(selectFavoritesCount); + const { theme } = useTheme(); + const location = useLocation(); + const [searchParams, setSearchParams] = useSearchParams(); + const searchParamsKey = searchParams.toString(); + const isProductListPage = PRODUCT_LIST_PATHS.includes(location.pathname); + const [searchValue, setSearchValue] = useState(''); + const debouncedSearchValue = useDebounce(searchValue, SEARCH_DEBOUNCE_DELAY); + + const searchPlaceholder = useMemo(() => { + const category = location.pathname.slice(1); + + return category ? `Search in ${category}...` : 'Search...'; + }, [location.pathname]); + + const toggleMenu = () => setIsMenuOpen(prev => !prev); + const clearSearch = () => setSearchValue(''); + + useEffect(() => { + const queryFromUrl = isProductListPage + ? (searchParams.get('query') ?? '') + : ''; + + setSearchValue(currentValue => { + if (!isProductListPage) { + return currentValue ? '' : currentValue; + } + + if (currentValue.trim() === queryFromUrl.trim()) { + return currentValue; + } + + return queryFromUrl; + }); + }, [isProductListPage, location.pathname, searchParams, searchParamsKey]); + + useEffect(() => { + if (!isProductListPage) { + return; + } + + const params = new URLSearchParams(searchParams); + const normalizedSearch = debouncedSearchValue.trim(); + + if ((params.get('query') ?? '') === normalizedSearch) { + return; + } + + if (normalizedSearch) { + params.set('query', normalizedSearch); + } else { + params.delete('query'); + } + + params.delete('page'); + setSearchParams(params, { replace: true }); + }, [debouncedSearchValue, isProductListPage, searchParams, setSearchParams]); + + return ( +
    +
    + + Logo + + + + +
    + {isProductListPage && ( +
    event.preventDefault()} + > + + + setSearchValue(event.target.value)} + /> + + {!searchValue && ( + <> + + {searchPlaceholder} + + + + Search in... + + + )} + + {searchValue && ( + + )} + + )} + +
    +
    + +
    + + + cn(styles.header__iconBtn, { + [styles['header__iconBtn--active']]: isActive, + }) + } + > + + {favCount > 0 && ( + {favCount} + )} + + + + cn(styles.header__iconBtn, { + [styles['header__iconBtn--active']]: isActive, + }) + } + > + + {cartCount > 0 && ( + {cartCount} + )} + + + +
    +
    +
    + + setIsMenuOpen(false)} /> +
    + ); +}; diff --git a/src/components/Header/Menu.module.scss b/src/components/Header/Menu.module.scss new file mode 100644 index 00000000000..481f6fc4734 --- /dev/null +++ b/src/components/Header/Menu.module.scss @@ -0,0 +1,179 @@ +@use '../../styles/index' as s; + +.menu { + position: fixed; + top: 0; + right: 0; + width: 100%; + height: 100vh; + background: var(--theme-background); + color: var(--text-main-color); + transform: translateX(100%); + transition: transform s.$effect-duration ease; + display: flex; + flex-direction: column; + box-sizing: border-box; + z-index: 2; + + &--open { + transform: translateX(0); + } + + @include s.on-tablet { + display: none; + } + + &__top { + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--border-color); + } + + &__logo { + img { + height: 22px; + display: block; + padding: 13px 16px; + } + } + + &__close { + width: 48px; + height: 48px; + display: inline-block; + background: none; + border: none; + border-left: 1px solid var(--border-color); + cursor: pointer; + padding: 0; + color: var(--text-main-color); + + svg { + height: 16px; + padding: 16px; + display: block; + } + } + + &__nav { + display: flex; + flex-direction: column; + gap: 16px; + margin-top: 24px; + justify-content: center; + align-items: center; + } + + &__link { + position: relative; + text-transform: uppercase; + text-decoration: none; + color: var(--text-color-secondary); + font-weight: 800; + font-size: 12px; + letter-spacing: 0.04em; + line-height: 11px; + padding: 8px 0; + transition: color s.$effect-duration ease; + + &::after { + content: ''; + position: absolute; + left: 0; + bottom: 0; + width: 100%; + height: 3px; + background: var(--text-main-color); + transform-origin: left center; + transform: scaleX(0); + transition: + transform s.$effect-duration cubic-bezier(.2, .9, .3, 1), + opacity s.$effect-duration linear; + pointer-events: none; + opacity: 1; + } + + &:hover, + &:focus { + color: var(--text-main-color); + + &::after { + transform: scaleX(1); + } + } + + &--active { + color: var(--text-main-color); + + &::after { + transform: scaleX(1); + } + } + } + + &__bottom { + position: absolute; + left: 0; + right: 0; + bottom: 0; + display: flex; + height: 64px; + background: transparent; + border-top: 1px solid var(--border-color); + } + + &__bottomBtn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + text-decoration: none; + color: var(--text-main-color); + background: transparent; + border-right: 1px solid var(--border-color); + cursor: pointer; + + &--active { + color: var(--text-main-color); + border-bottom: 3px solid var(--text-main-color); + } + } + + &__iconWrap { + position: relative; + display: inline-flex; + justify-content: center; + } + + &__badge { + position: absolute; + top: 0; + right: 0; + transform: translate(50%, -50%); + display: inline-flex; + align-items: center; + justify-content: center; + + min-width: 14px; + height: 14px; + padding: 0 4px; + box-sizing: border-box; + + background: s.$red; + color: s.$white; + font-size: 9px; + font-weight: 700; + line-height: 1; + border-radius: 999px; + border: 1px solid var(--theme-background); + white-space: nowrap; + pointer-events: none; + z-index: 3; + + &:hover, + &:focus { + color: var(--text-main-color); + } + } +} diff --git a/src/components/Header/Menu.tsx b/src/components/Header/Menu.tsx new file mode 100644 index 00000000000..5c909d5d6c5 --- /dev/null +++ b/src/components/Header/Menu.tsx @@ -0,0 +1,138 @@ +import React from 'react'; +import { Link, NavLink } from 'react-router-dom'; +import cn from 'classnames'; +import { useAppSelector } from '../../app/hooks'; +import { selectCartTotalQuantity } from '../../features/cart/cartSlice'; +import { selectFavoritesCount } from '../../features/favorites/favoritesSlice'; +import { useLockBodyScroll } from '../../hooks/useLockBodyScroll'; +import styles from './Menu.module.scss'; + +import logo from '../../assets/icons/logo.png'; +import logoLight from '../../assets/icons/logo-light.png'; +import { CartIcon, CloseIcon, HeartIcon } from '../iconsSVG'; +import { useTheme } from '../../context/ThemeContext'; + +interface Props { + isOpen: boolean; + onClose: () => void; +} + +export const Menu: React.FC = ({ isOpen, onClose }) => { + const cartCount = useAppSelector(selectCartTotalQuantity); + const favCount = useAppSelector(selectFavoritesCount); + const { theme } = useTheme(); + + useLockBodyScroll(isOpen); + + return ( + + ); +}; diff --git a/src/components/Header/index.ts b/src/components/Header/index.ts new file mode 100644 index 00000000000..266dec8a1bc --- /dev/null +++ b/src/components/Header/index.ts @@ -0,0 +1 @@ +export * from './Header'; diff --git a/src/components/Layout/Layout.module.scss b/src/components/Layout/Layout.module.scss new file mode 100644 index 00000000000..ff19f137a6d --- /dev/null +++ b/src/components/Layout/Layout.module.scss @@ -0,0 +1,16 @@ +@use '../../styles' as s; + +.layout { + display: flex; + flex-direction: column; + min-height: 100dvh; +} + +.main { + flex: 1 1 auto; + min-height: 0; +} + +.container { + @include s.page-container; +} diff --git a/src/components/Layout/Layout.tsx b/src/components/Layout/Layout.tsx new file mode 100644 index 00000000000..17bacb9a30a --- /dev/null +++ b/src/components/Layout/Layout.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { Outlet } from 'react-router-dom'; +import { Header } from '../Header/Header'; +import { Footer } from '../Footer/Footer'; +import styles from './Layout.module.scss'; + +export const Layout: React.FC = () => { + return ( +
    +
    +
    +
    + +
    +
    +
    +
    + ); +}; diff --git a/src/components/Layout/index.ts b/src/components/Layout/index.ts new file mode 100644 index 00000000000..9877e7f4ae2 --- /dev/null +++ b/src/components/Layout/index.ts @@ -0,0 +1 @@ +export * from './Layout'; diff --git a/src/components/Loader/Loader.module.scss b/src/components/Loader/Loader.module.scss new file mode 100644 index 00000000000..bec86eb9cf7 --- /dev/null +++ b/src/components/Loader/Loader.module.scss @@ -0,0 +1,27 @@ +@use '../../styles' as s; + +.loader { + display: flex; + width: 100%; + justify-content: center; + align-items: center; + + &__content { + border-radius: 50%; + width: 2em; + height: 2em; + margin: 1em auto; + border: 0.3em solid s.$white; + border-left-color: s.$black; + animation: load8 1.2s infinite linear; + } +} + +@keyframes load8 { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} diff --git a/src/components/Loader/Loader.tsx b/src/components/Loader/Loader.tsx new file mode 100644 index 00000000000..348ce22fda6 --- /dev/null +++ b/src/components/Loader/Loader.tsx @@ -0,0 +1,7 @@ +import styles from './Loader.module.scss'; + +export const Loader = () => ( +
    +
    +
    +); diff --git a/src/components/Loader/index.ts b/src/components/Loader/index.ts new file mode 100644 index 00000000000..d5ce981151f --- /dev/null +++ b/src/components/Loader/index.ts @@ -0,0 +1 @@ +export * from './Loader'; diff --git a/src/components/Pagination/Pagination.module.scss b/src/components/Pagination/Pagination.module.scss new file mode 100644 index 00000000000..72ab337a977 --- /dev/null +++ b/src/components/Pagination/Pagination.module.scss @@ -0,0 +1,79 @@ +@use '../../styles' as s; + +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + margin: 40px 0 0; + + &__pages { + display: flex; + align-items: center; + gap: 16px; + } + + &__button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: 1px solid var(--pagination-border-color); + background: var(--pagination-bg); + color: var(--text-main-color); + cursor: pointer; + font: inherit; + font-size: 14px; + font-weight: 600; + line-height: 1; + transition: + background-color s.$effect-duration ease, + border-color s.$effect-duration ease, + color s.$effect-duration ease; + + &:disabled { + border-color: var(--pagination-disabled-border-color); + background: var(--pagination-disabled-bg); + color: var(--pagination-disabled-text-color); + cursor: default; + } + + &:not(:disabled):hover { + border-color: var(--pagination-hover-border-color); + background: var(--pagination-hover-bg); + } + + &--active { + border-color: var(--pagination-selected-border-color); + background: var(--pagination-selected-bg); + color: var(--pagination-selected-text-color); + cursor: default; + + &:hover { + border-color: var(--pagination-selected-border-color); + background: var(--pagination-selected-bg); + } + } + } + + &__button--prev, + &__button--next { + svg { + width: 16px; + height: 16px; + } + } + + &__ellipsis { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + color: var(--text-color-secondary); + font-size: 14px; + font-weight: 600; + } +} diff --git a/src/components/Pagination/Pagination.tsx b/src/components/Pagination/Pagination.tsx new file mode 100644 index 00000000000..b6486da6efc --- /dev/null +++ b/src/components/Pagination/Pagination.tsx @@ -0,0 +1,137 @@ +import React, { useMemo } from 'react'; +import cn from 'classnames'; + +import { ChevronIcon } from '../iconsSVG'; +import styles from './Pagination.module.scss'; + +type PageItem = number | 'ellipsis'; + +interface PaginationProps { + page: number; + totalPages: number; + onPageChange: (page: number) => void; + maxVisible?: number; + className?: string; +} + +const buildPages = ( + current: number, + total: number, + maxVisible: number, +): PageItem[] => { + const maxPages = Math.max(3, Math.min(maxVisible, total)); + const half = Math.floor(maxPages / 2); + + if (total <= maxPages) { + return Array.from({ length: total }, (_, index) => index + 1); + } + + let start = Math.max(1, current - half); + let end = start + maxPages - 1; + + if (end > total) { + end = total; + start = Math.max(1, end - maxPages + 1); + } + + const pages: PageItem[] = []; + + if (start > 1) { + pages.push(1); + if (start > 2) { + pages.push('ellipsis'); + } + } + + for (let page = start; page <= end; page++) { + pages.push(page); + } + + if (end < total) { + if (end < total - 1) { + pages.push('ellipsis'); + } + + pages.push(total); + } + + return pages; +}; + +export const Pagination: React.FC = ({ + page, + totalPages, + onPageChange, + maxVisible = 5, + className, +}) => { + const pages = useMemo( + () => buildPages(page, totalPages, maxVisible), + [maxVisible, page, totalPages], + ); + + if (totalPages <= 1) { + return null; + } + + return ( + + ); +}; diff --git a/src/components/Pagination/index.tsx b/src/components/Pagination/index.tsx new file mode 100644 index 00000000000..e016c96b72e --- /dev/null +++ b/src/components/Pagination/index.tsx @@ -0,0 +1 @@ +export * from './Pagination'; diff --git a/src/components/ProductActions/ProductActions.module.scss b/src/components/ProductActions/ProductActions.module.scss new file mode 100644 index 00000000000..cd2196cd143 --- /dev/null +++ b/src/components/ProductActions/ProductActions.module.scss @@ -0,0 +1,57 @@ +@use '../../styles' as s; + +.productActions { + &__cartButton, + &__favoriteButton { + cursor: pointer; + transition: + background-color s.$effect-duration ease, + border-color s.$effect-duration ease, + color s.$effect-duration ease, + } + + &__cartButton { + flex: 1; + border: 0; + background: var(--button-main-color); + color: var(--button-text-color); + + &:hover { + background: var(--button-main-color-hover); + color: var(--hover-text-color); + } + + &--added { + border: 1px solid var(--border-color); + background: var(--added-button); + color: var(--added-text-color); + } + } + + &__favoriteButton { + display: inline-flex; + align-items: center; + justify-content: right; + width: 40px; + border: 1px solid var(--fav-btn-border); + background: var(--fav-btn-bg); + color: var(--text-main-color); + + &:hover { + border-color: var(--border-color); + } + + &--selected { + background: var(--fav-btn-bg-active); + } + + &:active { + transform: scale(0.96); + } + } + + &__icon { + width: 21px; + height: 21px; + } +} diff --git a/src/components/ProductActions/ProductActions.tsx b/src/components/ProductActions/ProductActions.tsx new file mode 100644 index 00000000000..672a53fc5c8 --- /dev/null +++ b/src/components/ProductActions/ProductActions.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import cn from 'classnames'; + +import { HeartIcon } from '../iconsSVG'; +import styles from './ProductActions.module.scss'; + +interface Props { + isInCart: boolean; + isFavorited: boolean; + onCartClick: () => void; + onFavoriteClick: () => void; +} + +export const ProductActions: React.FC = ({ + isInCart, + isFavorited, + onCartClick, + onFavoriteClick, +}) => ( + <> + + + + +); diff --git a/src/components/ProductActions/index.ts b/src/components/ProductActions/index.ts new file mode 100644 index 00000000000..812ea48911f --- /dev/null +++ b/src/components/ProductActions/index.ts @@ -0,0 +1 @@ +export { ProductActions } from './ProductActions'; diff --git a/src/components/ProductCard/ProductCard.module.scss b/src/components/ProductCard/ProductCard.module.scss new file mode 100644 index 00000000000..87089ae62e5 --- /dev/null +++ b/src/components/ProductCard/ProductCard.module.scss @@ -0,0 +1,105 @@ +@use '../../styles' as s; + +.product-card { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + height: 100%; + box-sizing: border-box; + padding: 32px; + background: var(--bg-card-color); + border: 1px solid var(--border-card); + + &__image-wrapper { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 208px; + flex-shrink: 0; + + &:hover img { + transform: scale(1.1); + } + } + + &__image { + display: block; + width: 100%; + max-width: 190px; + height: 100%; + max-height: 190px; + object-fit: contain; + transition: transform s.$effect-duration ease; + } + + &__content { + display: flex; + flex-direction: column; + gap: 8px; + } + + &__title { + margin: 0; + height: 38px; + padding-top: 24px; + color: var(--text-main-color); + } + + &__prices { + display: flex; + gap: 8px; + align-items: baseline; + flex-wrap: wrap; + } + + &__price-current { + font-size: 22px; + font-weight: 800; + color: var(--text-main-color); + } + + &__price-old { + font-size: 22px; + font-weight: 600; + text-decoration: line-through; + color: var(--text-color-secondary); + } + + &__divider { + border-bottom: 1px solid var(--border-color); + } + + &__specs { + display: grid; + gap: 8px; + padding: 8px 0; + } + + &__spec-row { + display: flex; + justify-content: space-between; + margin: 0; + font-size: 12px; + font-weight: 600; + line-height: 1; + } + + &__spec-name { + color: var(--text-color-secondary); + } + + &__spec-value { + display: inline-flex; + align-items: baseline; + gap: 4px; + color: var(--text-main-color); + } + + &__actions { + display: flex; + gap: 8px; + align-items: center; + } +} diff --git a/src/components/ProductCard/ProductCard.tsx b/src/components/ProductCard/ProductCard.tsx new file mode 100644 index 00000000000..66f4cef2c29 --- /dev/null +++ b/src/components/ProductCard/ProductCard.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import styles from './ProductCard.module.scss'; +import { Product } from '../../types/product'; +import { Link } from 'react-router-dom'; +import { ProductActions } from '../ProductActions'; +import { ProductPrice } from '../ProductPrice'; +import { useProductActions } from '../../modules/shared/hooks'; + +interface Props { + product: Product; + hideDiscount?: boolean; +} + +export const ProductCard: React.FC = React.memo(function ProductCard({ + product, + hideDiscount = false, +}) { + const { + productForAction, + isInCart, + isFavorited, + handleCartClick, + handleFavoriteClick, + } = useProductActions({ product, hideDiscount }); + + const price = productForAction?.price ?? product.price; + + return ( +
    + + {product.name} + + +
    +

    + + {product.name} + +

    + + + +
    + +
    +

    + Screen + + {product.screen.split(' ')[0]} + {product.screen.split(' ')[1]} + +

    + +

    + Capacity + + {product.capacity.replace('GB', '')} + GB + +

    + +

    + RAM + + {product.ram.replace('GB', '')} + GB + +

    +
    + +
    + +
    +
    +
    + ); +}); diff --git a/src/components/ProductCard/index.ts b/src/components/ProductCard/index.ts new file mode 100644 index 00000000000..7ce031c3820 --- /dev/null +++ b/src/components/ProductCard/index.ts @@ -0,0 +1 @@ +export * from './ProductCard'; diff --git a/src/components/ProductList/ProductList.module.scss b/src/components/ProductList/ProductList.module.scss new file mode 100644 index 00000000000..971e6a7cb83 --- /dev/null +++ b/src/components/ProductList/ProductList.module.scss @@ -0,0 +1,41 @@ +@use '../../styles' as s; + +.grid { + align-items: start; + + @include s.page-grid; +} + +.itemWrapper { + grid-column: span 4; + display: flex; + align-items: stretch; + + + @include s.on-tablet { + grid-column: span 6; + } + + @include s.on-desktop { + grid-column: span 6; + } +} + +.empty { + padding: 48px 0; + text-align: center; + color: var(--text-color-secondary); + font-weight: 600; + + @include s.content-padding-inline; +} + + +.sectionTitle { + margin: 8px 0 16px; + color: var(--text-main-color); + font-size: 20px; + font-weight: 800; + + @include s.content-padding-inline; +} diff --git a/src/components/ProductList/ProductList.tsx b/src/components/ProductList/ProductList.tsx new file mode 100644 index 00000000000..d612e82acf5 --- /dev/null +++ b/src/components/ProductList/ProductList.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import cn from 'classnames'; +import styles from './ProductList.module.scss'; +import { Product } from '../../types/product'; +import { ProductCard } from '../ProductCard'; + +export interface ProductListProps { + products: Product[]; + className?: string; +} + +export const ProductList: React.FC = ({ + products, + className = '', +}) => { + if (!products || products.length === 0) { + return null; + } + + return ( +
    + {products.map(p => ( +
    + +
    + ))} +
    + ); +}; diff --git a/src/components/ProductList/index.ts b/src/components/ProductList/index.ts new file mode 100644 index 00000000000..c71910ae056 --- /dev/null +++ b/src/components/ProductList/index.ts @@ -0,0 +1 @@ +export * from './ProductList'; diff --git a/src/components/ProductPrice/ProductPrice.tsx b/src/components/ProductPrice/ProductPrice.tsx new file mode 100644 index 00000000000..319c851b9af --- /dev/null +++ b/src/components/ProductPrice/ProductPrice.tsx @@ -0,0 +1,34 @@ +import React from 'react'; + +interface Props { + price: number; + fullPrice?: number; + hideDiscount?: boolean; + className?: string; + currentClassName?: string; + oldClassName?: string; +} + +const formatPrice = (value: number) => `$${value.toLocaleString()}`; + +export const ProductPrice: React.FC = ({ + price, + fullPrice, + hideDiscount = false, + className, + currentClassName, + oldClassName, +}) => { + const showFullPrice = + !hideDiscount && fullPrice !== undefined && fullPrice > price; + + return ( +
    +
    {formatPrice(price)}
    + + {showFullPrice && ( +
    {formatPrice(fullPrice)}
    + )} +
    + ); +}; diff --git a/src/components/ProductPrice/index.ts b/src/components/ProductPrice/index.ts new file mode 100644 index 00000000000..e41a8a7122e --- /dev/null +++ b/src/components/ProductPrice/index.ts @@ -0,0 +1 @@ +export { ProductPrice } from './ProductPrice'; diff --git a/src/components/ProductSlider/ProductSlider.module.scss b/src/components/ProductSlider/ProductSlider.module.scss new file mode 100644 index 00000000000..936a7873e3d --- /dev/null +++ b/src/components/ProductSlider/ProductSlider.module.scss @@ -0,0 +1,80 @@ +@use '../../styles' as s; + +.productSlider { + &__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 24px; + } + + &__title { + margin: 0; + color: var(--text-main-color); + font-size: 32px; + font-weight: 700; + line-height: 1; + } + + &__controls { + display: flex; + align-items: center; + gap: 16px; + } + + &__arrow { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid var(--border-color); + background: var(--border-color); + color: var(--text-main-color); + cursor: pointer; + line-height: 1; + transition: + background s.$effect-duration ease, + color s.$effect-duration ease, + border-color s.$effect-duration ease; + + &:disabled { + background: transparent; + color: var(--icons-hover); + cursor: default; + } + } + + &__track { + display: flex; + gap: 16px; + overflow-x: auto; + scroll-snap-type: x mandatory; + scroll-behavior: smooth; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + } + + &__slide { + flex: 0 0 calc(100% - 48px); + scroll-snap-align: start; + + @include s.on-tablet { + flex-basis: calc((100% - 40px) / 2.2); + } + + @include s.on-laptop { + flex-basis: calc((100% - 48px) / 4); + } + } + + &__empty { + color: var(--text-color-secondary); + font-size: 14px; + font-weight: 600; + } +} diff --git a/src/components/ProductSlider/ProductSlider.tsx b/src/components/ProductSlider/ProductSlider.tsx new file mode 100644 index 00000000000..063af9d77d0 --- /dev/null +++ b/src/components/ProductSlider/ProductSlider.tsx @@ -0,0 +1,124 @@ +import React, { useEffect, useRef, useState } from 'react'; +import cn from 'classnames'; +import type { Product } from '../../types/product'; +import { ChevronIcon } from '../iconsSVG'; +import { ProductCard } from '../ProductCard'; +import styles from './ProductSlider.module.scss'; + +interface Props { + products: Product[]; + title?: string; + emptyMessage?: string; + hideDiscount?: boolean; + className?: string; +} + +export const ProductSlider: React.FC = ({ + products, + title, + emptyMessage = 'There are no phones/tablets/accessories yet', + hideDiscount = false, + className, +}) => { + const trackRef = useRef(null); + const [canLeft, setCanLeft] = useState(false); + const [canRight, setCanRight] = useState(false); + + const updateButtons = () => { + const track = trackRef.current; + + if (!track) { + return; + } + + setCanLeft(track.scrollLeft > 0); + setCanRight(track.scrollLeft + track.clientWidth < track.scrollWidth - 1); + }; + + useEffect(() => { + updateButtons(); + + const track = trackRef.current; + + if (!track) { + return; + } + + track.addEventListener('scroll', updateButtons, { passive: true }); + window.addEventListener('resize', updateButtons); + + return () => { + track.removeEventListener('scroll', updateButtons); + window.removeEventListener('resize', updateButtons); + }; + }, [products]); + + const scroll = (direction: 'left' | 'right') => { + const track = trackRef.current; + + if (!track) { + return; + } + + const slide = track.querySelector( + `.${styles.productSlider__slide}`, + ); + const gap = parseInt(getComputedStyle(track).columnGap, 10) || 0; + const amount = slide ? slide.offsetWidth + gap : track.clientWidth; + + track.scrollBy({ + left: direction === 'right' ? amount : -amount, + behavior: 'smooth', + }); + }; + + if (!products || products.length === 0) { + return ( +
    + {title &&

    {title}

    } +
    {emptyMessage}
    +
    + ); + } + + return ( +
    +
    + {title &&

    {title}

    } + +
    + + + +
    +
    + +
    + {products.map(product => ( +
    + +
    + ))} +
    +
    + ); +}; diff --git a/src/components/ProductSlider/index.ts b/src/components/ProductSlider/index.ts new file mode 100644 index 00000000000..53492382705 --- /dev/null +++ b/src/components/ProductSlider/index.ts @@ -0,0 +1 @@ +export * from './ProductSlider'; diff --git a/src/components/SuggestedProducts/SuggestedProducts.tsx b/src/components/SuggestedProducts/SuggestedProducts.tsx new file mode 100644 index 00000000000..0fc1feed38f --- /dev/null +++ b/src/components/SuggestedProducts/SuggestedProducts.tsx @@ -0,0 +1,60 @@ +import React, { useMemo } from 'react'; +import type { Product } from '../../types/product'; +import { ProductSlider } from '../ProductSlider'; + +interface Props { + products?: Product[] | null; + currentId?: string | number | null; + maxItems?: number; + className?: string; +} + +function shuffle(arr: T[]) { + const items = [...arr]; + + for (let i = items.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + + [items[i], items[j]] = [items[j], items[i]]; + } + + return items; +} + +export const getSuggestedProducts = ( + products: Product[], + currentId: string | number | null, + maxItems: number, +) => { + const pool = products.filter( + product => (product.itemId ?? String(product.id)) !== String(currentId), + ); + + return shuffle(pool).slice(0, Math.max(0, maxItems)); +}; + +export const SuggestedProducts: React.FC = ({ + products, + currentId = null, + maxItems = 12, + className, +}) => { + const items = useMemo(() => { + if (!products || products.length === 0) { + return []; + } + + return getSuggestedProducts(products, currentId, maxItems); + }, [products, currentId, maxItems]); + + return ( + + ); +}; + +export default SuggestedProducts; diff --git a/src/components/SuggestedProducts/index.ts b/src/components/SuggestedProducts/index.ts new file mode 100644 index 00000000000..a3b45fa4d92 --- /dev/null +++ b/src/components/SuggestedProducts/index.ts @@ -0,0 +1 @@ +export * from './SuggestedProducts'; diff --git a/src/components/ThemeToggle/ThemeToggle.module.scss b/src/components/ThemeToggle/ThemeToggle.module.scss new file mode 100644 index 00000000000..aa1e90e6f08 --- /dev/null +++ b/src/components/ThemeToggle/ThemeToggle.module.scss @@ -0,0 +1,37 @@ +@use '../../styles' as s; + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + height: 36px; + border-radius: 8px; + border: 1px solid transparent; + background: transparent; + cursor: pointer; + padding: 4px; + transition: + background s.$effect-duration ease, + color s.$effect-duration ease, + transform s.$effect-duration ease; + color: var(--text-main-color); + background-color: transparent; + + &:hover, + &:focus { + transform: translateY(-1px); + outline: none; + } + + @include s.on-tablet { + width: 40px; + } +} + +.icon { + display: inline-flex; + line-height: 0; + svg { + display: block; + } +} diff --git a/src/components/ThemeToggle/ThemeToggle.tsx b/src/components/ThemeToggle/ThemeToggle.tsx new file mode 100644 index 00000000000..de60572fbae --- /dev/null +++ b/src/components/ThemeToggle/ThemeToggle.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { useTheme } from '../../context/ThemeContext'; +import { MoonIcon, SunIcon } from '../iconsSVG'; +import styles from './ThemeToggle.module.scss'; + +export const ThemeToggle: React.FC = () => { + const { isDark, toggleTheme } = useTheme(); + + return ( + + ); +}; diff --git a/src/components/ThemeToggle/index.ts b/src/components/ThemeToggle/index.ts new file mode 100644 index 00000000000..879e51319df --- /dev/null +++ b/src/components/ThemeToggle/index.ts @@ -0,0 +1 @@ +export * from './ThemeToggle'; diff --git a/src/components/iconsSVG/burgerIcon.tsx b/src/components/iconsSVG/burgerIcon.tsx new file mode 100644 index 00000000000..a95734466c7 --- /dev/null +++ b/src/components/iconsSVG/burgerIcon.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import Icon, { IconProps } from './icon'; + +export const BurgerIcon: React.FC> = ({ + size = 16, + title = 'Menu', + className, + ...rest +}) => { + return ( + + + + + + ); +}; diff --git a/src/components/iconsSVG/cartIcon.tsx b/src/components/iconsSVG/cartIcon.tsx new file mode 100644 index 00000000000..b185170b5e1 --- /dev/null +++ b/src/components/iconsSVG/cartIcon.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +/* eslint-disable max-len */ +import Icon, { IconProps } from './icon'; + +export const CartIcon: React.FC> = ({ + size = 16, + title = 'Cart', + className, + ...rest +}) => { + return ( + + + + + + ); +}; diff --git a/src/components/iconsSVG/chevronIcon.tsx b/src/components/iconsSVG/chevronIcon.tsx new file mode 100644 index 00000000000..a10c85eea2f --- /dev/null +++ b/src/components/iconsSVG/chevronIcon.tsx @@ -0,0 +1,52 @@ +/* eslint-disable max-len */ +import React from 'react'; +import Icon, { IconProps } from './icon'; + +export type Direction = 'down' | 'up' | 'left' | 'right'; + +export type ChevronIconProps = Omit & { + direction?: Direction; + color?: string; + size?: number; + rotateDeg?: number; +}; + +export const ChevronIcon: React.FC = ({ + size = 16, + title = 'Chevron', + className, + direction = 'down', + color = 'currentColor', + rotateDeg = 0, + ...rest +}) => { + const d = + 'M12.4715 5.52864C12.7318 5.78899 12.7318 6.2111 12.4715 6.47145L8.47149 10.4714C8.21114 10.7318 7.78903 10.7318 7.52868 10.4714L3.52868 6.47145C3.26833 6.2111 3.26833 5.78899 3.52868 5.52864C3.78903 5.26829 4.21114 5.26829 4.47149 5.52864L8.00008 9.05723L11.5287 5.52864C11.789 5.26829 12.2111 5.26829 12.4715 5.52864Z'; + + const dirToDeg: Record = { + down: 0, + right: 90, + up: 180, + left: 270, + }; + + const deg = dirToDeg[direction] + rotateDeg; + + return ( + + + + ); +}; diff --git a/src/components/iconsSVG/closeIcon.tsx b/src/components/iconsSVG/closeIcon.tsx new file mode 100644 index 00000000000..cae5e0ceabc --- /dev/null +++ b/src/components/iconsSVG/closeIcon.tsx @@ -0,0 +1,27 @@ +import Icon, { IconProps } from './icon'; +import React from 'react'; + +export const CloseIcon: React.FC> = ({ + size = 16, + title = 'Close menu', + className, + ...rest +}) => { + return ( + + + + ); +}; diff --git a/src/components/iconsSVG/decreaseIcon.tsx b/src/components/iconsSVG/decreaseIcon.tsx new file mode 100644 index 00000000000..ef170c1dd5a --- /dev/null +++ b/src/components/iconsSVG/decreaseIcon.tsx @@ -0,0 +1,27 @@ +/* eslint-disable max-len */ +import Icon, { IconProps } from './icon'; +import React from 'react'; + +export const MinusIcon: React.FC> = ({ + size = 16, + title = 'Minus', + className, + ...rest +}) => { + return ( + + + + ); +}; diff --git a/src/components/iconsSVG/heartIcon.tsx b/src/components/iconsSVG/heartIcon.tsx new file mode 100644 index 00000000000..ed5f5cf02e0 --- /dev/null +++ b/src/components/iconsSVG/heartIcon.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +/* eslint-disable max-len */ +import Icon, { IconProps } from './icon'; + +export type HeartIconProps = Omit & { + filled?: boolean; + filledColor?: string; + strokeColor?: string; + strokeWidth?: number; +}; + +export const HeartIcon: React.FC = ({ + size = 16, + title = 'Favorites', + className, + filled = false, + filledColor = '#EB5757', + strokeColor = 'currentColor', + strokeWidth = 1.5, + ...rest +}) => { + const d = + 'M11.3 1.29878C10.7264 1.29878 10.1584 1.4118 9.62852 1.63137C9.09865 1.85092 8.61711 2.17283 8.21162 2.57847L8.00005 2.79005L7.78835 2.57836C6.96928 1.75929 5.85839 1.29914 4.70005 1.29914C3.54171 1.29914 2.43081 1.75929 1.61174 2.57836C0.792668 3.39743 0.33252 4.50833 0.33252 5.66667C0.33252 6.82501 0.792668 7.9359 1.61174 8.75497L7.50507 14.6483C7.77844 14.9217 8.22165 14.9217 8.49502 14.6483L14.3884 8.75497C14.794 8.34949 15.1158 7.86806 15.3353 7.33819C15.5549 6.80827 15.6679 6.24028 15.6679 5.66667C15.6679 5.09305 15.5549 4.52506 15.3353 3.99514C15.1158 3.46532 14.7941 2.98394 14.3885 2.57847C13.983 2.17277 13.5015 1.85094 12.9716 1.63137C12.4416 1.4118 11.8737 1.29878 11.3 1.29878Z'; + + return ( + + {filled ? ( + + ) : ( + + )} + + ); +}; diff --git a/src/components/iconsSVG/homeIcon.tsx b/src/components/iconsSVG/homeIcon.tsx new file mode 100644 index 00000000000..4f4dcbadf35 --- /dev/null +++ b/src/components/iconsSVG/homeIcon.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +/* eslint-disable max-len */ +import Icon, { IconProps } from './icon'; + +export const HomeIcon: React.FC> = ({ + size = 16, + title = 'Home', + className, + ...rest +}) => { + return ( + + + + + ); +}; diff --git a/src/components/iconsSVG/icon.tsx b/src/components/iconsSVG/icon.tsx new file mode 100644 index 00000000000..aac169ee295 --- /dev/null +++ b/src/components/iconsSVG/icon.tsx @@ -0,0 +1,44 @@ +import React, { useId } from 'react'; + +export interface IconProps extends React.SVGProps { + size?: number | string; + title?: string | null; + viewBox?: string; + children?: React.ReactNode; +} + +export const Icon: React.FC = React.memo(function Icon({ + size = 16, + title = null, + viewBox = '0 0 20 20', + className, + children, + ...rest +}) { + const id = useId(); + const titleId = title ? `icon-title-${id}` : undefined; + const sizeAttr = typeof size === 'number' ? `${size}` : size; + + const ariaAttrs = title + ? { role: 'img', 'aria-labelledby': titleId } + : { 'aria-hidden': true as const }; + + return ( + + {title ? {title} : null} + {children} + + ); +}); + +export default Icon; diff --git a/src/components/iconsSVG/index.ts b/src/components/iconsSVG/index.ts new file mode 100644 index 00000000000..b3f43723a34 --- /dev/null +++ b/src/components/iconsSVG/index.ts @@ -0,0 +1,11 @@ +export { BurgerIcon } from './burgerIcon'; +export { CloseIcon } from './closeIcon'; +export { CartIcon } from './cartIcon'; +export { HeartIcon } from './heartIcon'; +export { HomeIcon } from './homeIcon'; +export { MinusIcon } from './decreaseIcon'; +export { PlusIcon } from './plusIcon'; +export { ChevronIcon } from './chevronIcon'; +export { SearchIcon } from './searchIcon'; +export { SunIcon } from './sunIcon'; +export { MoonIcon } from './moonIcon'; diff --git a/src/components/iconsSVG/moonIcon.tsx b/src/components/iconsSVG/moonIcon.tsx new file mode 100644 index 00000000000..b7c58cc827c --- /dev/null +++ b/src/components/iconsSVG/moonIcon.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import Icon, { IconProps } from './icon'; + +export const MoonIcon: React.FC> = ({ + size = 18, + title = null, + className, + ...rest +}) => ( + + + +); diff --git a/src/components/iconsSVG/plusIcon.tsx b/src/components/iconsSVG/plusIcon.tsx new file mode 100644 index 00000000000..9dceacb736c --- /dev/null +++ b/src/components/iconsSVG/plusIcon.tsx @@ -0,0 +1,27 @@ +/* eslint-disable max-len */ +import Icon, { IconProps } from './icon'; +import React from 'react'; + +export const PlusIcon: React.FC> = ({ + size = 16, + title = 'Plus', + className, + ...rest +}) => { + return ( + + + + ); +}; diff --git a/src/components/iconsSVG/searchIcon.tsx b/src/components/iconsSVG/searchIcon.tsx new file mode 100644 index 00000000000..bc7db146535 --- /dev/null +++ b/src/components/iconsSVG/searchIcon.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import Icon, { IconProps } from './icon'; + +export const SearchIcon: React.FC> = ({ + size = 16, + title = 'Search', + className, + ...rest +}) => { + return ( + + + + + ); +}; diff --git a/src/components/iconsSVG/sunIcon.tsx b/src/components/iconsSVG/sunIcon.tsx new file mode 100644 index 00000000000..cad8f59d55a --- /dev/null +++ b/src/components/iconsSVG/sunIcon.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import Icon, { IconProps } from './icon'; + +export const SunIcon: React.FC> = ({ + size = 18, + title = null, + className, + ...rest +}) => ( + + + + + + + + + + + + + +); diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx new file mode 100644 index 00000000000..364440d1834 --- /dev/null +++ b/src/context/ThemeContext.tsx @@ -0,0 +1,117 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; + +export type Theme = 'light' | 'dark'; + +type ThemeContextValue = { + theme: Theme; + isDark: boolean; + setTheme: (t: Theme) => void; + toggleTheme: () => void; +}; + +const STORAGE_KEY = 'theme'; + +function getSystemTheme(): Theme | null { + if ( + typeof window === 'undefined' || + typeof window.matchMedia === 'undefined' + ) { + return null; + } + + return window.matchMedia('(prefers-color-scheme: dark)').matches + ? 'dark' + : 'light'; +} + +function readSavedTheme(): Theme | null { + try { + const v = localStorage.getItem(STORAGE_KEY); + + return v === 'light' || v === 'dark' ? v : null; + } catch { + return null; + } +} + +function applyThemeToDocument(theme: Theme) { + document.documentElement.setAttribute('data-theme', theme); +} + +const ThemeContext = createContext(null); + +type ThemeProviderProps = { + children: React.ReactNode; + defaultTheme?: Theme; +}; + +export const ThemeProvider: React.FC = ({ + children, + defaultTheme = 'dark', +}) => { + const [theme, setThemeState] = useState(() => { + if (typeof window === 'undefined') { + return defaultTheme; + } + + const saved = readSavedTheme(); + + if (saved) { + return saved; + } + + const system = getSystemTheme(); + + if (system) { + return system; + } + + return defaultTheme; + }); + + useEffect(() => { + applyThemeToDocument(theme); + + try { + localStorage.setItem(STORAGE_KEY, theme); + } catch {} + }, [theme]); + + const setTheme = useCallback((t: Theme) => { + setThemeState(t); + }, []); + + const toggleTheme = useCallback(() => { + setThemeState(prev => (prev === 'dark' ? 'light' : 'dark')); + }, []); + + const value = useMemo(() => { + return { + theme, + isDark: theme === 'dark', + setTheme, + toggleTheme, + }; + }, [theme, setTheme, toggleTheme]); + + return ( + {children} + ); +}; + +export function useTheme(): ThemeContextValue { + const ctx = useContext(ThemeContext); + + if (!ctx) { + throw new Error('useTheme must be used within ThemeProvider'); + } + + return ctx; +} diff --git a/src/features/cart/cartSlice.ts b/src/features/cart/cartSlice.ts new file mode 100644 index 00000000000..afe0aa76966 --- /dev/null +++ b/src/features/cart/cartSlice.ts @@ -0,0 +1,104 @@ +/* eslint-disable no-param-reassign */ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import type { RootState } from '../../app/store'; +import { Product } from '../../types/product'; + +export interface CartItem { + product: Product; + quantity: number; +} + +export interface CartState { + items: CartItem[]; +} + +const CART_STORAGE_KEY = 'cart'; + +const loadCart = (): CartItem[] => { + try { + const raw = localStorage.getItem(CART_STORAGE_KEY); + + if (!raw) { + return []; + } + + return JSON.parse(raw) as CartItem[]; + } catch { + return []; + } +}; + +const saveCart = (items: CartItem[]) => { + try { + localStorage.setItem(CART_STORAGE_KEY, JSON.stringify(items)); + } catch {} +}; + +const initialState: CartState = { + items: loadCart(), +}; + +const cartSlice = createSlice({ + name: 'cart', + initialState, + reducers: { + addToCart: (state, action: PayloadAction) => { + const existing = state.items.find( + item => item.product.itemId === action.payload.itemId, + ); + + if (existing) { + existing.quantity += 1; + } else { + state.items.push({ product: action.payload, quantity: 1 }); + } + + saveCart(state.items); + }, + removeFromCart: (state, action: PayloadAction) => { + state.items = state.items.filter( + item => item.product.itemId !== action.payload, + ); + saveCart(state.items); + }, + changeQuantity: ( + state, + action: PayloadAction<{ itemId: string; delta: number }>, + ) => { + const item = state.items.find( + i => i.product.itemId === action.payload.itemId, + ); + + if (!item) { + return; + } + + const newQty = item.quantity + action.payload.delta; + + if (newQty < 1) { + return; + } + + item.quantity = newQty; + saveCart(state.items); + }, + clearCart: state => { + state.items = []; + saveCart([]); + }, + }, +}); + +export const selectCartItems = (state: RootState) => state.cart.items; +export const selectCartTotalQuantity = (state: RootState) => + state.cart.items.reduce((sum, item) => sum + item.quantity, 0); +export const selectCartTotalPrice = (state: RootState) => + state.cart.items.reduce( + (sum, item) => sum + item.quantity * (item.product.price ?? 0), + 0, + ); + +export const { addToCart, removeFromCart, changeQuantity, clearCart } = + cartSlice.actions; + +export default cartSlice.reducer; diff --git a/src/features/favorites/favoritesSlice.ts b/src/features/favorites/favoritesSlice.ts new file mode 100644 index 00000000000..8439da3e3e4 --- /dev/null +++ b/src/features/favorites/favoritesSlice.ts @@ -0,0 +1,65 @@ +/* eslint-disable no-param-reassign */ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { Product } from '../../types/product'; +import type { RootState } from '../../app/store'; + +export interface FavoritesState { + items: Product[]; +} + +const FAVORITES_KEY = 'favorites'; + +const loadFavorites = (): Product[] => { + try { + const raw = localStorage.getItem(FAVORITES_KEY); + + if (!raw) { + return []; + } + + return JSON.parse(raw) as Product[]; + } catch { + return []; + } +}; + +const saveFavorites = (items: Product[]) => { + try { + localStorage.setItem(FAVORITES_KEY, JSON.stringify(items)); + } catch {} +}; + +const initialState: FavoritesState = { + items: loadFavorites(), +}; + +const favoritesSlice = createSlice({ + name: 'favorites', + initialState, + reducers: { + toggleFavorite: (state, action: PayloadAction) => { + const p = action.payload; + const exists = state.items.find(i => i.itemId === p.itemId); + + if (exists) { + state.items = state.items.filter(i => i.itemId !== p.itemId); + } else { + state.items.push(p); + } + + saveFavorites(state.items); + }, + clearFavorites: state => { + state.items = []; + saveFavorites([]); + }, + }, +}); + +export const { toggleFavorite, clearFavorites } = favoritesSlice.actions; + +export const selectFavoritesItems = (state: RootState) => state.favorites.items; +export const selectFavoritesCount = (state: RootState) => + state.favorites.items.length; + +export default favoritesSlice.reducer; diff --git a/src/features/products/index.ts b/src/features/products/index.ts new file mode 100644 index 00000000000..ca9cc80721b --- /dev/null +++ b/src/features/products/index.ts @@ -0,0 +1,3 @@ +export * from './productSlice'; +export * from './useFetchProducts'; +export * from './useProductDetails'; diff --git a/src/features/products/productSlice.ts b/src/features/products/productSlice.ts new file mode 100644 index 00000000000..e64c1f140f8 --- /dev/null +++ b/src/features/products/productSlice.ts @@ -0,0 +1,94 @@ +/* eslint-disable no-param-reassign */ +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; +import type { RootState } from '../../app/store'; +import type { Product } from '../../types/product'; +import { getPublicUrl } from '../../utils/publicPath'; + +export interface ProductsState { + products: Product[]; + isLoading: boolean; + error: string | null; +} + +interface FetchProductsConfig { + rejectValue: string; +} + +const normalizeNewestProducts = (products: Product[]): Product[] => { + const newestYear = Math.max(...products.map(product => product.year ?? 0)); + + return products.map(product => { + const isNewest = product.year === newestYear; + + if ( + !isNewest || + product.fullPrice === undefined || + product.fullPrice <= product.price + ) { + return product; + } + + const { fullPrice, ...productWithoutFullPrice } = product; + + return { + ...productWithoutFullPrice, + price: fullPrice, + }; + }); +}; + +/* eslint-disable @typescript-eslint/indent */ +export const fetchProducts = createAsyncThunk< + Product[], + void, + FetchProductsConfig +>('products/fetchProducts', async (_, { rejectWithValue }) => { + /* eslint-enable @typescript-eslint/indent */ + try { + const response = await fetch(getPublicUrl('api/products.json')); + + if (!response.ok) { + return rejectWithValue('Failed to fetch products'); + } + + const data = (await response.json()) as Product[]; + + return normalizeNewestProducts(data); + } catch (err) { + return rejectWithValue('Network error'); + } +}); + +const initialState: ProductsState = { + products: [], + isLoading: false, + error: null, +}; + +export const productsSlice = createSlice({ + name: 'products', + initialState, + reducers: {}, + extraReducers: builder => { + builder + .addCase(fetchProducts.pending, state => { + state.isLoading = true; + state.error = null; + }) + .addCase(fetchProducts.fulfilled, (state, action) => { + state.products = action.payload; + state.isLoading = false; + }) + .addCase(fetchProducts.rejected, (state, action) => { + state.isLoading = false; + state.error = action.payload ?? action.error.message ?? 'Error'; + }); + }, +}); + +export const selectProducts = (state: RootState) => state.products.products; +export const selectProductsLoading = (state: RootState) => + state.products.isLoading; +export const selectProductsError = (state: RootState) => state.products.error; + +export default productsSlice.reducer; diff --git a/src/features/products/useFetchProducts.ts b/src/features/products/useFetchProducts.ts new file mode 100644 index 00000000000..75fc0fb04cc --- /dev/null +++ b/src/features/products/useFetchProducts.ts @@ -0,0 +1,24 @@ +import { useEffect } from 'react'; +import { useAppDispatch, useAppSelector } from '../../app/hooks'; +import { + fetchProducts, + selectProducts, + selectProductsLoading, + selectProductsError, +} from './productSlice'; + +export const useFetchProducts = () => { + const dispatch = useAppDispatch(); + const products = useAppSelector(selectProducts); + const isLoading = useAppSelector(selectProductsLoading); + const error = useAppSelector(selectProductsError); + const hasProducts = products.length > 0; + + useEffect(() => { + if (!hasProducts && !isLoading) { + dispatch(fetchProducts()); + } + }, [dispatch, hasProducts, isLoading]); + + return { products, isLoading, error }; +}; diff --git a/src/features/products/useProductDetails.ts b/src/features/products/useProductDetails.ts new file mode 100644 index 00000000000..9586140ea0c --- /dev/null +++ b/src/features/products/useProductDetails.ts @@ -0,0 +1,120 @@ +import { useEffect, useMemo, useState } from 'react'; +import type { + Product, + ProductDetails, + ProductWithDetails, +} from '../../types/product'; +import { getPublicUrl } from '../../utils/publicPath'; + +const detailFileByCategory: Record = { + phones: 'phones.json', + tablets: 'tablets.json', + accessories: 'accessories.json', +}; + +const toArray = (value?: T | T[]): T[] => { + if (value === undefined || value === null) { + return []; + } + + return Array.isArray(value) ? value : [value]; +}; + +const getDetailFile = (category?: string) => + category ? detailFileByCategory[category] : undefined; + +export function useProductDetails(baseProduct?: Product | null) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [details, setDetails] = useState(null); + + useEffect(() => { + if (!baseProduct) { + setDetails(null); + setError(null); + setLoading(false); + + return; + } + + const fileName = getDetailFile(baseProduct.category); + + if (!fileName) { + setDetails(null); + setError('Unknown product category'); + setLoading(false); + + return; + } + + let isActive = true; + + setLoading(true); + setError(null); + + fetch(getPublicUrl(`api/${fileName}`)) + .then(response => { + if (!response.ok) { + throw new Error(`Failed to load ${fileName}`); + } + + return response.json() as Promise; + }) + .then(items => { + if (!isActive) { + return; + } + + const itemDetails = items.find(item => item.id === baseProduct.itemId); + + setDetails(itemDetails ?? null); + }) + .catch((err: Error) => { + if (!isActive) { + return; + } + + setDetails(null); + setError(err.message); + }) + .finally(() => { + if (isActive) { + setLoading(false); + } + }); + + return () => { + isActive = false; + }; + }, [baseProduct]); + + const mergedProduct = useMemo(() => { + if (!baseProduct) { + return null; + } + + const images = toArray( + details?.images ?? details?.images ?? baseProduct.image, + ); + const colorsAvailable = toArray( + details?.colorsAvailable ?? details?.color ?? baseProduct.color, + ); + const capacityAvailable = toArray( + details?.capacityAvailable ?? baseProduct.capacity, + ); + const detailsWithoutId = { ...details }; + + delete detailsWithoutId.id; + + return { + ...baseProduct, + ...detailsWithoutId, + images, + colorsAvailable, + capacityAvailable, + description: details?.description ?? null, + } as ProductWithDetails; + }, [baseProduct, details]); + + return { mergedProduct, loading, error }; +} diff --git a/src/hooks/useLockBodyScroll.ts b/src/hooks/useLockBodyScroll.ts new file mode 100644 index 00000000000..94f7d73c3fe --- /dev/null +++ b/src/hooks/useLockBodyScroll.ts @@ -0,0 +1,24 @@ +import { useEffect } from 'react'; + +const BODY_LOCK_CLASS = 'no-scroll'; +let lockCount = 0; + +export function useLockBodyScroll(shouldLock: boolean) { + useEffect(() => { + if (shouldLock) { + lockCount = Math.max(0, lockCount) + 1; + if (lockCount === 1) { + document.body.classList.add(BODY_LOCK_CLASS); + } + } + + return () => { + if (shouldLock) { + lockCount = Math.max(0, lockCount - 1); + if (lockCount === 0) { + document.body.classList.remove(BODY_LOCK_CLASS); + } + } + }; + }, [shouldLock]); +} diff --git a/src/index.tsx b/src/index.tsx index 50470f1508d..96f7228716f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,23 @@ +import './styles/app.scss'; import { createRoot } from 'react-dom/client'; +import { Provider } from 'react-redux'; +import { HashRouter as Router } from 'react-router-dom'; + +import { store } from './app/store'; import { App } from './App'; +import { ThemeProvider } from './context/ThemeContext'; + +const container = document.getElementById('root') as HTMLElement; +const root = createRoot(container); + +const Root = () => ( + + + + + + + +); -createRoot(document.getElementById('root') as HTMLElement).render(); +root.render(); diff --git a/src/modules/CartPage/CartPage.module.scss b/src/modules/CartPage/CartPage.module.scss new file mode 100644 index 00000000000..c21540063ba --- /dev/null +++ b/src/modules/CartPage/CartPage.module.scss @@ -0,0 +1,127 @@ +@use '../../styles' as s; + +.cartPage { + padding-top: 24px; + + @include s.on-tablet { + padding-top: 40px; + } + + &__header { + display: flex; + flex-direction: column; + gap: 24px; + + @include s.on-tablet { + gap: 16px; + } + } + + &__title { + margin: 0; + font-size: 32px; + font-weight: 800; + color: var(--text-main-color); + + @include s.on-tablet { + font-size: 48px; + } + } + + &__content { + display: flex; + flex-direction: column; + gap: 32px; + margin-top: 32px; + + @include s.on-desktop { + display: grid; + grid-template-columns: minmax(0, 1fr) 368px; + gap: 16px; + align-items: start; + } + } + + &__list { + width: 100%; + } + + &__items { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 16px; + } + + &__empty { + color: var(--text-main-color); + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 16px; + width: 100%; + } + + &__emptyText { + margin: 0; + color: var(--text-main-color); + font-size: 22px; + font-weight: 700; + line-height: 1.4; + } + + &__emptyImage { + display: block; + width: min(100%, 420px); + max-height: min(36vh, 320px); + height: auto; + object-fit: contain; + } +} + +.summary { + width: 100%; + + &__inner { + border: 1px solid var(--border-color); + padding: 24px; + display: flex; + flex-direction: column; + align-items: center; + background: var(--card-bg); + text-align: center; + } + + &__total { + width: 100%; + padding-bottom: 24px; + border-bottom: 1px solid var(--border-color); + } + + &__amount { + font-weight: 800; + font-size: 28px; + color: var(--text-main-color); + } + + &__label { + color: var(--text-color-secondary); + font-size: 14px; + } + + &__checkout { + width: 100%; + margin-top: 24px; + background: var(--button-main-color); + color: var(--button-text-color); + border: none; + padding: 14px; + transition: background-color s.$effect-duration ease; + + &:hover { + background-color: var(--button-main-color-hover); + } + } +} diff --git a/src/modules/CartPage/CartPage.tsx b/src/modules/CartPage/CartPage.tsx new file mode 100644 index 00000000000..41a9e339e8a --- /dev/null +++ b/src/modules/CartPage/CartPage.tsx @@ -0,0 +1,85 @@ +import React, { useState } from 'react'; +import { BackLink } from '../../components/BackLink'; +import { CartItem } from '../../components/CartItem'; +import { ConfirmModal } from '../../components/ConfirmModal'; +import { getPublicUrl } from '../../utils/publicPath'; +import { useCart } from '../shared/hooks/useCart'; +import styles from './CartPage.module.scss'; + +const CHECKOUT_MESSAGE = + 'Checkout is not implemented yet. Do you want to clear the Cart?'; + +export const CartPage: React.FC = () => { + const { cartItems, totalAmount, totalQuantity, clear } = useCart(); + const [isConfirmOpen, setConfirmOpen] = useState(false); + + const handleCheckoutClick = () => setConfirmOpen(true); + const handleConfirm = () => { + clear(); + setConfirmOpen(false); + }; + + const handleCancel = () => setConfirmOpen(false); + + return ( +
    +
    + +
    +

    Cart

    +
    +
    + +
    +
    +
      + {cartItems.length > 0 ? ( + cartItems.map(item => ( + + )) + ) : ( +
    • +

      Your cart is empty

      + +
    • + )} +
    +
    + + +
    + + +
    + ); +}; diff --git a/src/modules/CartPage/index.ts b/src/modules/CartPage/index.ts new file mode 100644 index 00000000000..90c010237a0 --- /dev/null +++ b/src/modules/CartPage/index.ts @@ -0,0 +1 @@ +export * from './CartPage'; diff --git a/src/modules/FavoritesPage/FavoritesPage.module.scss b/src/modules/FavoritesPage/FavoritesPage.module.scss new file mode 100644 index 00000000000..1702c6d17b3 --- /dev/null +++ b/src/modules/FavoritesPage/FavoritesPage.module.scss @@ -0,0 +1,24 @@ +@use '../../styles' as s; + +.favoritesPage { + padding-bottom: 40px; + + &__title { + color: var(--text-main-color); + } + + &__count { + margin-bottom: 32px; + color: var(--text-color-secondary); + + @include s.on-tablet { + margin-bottom: 40px; + } + } + + &__empty { + color: var(--text-color-secondary); + font-size: 16px; + font-weight: 600; + } +} diff --git a/src/modules/FavoritesPage/FavoritesPage.tsx b/src/modules/FavoritesPage/FavoritesPage.tsx new file mode 100644 index 00000000000..11b57fa05f6 --- /dev/null +++ b/src/modules/FavoritesPage/FavoritesPage.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { useAppSelector } from '../../app/hooks'; +import { selectFavoritesItems } from '../../features/favorites/favoritesSlice'; +import { ProductList } from '../../components/ProductList/ProductList'; +import { BreadCrumbs } from '../../components/BreadCrumbs'; +import { productMatchesQuery } from '../../utils/productSearch'; +import styles from './FavoritesPage.module.scss'; + +export const FavoritesPage: React.FC = () => { + const favorites = useAppSelector(selectFavoritesItems); + const [searchParams] = useSearchParams(); + const query = searchParams.get('query') ?? ''; + const visibleFavorites = query + ? favorites.filter(product => productMatchesQuery(product, query)) + : favorites; + + return ( +
    + + +

    Favourites

    +

    + {favorites.length} item{favorites.length === 1 ? '' : 's'} +

    + + {favorites.length === 0 ? ( +

    Favorites is empty

    + ) : visibleFavorites.length === 0 ? ( +

    + There are no products matching the query. +

    + ) : ( + + )} +
    + ); +}; diff --git a/src/modules/FavoritesPage/index.ts b/src/modules/FavoritesPage/index.ts new file mode 100644 index 00000000000..b3a884b1889 --- /dev/null +++ b/src/modules/FavoritesPage/index.ts @@ -0,0 +1 @@ +export * from './FavoritesPage'; diff --git a/src/modules/HomePage/HomePage.module.scss b/src/modules/HomePage/HomePage.module.scss new file mode 100644 index 00000000000..b2903f98f97 --- /dev/null +++ b/src/modules/HomePage/HomePage.module.scss @@ -0,0 +1,51 @@ +@use '../../styles' as s; + +.visuallyHidden { + position: absolute; + display: none; + overflow: hidden; +} + +.homeTitle { + font-size: 32px; + line-height: 41px; + align-self: center; + font-weight: 800; + color: var(--text-main-color); + margin: 24px 0; + + @include s.on-tablet { + max-width: 592px; + font-size: 48px; + line-height: 56px; + margin: 32px 0; + } + + @include s.on-desktop { + max-width: fit-content; + margin: 56px 0; + } +} + +.section { + margin: 56px 0; + + @include s.on-tablet { + margin: 64px 0; + } + + @include s.on-tablet { + margin: 80px 0; + } +} + +.sectionTitle{ + font-size: 22px; + font-weight: 800; + margin-bottom: 24px; + color: var(--text-main-color); + + @include s.on-tablet { + font-size: 32px; + } +} diff --git a/src/modules/HomePage/HomePage.tsx b/src/modules/HomePage/HomePage.tsx new file mode 100644 index 00000000000..f7022aef5cf --- /dev/null +++ b/src/modules/HomePage/HomePage.tsx @@ -0,0 +1,57 @@ +import React, { useMemo } from 'react'; +import { useFetchProducts } from '../../features/products/useFetchProducts'; +import { getBrandNewModels } from '../shared/helpers/brandNewModels'; +import { Loader } from '../../components/Loader'; +import { HomeBannerSlider } from './components/HomeBannerSlider'; +import { ProductSlider } from '../../components/ProductSlider'; +import { ShopByCategory } from './components/ShopByCategory'; +import styles from './HomePage.module.scss'; + +export const HomePage: React.FC = () => { + const { products, isLoading, error } = useFetchProducts(); + + const brandNew = useMemo(() => getBrandNewModels(products, 8), [products]); + const hotPrices = useMemo( + () => + products + .filter(p => p.fullPrice !== undefined && p.fullPrice > p.price) + .sort((a, b) => b.fullPrice! - b.price - (a.fullPrice! - a.price)) + .slice(0, 8), + [products], + ); + + if (isLoading) { + return ; + } + + if (error) { + return
    {error}
    ; + } + + return ( +
    +

    Product Catalog

    +

    Welcome to Nice Gadgets store!

    +
    + +
    + +
    + +
    + +
    +

    Shop by category

    + +
    + +
    + +
    +
    + ); +}; diff --git a/src/modules/HomePage/components/HomeBannerSlider/HomeBannerSlider.module.scss b/src/modules/HomePage/components/HomeBannerSlider/HomeBannerSlider.module.scss new file mode 100644 index 00000000000..8a91c8289af --- /dev/null +++ b/src/modules/HomePage/components/HomeBannerSlider/HomeBannerSlider.module.scss @@ -0,0 +1,308 @@ +@use '../../../../styles' as s; + +.homeBannerSlider { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + position: relative; + margin-bottom: 56px; + + @include s.on-tablet { + margin-bottom: 64px; + } + + @include s.on-tablet { + margin-bottom: 80px; + } + + &__wrapper { + display: flex; + width: 100%; + gap: 19px; + + @include s.on-desktop { + gap: 16px; + } + } + + &__arrow { + display: none; + border: none; + padding: 0; + cursor: pointer; + + @include s.on-tablet { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 189px; + background: var(--border-color); + } + + @include s.on-desktop { + height: 400px; + } + } + + &__arrowInner { + display: block; + color: var(--text-main-color); + font-size: 16px; + line-height: 1; + user-select: none; + } + + &__frame { + width: 100vw; + max-width: 100vw; + margin-left: calc(50% - 50vw); + margin-right: calc(50% - 50vw); + padding: 0; + + @include s.on-tablet { + width: 100%; + + margin-left: 0; + margin-right: 0; + } + } + + &__viewport { + width: 100%; + overflow: hidden; + position: relative; + display: block; + aspect-ratio: 1 / 1; + + @include s.on-tablet { + height: 189px; + aspect-ratio: auto; + } + + @include s.on-desktop { + height: 400px; + aspect-ratio: auto; + } + } + + &__track { + display: flex; + width: 100%; + height: 100%; + transition: transform s.$effect-duration ease; + min-height: 0; + } + + &__slide { + min-width: 100%; + height: 100%; + display: flex; + min-height: 0; + } + + + &__banner { + width: 100%; + height: 100%; + overflow: hidden; + background: s.$black; + display: grid; + + + @include s.on-tablet { + display: grid; + grid-template-rows: none; + grid-template-columns: minmax(0, 1.1fr) minmax(0, 1.3fr); + } + } + + &__bannerContent { + display: none; + + @include s.on-tablet { + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: flex-start; + margin: 6px; + padding: 24px; + border-radius: 16px; + + background: s.$banner-back-color; + } + + @include s.on-desktop { + margin: 12px; + padding: 52px; + } + } + + &__bannerTop { + display: flex; + flex-direction: column; + max-width: 280px; + line-height: 1.2; + } + + &__bannerKickerText { + font-size: 24px; + font-weight: 700; + background: linear-gradient(90deg, #891CE8 0%, #7560FA 50%, #E963FF 100%); + background-clip: text; + color: transparent; + + @include s.on-tablet { + padding-right: 8px; + font-size: 20px; + } + + @include s.on-desktop { + font-size: 35px; + } + } + + &__bannerKickerIcon { + display: none; + + @include s.on-tablet { + display: inline-block; + width: 20px; + height: 24px; + } + } + + &__bannerText { + font-size: 10px; + font-weight: 400; + padding-top: 5px; + color: s.$banner-second-color; + + @include s.on-desktop { + font-size: 14px; + } + } + + &__bannerButton { + display: none; + + @include s.on-tablet { + display: flex; + padding: 14px 35px; + border-radius: 999px; + border: 1px solid s.$banner-button-color; + background: transparent; + color: s.$white; + font-size: 10px; + text-decoration: none; + transition: + background s.$effect-duration ease, + color s.$effect-duration ease, + transform s.$effect-duration ease; + + &:hover, + &:focus-visible { + background: s.$banner-button-color; + color: s.$white; + border: 1px solid s.$white; + } + } + + @include s.on-desktop { + font-size: 14px; + } + } + + &__bannerImageWrap { + position: relative; + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding-top: 44px; + + @include s.on-tablet { + padding-top: 0; + } + } + + &__bannerImage { + width: 100%; + max-height: 100%; + display: block; + object-fit: contain; + + @include s.on-tablet { + width: 100%; + object-fit: contain; + } + } + + &__imageKicker { + position: absolute; + top: 14px; + left: 50%; + transform: translateX(-50%); + margin: 0; + z-index: 2; + text-align: center; + + @include s.on-tablet { + display: none; + } + } + + &__imageKickerText { + font-size: 16px; + font-weight: 800; + line-height: 1.2; + text-align: center; + max-width: 280px; + background: linear-gradient(90deg, #891CE8 0%, #7560FA 50%, #E963FF 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + color: transparent; + } + + &__dotsWrap { + width: 100%; + display: flex; + justify-content: center; + margin-top: 10px; + padding: 10px; + box-sizing: border-box; + } + + &__dots { + display: flex; + gap: 12px; + align-items: center; + justify-content: center; + width: 100%; + box-sizing: border-box; + } + + &__dot { + width: 14px; + height: 4px; + background: var(--border-color); + border: none; + cursor: pointer; + transition: + transform s.$effect-duration ease, + background s.$effect-duration ease; + + + &:hover { + transform: scale(1.06); + } + } + + &__dotActive { + background: var(--text-main-color); + } +} diff --git a/src/modules/HomePage/components/HomeBannerSlider/HomeBannerSlider.tsx b/src/modules/HomePage/components/HomeBannerSlider/HomeBannerSlider.tsx new file mode 100644 index 00000000000..291e2005506 --- /dev/null +++ b/src/modules/HomePage/components/HomeBannerSlider/HomeBannerSlider.tsx @@ -0,0 +1,271 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import cn from 'classnames'; +import { Link } from 'react-router-dom'; +import styles from './HomeBannerSlider.module.scss'; +import logoIcon from '../../../../assets/icons/icon-logo.png'; +import { ChevronIcon } from '../../../../components/iconsSVG'; +import { getPublicUrl } from '../../../../utils/publicPath'; + +type BannerId = 'phones' | 'tablets' | 'accessories'; + +type Banner = { + id: BannerId; + kicker?: string; + icon?: string; + text?: string; + cta: string; + link: string; + image: string; +}; + +const BANNERS: Banner[] = [ + { + id: 'phones', + kicker: 'Now available in our store!', + icon: logoIcon, + text: 'Be the first!', + cta: 'ORDER NOW', + link: '/phones', + image: getPublicUrl('img/category-phone.png'), + }, + { + id: 'tablets', + kicker: 'Perfect for work & study', + text: 'Choose your ideal iPad.', + cta: 'SHOP TABLETS', + link: '/tablets', + image: getPublicUrl('img/category-tablets.png'), + }, + { + id: 'accessories', + kicker: 'Designed to move with you', + text: 'Health. Fitness. Style.', + cta: 'SHOP WATCHES', + link: '/accessories', + image: getPublicUrl('img/accessories/apple-watch-series-6/silver/00.webp'), + }, +]; + +const AUTOPLAY_DELAY = 5000; +const SLIDE_WIDTH_PERCENT = 100; +const SWIPE_THRESHOLD = 50; + +export const HomeBannerSlider: React.FC = () => { + const [current, setCurrent] = useState(0); + const len = BANNERS.length; + + const autoplayRef = useRef | null>(null); + const touchStartX = useRef(null); + const touchDeltaX = useRef(0); + + const stopAutoplay = useCallback(() => { + if (autoplayRef.current !== null) { + clearInterval(autoplayRef.current); + autoplayRef.current = null; + } + }, []); + + const startAutoplay = useCallback(() => { + if (autoplayRef.current !== null) { + clearInterval(autoplayRef.current); + } + + autoplayRef.current = setInterval(() => { + setCurrent(previousIndex => (previousIndex + 1) % len); + }, AUTOPLAY_DELAY); + }, [len]); + + useEffect(() => { + startAutoplay(); + + return () => { + stopAutoplay(); + }; + }, [startAutoplay, stopAutoplay]); + + const prev = () => { + setCurrent(previousIndex => (previousIndex - 1 + len) % len); + }; + + const next = () => { + setCurrent(previousIndex => (previousIndex + 1) % len); + }; + + const goTo = (i: number) => { + setCurrent(Math.max(0, Math.min(i, len - 1))); + }; + + const handleTouchStart = (e: React.TouchEvent) => { + stopAutoplay(); + touchStartX.current = e.touches[0].clientX; + touchDeltaX.current = 0; + }; + + const handleTouchMove = (e: React.TouchEvent) => { + if (touchStartX.current == null) { + return; + } + + touchDeltaX.current = e.touches[0].clientX - touchStartX.current; + }; + + const handleTouchEnd = () => { + const delta = touchDeltaX.current; + + touchStartX.current = null; + touchDeltaX.current = 0; + + if (delta > SWIPE_THRESHOLD) { + prev(); + } else if (delta < -SWIPE_THRESHOLD) { + next(); + } + + startAutoplay(); + }; + + return ( +
    +
    + + +
    +
    +
    + {BANNERS.map((banner, idx) => ( +
    +
    +
    +
    + {banner.kicker && ( +

    + + {banner.kicker} + + {banner.icon && ( + + )} +

    + )} + + {banner.text && ( +

    + {banner.text} +

    + )} +
    + + + {banner.cta} + +
    + +
    + {banner.kicker && ( +

    + + {banner.kicker} + +

    + )} + + +
    +
    +
    + ))} +
    +
    +
    + + +
    + +
    +
    + {BANNERS.map((_, idx) => ( +
    +
    +
    + ); +}; diff --git a/src/modules/HomePage/components/HomeBannerSlider/index.ts b/src/modules/HomePage/components/HomeBannerSlider/index.ts new file mode 100644 index 00000000000..8648cdd3730 --- /dev/null +++ b/src/modules/HomePage/components/HomeBannerSlider/index.ts @@ -0,0 +1 @@ +export * from './HomeBannerSlider'; diff --git a/src/modules/HomePage/components/ShopByCategory/ShopByCategory.module.scss b/src/modules/HomePage/components/ShopByCategory/ShopByCategory.module.scss new file mode 100644 index 00000000000..b4bc8c76615 --- /dev/null +++ b/src/modules/HomePage/components/ShopByCategory/ShopByCategory.module.scss @@ -0,0 +1,77 @@ +@use '../../../../styles/' as s; + +.shopByCategory { + margin-bottom: 56px; + + @include s.on-tablet { + margin-bottom: 64px; + } + + @include s.on-desktop { + margin-bottom: 80px; + } + + &__grid { + display: grid; + grid-template-columns: 1fr; + gap: 32px; + align-items: start; + + @include s.on-tablet { + grid-template-columns: repeat(3, 1fr); + gap: 28px; + } + } + + &__card { + display: flex; + flex-direction: column; + gap: 16px; + color: inherit; + text-decoration: none; + transition: transform s.$effect-duration ease; + + &:hover img, + &:focus-visible img { + transform: scale(1.06); + } + } + + &__preview { + width: 100%; + aspect-ratio: 1 / 1; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + } + + &__image { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + transform: scale(1); + transition: transform s.$effect-duration ease; + } + + &__info { + display: flex; + flex-direction: column; + gap: 6px; + } + + &__title { + color: var(--text-main-color); + font-size: 20px; + line-height: 1; + font-weight: 700; + } + + &__count { + color: var(--text-color-secondary); + font-size: 14px; + font-weight: 600; + text-transform: none; + } +} diff --git a/src/modules/HomePage/components/ShopByCategory/ShopByCategory.tsx b/src/modules/HomePage/components/ShopByCategory/ShopByCategory.tsx new file mode 100644 index 00000000000..f3b1994ff6e --- /dev/null +++ b/src/modules/HomePage/components/ShopByCategory/ShopByCategory.tsx @@ -0,0 +1,74 @@ +import React, { useMemo } from 'react'; +import cn from 'classnames'; +import { Link } from 'react-router-dom'; +import { useFetchProducts } from '../../../../features/products'; +import type { Product } from '../../../../types/product'; +import styles from './ShopByCategory.module.scss'; +import phonesImg from '../../../../assets/images/phones.png'; +import tabletsImg from '../../../../assets/images/tablets.png'; +import accessoriesImg from '../../../../assets/images/accessories.png'; + +const CATEGORIES = [ + { id: 'phones', title: 'Mobile phones', img: phonesImg }, + { id: 'tablets', title: 'Tablets', img: tabletsImg }, + { id: 'accessories', title: 'Accessories', img: accessoriesImg }, +]; + +export const ShopByCategory: React.FC = () => { + const { products, isLoading } = useFetchProducts(); + + const counts = useMemo(() => { + const map: Record = { + phones: 0, + tablets: 0, + accessories: 0, + }; + + if (!products || products.length === 0) { + return map; + } + + for (const p of products as Product[]) { + const cat = p.category; + + if (cat in map) { + map[cat] += 1; + } + } + + return map; + }, [products]); + + return ( +
    +
    + {CATEGORIES.map(c => ( + +
    + {c.title} +
    + +
    +
    {c.title}
    +
    + {isLoading ? '...' : `${counts[c.id] ?? 0} models`} +
    +
    + + ))} +
    +
    + ); +}; diff --git a/src/modules/HomePage/components/ShopByCategory/index.ts b/src/modules/HomePage/components/ShopByCategory/index.ts new file mode 100644 index 00000000000..767e814b1f2 --- /dev/null +++ b/src/modules/HomePage/components/ShopByCategory/index.ts @@ -0,0 +1 @@ +export { ShopByCategory } from './ShopByCategory'; diff --git a/src/modules/HomePage/index.ts b/src/modules/HomePage/index.ts new file mode 100644 index 00000000000..11e53da674c --- /dev/null +++ b/src/modules/HomePage/index.ts @@ -0,0 +1 @@ +export * from './HomePage'; diff --git a/src/modules/NotFoundPage/NotFoundPage.module.scss b/src/modules/NotFoundPage/NotFoundPage.module.scss new file mode 100644 index 00000000000..efc030a9e3b --- /dev/null +++ b/src/modules/NotFoundPage/NotFoundPage.module.scss @@ -0,0 +1,36 @@ +@use '../../styles' as s; + +.notFoundPage { + padding: 40px 0; + + &__title { + margin: 0 0 16px; + color: var(--text-main-color); + font-size: 32px; + font-weight: 800; + line-height: 1.28; + } + + &__image { + display: block; + width: min(100%, 520px); + margin: 40px auto 0; + } + + &__text { + margin: 0; + color: var(--text-color-secondary); + font-size: 16px; + font-weight: 600; + } + + &__link { + color: var(--text-main-color); + font-weight: 700; + transition: color s.$effect-duration ease; + + &:hover { + color: var(--button-main-color); + } + } +} diff --git a/src/modules/NotFoundPage/NotFoundPage.tsx b/src/modules/NotFoundPage/NotFoundPage.tsx new file mode 100644 index 00000000000..9d4baf363ed --- /dev/null +++ b/src/modules/NotFoundPage/NotFoundPage.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { getPublicUrl } from '../../utils/publicPath'; +import styles from './NotFoundPage.module.scss'; + +export const NotFoundPage: React.FC = () => ( +
    +

    Page not found

    +

    + Go to{' '} + + Home + + . +

    + Page not found +
    +); diff --git a/src/modules/NotFoundPage/index.ts b/src/modules/NotFoundPage/index.ts new file mode 100644 index 00000000000..6197aa75aa8 --- /dev/null +++ b/src/modules/NotFoundPage/index.ts @@ -0,0 +1 @@ +export * from './NotFoundPage'; diff --git a/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss b/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss new file mode 100644 index 00000000000..369298c288c --- /dev/null +++ b/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss @@ -0,0 +1,383 @@ +@use '../../styles' as s; + +.product-details-page { + color: var(--text-main-color); + min-height: 100vh; + + &__container { + max-width: 1136px; + margin: 0 auto; + } + + &__title { + margin: 0 0 32px; + color: var(--text-main-color); + font-size: 22px; + font-weight: 800; + line-height: 1.4; + + @include s.on-tablet { + font-size: 32px; + line-height: 1.28; + } + } + + &__not-found-text { + margin-bottom: 16px; + color: var(--text-color-secondary); + } + + &__home-link { + color: var(--button-main-color); + font-weight: 700; + } + + &__top { + display: grid; + grid-template-columns: 1fr; + gap: 40px; + align-items: start; + + @include s.on-desktop { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 64px; + } + } + + &__gallery { + display: flex; + flex-direction: column-reverse; + gap: 16px; + align-items: center; + + @include s.on-tablet { + display: grid; + grid-template-columns: 80px minmax(0, 1fr); + } + } + + &__thumbs { + display: flex; + flex-direction: row; + gap: 8px; + overflow-x: auto; + padding-bottom: 2px; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + + @include s.on-tablet { + flex-direction: column; + gap: 16px; + overflow: visible; + padding-bottom: 0; + } + } + + &__thumb { + flex: 0 0 51px; + width: 51px; + height: 51px; + padding: 4px; + border: 1px solid var(--border-color); + background: transparent; + cursor: pointer; + transition: + border-color s.$effect-duration ease, + transform s.$effect-duration ease; + + @include s.on-tablet { + flex: 0 0 80px; + width: 80px; + height: 80px; + padding: 6px; + } + + img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + } + + &:hover, + &--active { + border-color: var(--text-main-color); + } + } + + &__main-image { + display: flex; + align-items: center; + justify-content: center; + min-height: 288px; + + @include s.on-tablet { + min-height: 464px; + } + + img { + display: block; + width: 100%; + max-width: 288px; + height: 288px; + object-fit: contain; + + @include s.on-tablet { + max-width: 464px; + height: 464px; + } + } + } + + &__no-image { + color: var(--text-color-secondary); + font-size: 14px; + } + + &__info { + display: flex; + flex-direction: column; + gap: 24px; + } + + &__options { + margin: 0; + padding: 0 0 24px; + border: 0; + border-bottom: 1px solid var(--border-color); + + legend { + padding-bottom: 8px; + color: var(--text-color-secondary); + font-size: 12px; + font-weight: 600; + line-height: 15px; + } + } + + &__option-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; + + span { + color: var(--icons-hover); + font-size: 12px; + font-weight: 700; + } + } + + &__color-group, + &__capacity-group { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + &__color-button { + position: relative; + width: 32px; + height: 32px; + padding: 2px; + border: 1px solid var(--border-color); + border-radius: 50%; + background: transparent; + cursor: pointer; + transition: + border-color s.$effect-duration ease, + opacity s.$effect-duration ease; + + &::before { + content: ''; + display: block; + width: 100%; + height: 100%; + border-radius: 50%; + background: var(--option-color); + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12); + } + + &:hover, + &--selected { + border-color: var(--text-main-color); + } + + &--disabled { + cursor: default; + opacity: 0.35; + } + + input { + position: absolute; + opacity: 0; + pointer-events: none; + } + } + + &__capacity-button { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 56px; + height: 32px; + padding: 0 8px; + border: 1px solid var(--border-color); + background: transparent; + color: var(--text-main-color); + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: + border-color s.$effect-duration ease, + background-color s.$effect-duration ease, + color s.$effect-duration ease; + + &:hover { + border-color: var(--text-main-color); + } + + &--selected { + border-color: var(--text-main-color); + background: var(--text-main-color); + color: var(--theme-background); + } + + &--disabled { + cursor: default; + opacity: 0.35; + } + + input { + position: absolute; + opacity: 0; + pointer-events: none; + } + } + + &__price { + display: flex; + align-items: baseline; + gap: 8px; + margin-top: 32px; + margin-bottom: 16px; + } + + &__current-price { + color: var(--text-main-color); + font-size: 32px; + font-weight: 800; + line-height: 1.28; + } + + &__old-price { + color: var(--text-color-secondary); + font-size: 22px; + font-weight: 500; + line-height: 1; + text-decoration: line-through; + } + + &__actions { + display: grid; + grid-template-columns: minmax(0, 1fr) 48px; + gap: 8px; + margin-bottom: 32px; + + --product-actions-height: 48px; + } + + &__short-specs, + &__tech-specs { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px 24px; + margin: 0; + + dt { + color: var(--text-color-secondary); + font-size: 12px; + font-weight: 600; + } + + dd { + color: var(--text-main-color); + font-size: 12px; + font-weight: 600; + text-align: right; + } + } + + &__spec-row { + display: contents; + } + + &__details { + display: grid; + grid-template-columns: 1fr; + gap: 56px; + margin: 56px 0; + + @include s.on-tablet { + gap: 64px; + margin: 64px 0; + } + + @include s.on-desktop { + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin: 80px 0; + } + } + + &__section-title { + margin: 0; + padding-bottom: 16px; + border-bottom: 1px solid var(--border-color); + color: var(--text-main-color); + font-size: 20px; + font-weight: 600; + + @include s.on-tablet { + font-size: 22px; + font-weight: 800; + } + } + + &__about-text { + display: grid; + gap: 32px; + padding-top: 32px; + } + + &__about-section { + display: grid; + gap: 16px; + } + + &__about-subtitle { + margin: 0; + color: var(--text-main-color); + font-size: 16px; + + @include s.on-tablet { + font-size: 20px; + } + } + + &__about-paragraph { + margin: 0; + color: var(--text-color-secondary); + } + + &__tech-specs { + padding-top: 24px; + + dd { + max-width: 260px; + } + } +} diff --git a/src/modules/ProductDetailsPage/ProductDetailsPage.tsx b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx new file mode 100644 index 00000000000..5ad5af90ffd --- /dev/null +++ b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx @@ -0,0 +1,94 @@ +import React from 'react'; + +import { BackLink } from '../../components/BackLink'; +import { BreadCrumbs } from '../../components/BreadCrumbs'; +import { Loader } from '../../components/Loader'; +import { SuggestedProducts } from '../../components/SuggestedProducts'; +import { + ProductAbout, + ProductGallery, + ProductNotFound, + ProductOptions, + ProductSpecs, + ProductSummary, +} from './components'; +import { block, cx, styles } from './components/styles'; +import { useProductDetailsPage } from './useProductDetailsPage'; + +export const ProductDetailsPage: React.FC = () => { + const { content, gallery, navigation, options, state, suggestions, summary } = + useProductDetailsPage(); + const { detailsLoading, display, isLoading, product, productId } = state; + const { navigateBack } = navigation; + + if ((isLoading || detailsLoading) && !display) { + return ; + } + + if (!product && !isLoading) { + return ; + } + + if (!display) { + return null; + } + + if (!summary.priceValue) { + return null; + } + + return ( +
    +
    + + + + +

    {display.name}

    + +
    + + +
    + + + +
    +
    + +
    + + +
    + + +
    +
    + ); +}; + +export default ProductDetailsPage; diff --git a/src/modules/ProductDetailsPage/components/ProductAbout.tsx b/src/modules/ProductDetailsPage/components/ProductAbout.tsx new file mode 100644 index 00000000000..978060c42ed --- /dev/null +++ b/src/modules/ProductDetailsPage/components/ProductAbout.tsx @@ -0,0 +1,32 @@ +import React from 'react'; + +import type { DescriptionSection } from '../descriptionUtils'; +import { cx } from './styles'; + +interface Props { + sections: DescriptionSection[]; +} + +export const ProductAbout: React.FC = ({ sections }) => ( +
    +

    About

    + +
    + {sections.map((section, index) => ( +
    + {section.title && ( +

    {section.title}

    + )} + {section.paragraphs.map((paragraph, paragraphIndex) => ( +

    + {paragraph} +

    + ))} +
    + ))} +
    +
    +); diff --git a/src/modules/ProductDetailsPage/components/ProductGallery.tsx b/src/modules/ProductDetailsPage/components/ProductGallery.tsx new file mode 100644 index 00000000000..8fe988783db --- /dev/null +++ b/src/modules/ProductDetailsPage/components/ProductGallery.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import cn from 'classnames'; + +import { block, cx, styles } from './styles'; + +interface Props { + productName: string; + images: string[]; + mainImage?: string; + onSelectImage: (image: string) => void; +} + +export const ProductGallery: React.FC = ({ + productName, + images, + mainImage, + onSelectImage, +}) => ( +
    +
    + {images.map((image, index) => ( + + ))} +
    + +
    + {mainImage ? ( + {productName} + ) : ( +
    No image
    + )} +
    +
    +); diff --git a/src/modules/ProductDetailsPage/components/ProductNotFound.tsx b/src/modules/ProductDetailsPage/components/ProductNotFound.tsx new file mode 100644 index 00000000000..355f13d186d --- /dev/null +++ b/src/modules/ProductDetailsPage/components/ProductNotFound.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +import { BackLink } from '../../../components/BackLink'; +import { block, cx, styles } from './styles'; + +interface Props { + productId?: string; + onBack: () => void; +} + +export const ProductNotFound: React.FC = ({ productId, onBack }) => ( +
    +
    + +

    Product was not found

    +

    + The product with id {productId} is missing. +

    + + Return to home + +
    +
    +); diff --git a/src/modules/ProductDetailsPage/components/ProductOptions.tsx b/src/modules/ProductDetailsPage/components/ProductOptions.tsx new file mode 100644 index 00000000000..0efbf34830c --- /dev/null +++ b/src/modules/ProductDetailsPage/components/ProductOptions.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import cn from 'classnames'; + +import type { Product } from '../../../types/product'; +import { + formatOption, + getColorValue, + normalizeOption, +} from '../productOptions'; +import { block, cx, styles } from './styles'; + +interface Props { + displayId: string | number; + colors: string[]; + capacities: (string | number)[]; + selectedColorValue: string; + selectedCapacityValue: string; + findVariant: ( + capacity: string | number, + color: string, + ) => Product | undefined; + onSelectVariant: (capacity: string | number, color: string) => void; +} + +export const ProductOptions: React.FC = ({ + displayId, + colors, + capacities, + selectedColorValue, + selectedCapacityValue, + findVariant, + onSelectVariant, +}) => ( + <> + {colors.length > 0 && ( +
    +
    + Available colors + ID: {displayId} +
    + +
    + {colors.map(color => { + const variant = findVariant(selectedCapacityValue, color); + const isSelected = + normalizeOption(selectedColorValue) === normalizeOption(color); + + return ( + + ); + })} +
    +
    + )} + + {capacities.length > 0 && ( +
    + Select capacity + +
    + {capacities.map(capacity => { + const variant = findVariant(capacity, selectedColorValue); + const isSelected = + normalizeOption(selectedCapacityValue) === + normalizeOption(capacity); + + return ( + + ); + })} +
    +
    + )} + +); diff --git a/src/modules/ProductDetailsPage/components/ProductSpecs.tsx b/src/modules/ProductDetailsPage/components/ProductSpecs.tsx new file mode 100644 index 00000000000..c37536e5d19 --- /dev/null +++ b/src/modules/ProductDetailsPage/components/ProductSpecs.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +import type { ProductSpec } from './ProductSummary'; +import { cx } from './styles'; + +interface Props { + specs: ProductSpec[]; +} + +export const ProductSpecs: React.FC = ({ specs }) => ( +
    +

    Tech specs

    + +
    + {specs.map(spec => ( +
    +
    {spec.label}
    +
    {spec.value}
    +
    + ))} +
    +
    +); diff --git a/src/modules/ProductDetailsPage/components/ProductSummary.tsx b/src/modules/ProductDetailsPage/components/ProductSummary.tsx new file mode 100644 index 00000000000..8fa94e752af --- /dev/null +++ b/src/modules/ProductDetailsPage/components/ProductSummary.tsx @@ -0,0 +1,58 @@ +import React from 'react'; + +import { ProductActions } from '../../../components/ProductActions'; +import { ProductPrice } from '../../../components/ProductPrice'; +import { cx } from './styles'; + +export interface ProductSpec { + label: string; + value: string; +} + +interface Props { + price: number; + fullPrice?: number; + isInCart: boolean; + isFavorited: boolean; + specs: ProductSpec[]; + onCartClick: () => void; + onFavoriteClick: () => void; +} + +export const ProductSummary: React.FC = ({ + price, + fullPrice, + isInCart, + isFavorited, + specs, + onCartClick, + onFavoriteClick, +}) => ( + <> + + +
    + +
    + +
    + {specs.map(spec => ( +
    +
    {spec.label}
    +
    {spec.value}
    +
    + ))} +
    + +); diff --git a/src/modules/ProductDetailsPage/components/index.ts b/src/modules/ProductDetailsPage/components/index.ts new file mode 100644 index 00000000000..3c94b0ce740 --- /dev/null +++ b/src/modules/ProductDetailsPage/components/index.ts @@ -0,0 +1,6 @@ +export { ProductAbout } from './ProductAbout'; +export { ProductGallery } from './ProductGallery'; +export { ProductNotFound } from './ProductNotFound'; +export { ProductOptions } from './ProductOptions'; +export { ProductSpecs } from './ProductSpecs'; +export { ProductSummary } from './ProductSummary'; diff --git a/src/modules/ProductDetailsPage/components/styles.ts b/src/modules/ProductDetailsPage/components/styles.ts new file mode 100644 index 00000000000..a10c01916f8 --- /dev/null +++ b/src/modules/ProductDetailsPage/components/styles.ts @@ -0,0 +1,5 @@ +import styles from '../ProductDetailsPage.module.scss'; + +export const block = 'product-details-page'; +export const cx = (element: string) => styles[`${block}__${element}`]; +export { styles }; diff --git a/src/modules/ProductDetailsPage/descriptionUtils.tsx b/src/modules/ProductDetailsPage/descriptionUtils.tsx new file mode 100644 index 00000000000..7f8929c0ab0 --- /dev/null +++ b/src/modules/ProductDetailsPage/descriptionUtils.tsx @@ -0,0 +1,17 @@ +export interface DescriptionSection { + title?: string; + paragraphs: string[]; +} + +interface ProductDescriptionItem { + title?: string; + text: string[]; +} + +export const getDescriptionSections = ( + description?: ProductDescriptionItem[] | null, +): DescriptionSection[] => + description?.map(section => ({ + title: section.title, + paragraphs: section.text, + })) ?? []; diff --git a/src/modules/ProductDetailsPage/index.ts b/src/modules/ProductDetailsPage/index.ts new file mode 100644 index 00000000000..6615089e5ec --- /dev/null +++ b/src/modules/ProductDetailsPage/index.ts @@ -0,0 +1 @@ +export * from './ProductDetailsPage'; diff --git a/src/modules/ProductDetailsPage/productOptions.ts b/src/modules/ProductDetailsPage/productOptions.ts new file mode 100644 index 00000000000..61ce6008737 --- /dev/null +++ b/src/modules/ProductDetailsPage/productOptions.ts @@ -0,0 +1,130 @@ +import type { Product, ProductWithDetails } from '../../types/product'; + +type ProductView = ProductWithDetails; + +const colorMap: Record = { + black: '#1f2020', + blue: '#4c77a8', + coral: '#ff7f6e', + gold: '#f4d6b8', + graphite: '#54524f', + green: '#aee1cd', + midnight: '#1f2833', + midnightgreen: '#4e5851', + pink: '#f7c8cf', + purple: '#d9c7f1', + red: '#c91c2d', + rosegold: '#f6c7bd', + silver: '#e4e6e8', + skyblue: '#b9d8ee', + spaceblack: '#1d1d1f', + spacegray: '#535150', + starlight: '#f2eadf', + white: '#f8f8f8', + yellow: '#f7e27b', +}; + +export const normalizeOption = (value?: string | number | null) => + String(value ?? '') + .toLowerCase() + .replace(/[^a-z0-9]/g, ''); + +const toSlug = (value: string) => + value + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/g, ''); + +export const getProductNamespace = (item: ProductView) => { + if (item.namespaceId) { + return String(item.namespaceId); + } + + const id = item.itemId; + const color = toSlug(item.color); + const withoutColor = id.endsWith(`-${color}`) + ? id.slice(0, -color.length - 1) + : id; + + return withoutColor.replace(/-\d+(gb|tb|mm)$/i, ''); +}; + +export const getProductCapacity = (item: Product) => { + const id = item.itemId; + const color = toSlug(item.color); + const withoutColor = id.endsWith(`-${color}`) + ? id.slice(0, -color.length - 1) + : id; + const capacityFromId = withoutColor.match(/(\d+(gb|tb|mm))$/i)?.[1]; + + return capacityFromId ?? item.capacity; +}; + +export const formatOption = (value: string | number) => + String(value) + .replace(/(\d+)\s*(gb|tb)$/i, '$1 $2') + .toUpperCase(); + +export const getColorValue = (color: string) => + colorMap[normalizeOption(color)] ?? color.replace(/\s+/g, '').toLowerCase(); + +export const getProductWithHiddenDiscount = ( + product: T, + hideDiscount: boolean, +): T => { + if (!product || !hideDiscount) { + return product; + } + + return { + ...product, + price: product.fullPrice ?? product.price, + fullPrice: undefined, + } as T; +}; + +export const getProductOptionValues = (product: ProductView) => ({ + colors: product.colorsAvailable ?? [product.color], + capacities: product.capacityAvailable ?? [product.capacity], +}); + +export const getProductPrice = (product: ProductView, hideDiscount = false) => { + if (hideDiscount) { + return { + priceValue: Number( + product.priceRegular ?? product.fullPrice ?? product.price, + ), + fullPriceValue: undefined, + }; + } + + const price = product.priceDiscount ?? product.price; + const fullPrice = + product.priceDiscount !== undefined + ? product.priceRegular + : product.fullPrice; + + return { + priceValue: Number(price), + fullPriceValue: fullPrice === undefined ? undefined : Number(fullPrice), + }; +}; + +export const getProductSpecs = ( + product: ProductView, + keys: Array, +) => + keys + .map(key => { + const value = product[key]; + + if (!value) { + return null; + } + + return { + label: key.charAt(0).toUpperCase() + key.slice(1), + value: Array.isArray(value) ? value.join(', ') : String(value), + }; + }) + .filter((spec): spec is { label: string; value: string } => Boolean(spec)); diff --git a/src/modules/ProductDetailsPage/useProductDetailsPage.ts b/src/modules/ProductDetailsPage/useProductDetailsPage.ts new file mode 100644 index 00000000000..60a90f21199 --- /dev/null +++ b/src/modules/ProductDetailsPage/useProductDetailsPage.ts @@ -0,0 +1,194 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; + +import { useFetchProducts } from '../../features/products/useFetchProducts'; +import { useProductDetails } from '../../features/products/useProductDetails'; +import type { Product, ProductWithDetails } from '../../types/product'; +import { scrollToTop } from '../../utils/scrollToTop'; +import { useProductActions } from '../shared/hooks/useProductActions'; +import { getDescriptionSections } from './descriptionUtils'; +import { + getProductCapacity, + getProductNamespace, + getProductOptionValues, + getProductPrice, + getProductSpecs, + getProductWithHiddenDiscount, + normalizeOption, +} from './productOptions'; + +interface ProductDetailsLocationState { + hideDiscount?: boolean; +} + +const isString = (value?: string): value is string => Boolean(value); +const SHORT_SPEC_KEYS: Array = [ + 'screen', + 'resolution', + 'processor', + 'ram', +]; +const TECH_SPEC_KEYS: Array = [ + 'screen', + 'resolution', + 'ram', + 'capacity', + 'processor', + 'cell', + 'zoom', + 'camera', + 'year', +]; + +export const useProductDetailsPage = () => { + const { productId } = useParams<{ productId: string }>(); + const navigate = useNavigate(); + const { state } = useLocation(); + const hideDiscount = Boolean( + (state as ProductDetailsLocationState | null)?.hideDiscount, + ); + + const { products, isLoading } = useFetchProducts(); + const product = useMemo(() => { + if (!productId) { + return undefined; + } + + return products.find( + item => (item.itemId ?? String(item.id)) === productId, + ); + }, [products, productId]); + + const productForDetails = useMemo( + () => getProductWithHiddenDiscount(product, hideDiscount), + [hideDiscount, product], + ); + const { mergedProduct: display, loading: detailsLoading } = useProductDetails( + productForDetails ?? null, + ); + + const [selectedColor, setSelectedColor] = useState(''); + const [selectedCapacity, setSelectedCapacity] = useState(''); + const [mainImage, setMainImage] = useState(); + + const currentItemId = String( + product?.itemId ?? display?.itemId ?? display?.id ?? '', + ); + const cartProductBase = currentItemId + ? (products.find(item => item.itemId === currentItemId) ?? product) + : product; + const { isInCart, isFavorited, handleCartClick, handleFavoriteClick } = + useProductActions({ + product: cartProductBase, + hideDiscount, + }); + + useEffect(() => { + scrollToTop(); + }, [productId]); + + useEffect(() => { + if (!display) { + return; + } + + setSelectedColor( + display.colorsAvailable?.[0] ?? + (display.color ? String(display.color) : ''), + ); + setSelectedCapacity( + display.capacityAvailable?.[0] ?? display.capacity ?? '', + ); + setMainImage(display.images?.[0] ?? display.image); + }, [display]); + + const namespace = display ? getProductNamespace(display) : ''; + const variantProducts = useMemo(() => { + if (!display) { + return []; + } + + return products.filter( + item => + item.category === display.category && + getProductNamespace(item) === namespace, + ); + }, [display, namespace, products]); + const selectedColorValue = String(display?.color ?? selectedColor); + const selectedCapacityValue = String(display?.capacity ?? selectedCapacity); + const { colors, capacities } = display + ? getProductOptionValues(display) + : { colors: [], capacities: [] }; + + const findVariant = useCallback( + (capacity: string | number, color: string) => + variantProducts.find( + item => + normalizeOption(getProductCapacity(item)) === + normalizeOption(capacity) && + normalizeOption(item.color) === normalizeOption(color), + ), + [variantProducts], + ); + + const goToVariant = useCallback( + (capacity: string | number, color: string) => { + const variant = findVariant(capacity, color); + + if (variant && variant.itemId !== currentItemId) { + navigate(`/product/${variant.itemId}`, { + replace: true, + ...(hideDiscount ? { state: { hideDiscount: true } } : {}), + }); + } + }, + [currentItemId, findVariant, hideDiscount, navigate], + ); + + const price = display ? getProductPrice(display, hideDiscount) : null; + const categoryPath = `/${display?.category ?? product?.category ?? 'phones'}`; + + return { + state: { + detailsLoading, + display, + isLoading, + product, + productId, + }, + navigation: { + navigateBack: () => navigate(categoryPath), + }, + gallery: { + images: (display?.images ?? [display?.image]).filter(isString), + mainImage, + setMainImage, + }, + options: { + capacities, + colors, + displayId: product?.id ?? display?.id ?? currentItemId, + findVariant, + goToVariant, + selectedCapacityValue, + selectedColorValue, + }, + summary: { + fullPriceValue: price?.fullPriceValue, + handleCartClick, + handleFavoriteClick, + isFavorited, + isInCart, + priceValue: price?.priceValue, + shortSpecs: display ? getProductSpecs(display, SHORT_SPEC_KEYS) : [], + }, + content: { + descriptionSections: getDescriptionSections(display?.description), + techSpecs: display ? getProductSpecs(display, TECH_SPEC_KEYS) : [], + }, + suggestions: { + currentItemId, + products, + }, + }; +}; diff --git a/src/modules/ProductListPage/ProductListPage.module.scss b/src/modules/ProductListPage/ProductListPage.module.scss new file mode 100644 index 00000000000..fa798abe25b --- /dev/null +++ b/src/modules/ProductListPage/ProductListPage.module.scss @@ -0,0 +1,132 @@ +@use '../../styles/index' as s; + +.product-list-page { + &__header { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 32px; + + @include s.on-tablet { + margin-bottom: 40px; + } + } + + &__title { + color: var(--text-main-color); + font-size: 48px; + } + + &__meta { + line-height: 21px; + justify-content: left; + color: var(--text-color-secondary); + } + + &__controls { + margin-bottom: 24px; + } + + &__form { + display: flex; + gap: 16px; + flex-wrap: wrap; + align-items: center; + } + + &__field { + display: inline-flex; + flex-direction: column; + gap: 6px; + min-width: 160px; + } + + &__label { + font-size: 12px; + color: var(--text-color-secondary); + } + + &__input { + height: 40px; + padding: 0 12px; + border: 1px solid var(--icons-hover); + + background: var(--added-button); + color: var(--text-main-color); + + font-size: 14px; + font-weight: 700; + cursor: pointer; + } + + &__select-wrap { + position: relative; + display: block; + color: var(--text-main-color); + } + + &__select { + width: 100%; + height: 40px; + padding: 0 40px 0 12px; + appearance: none; + + border: 1px solid var(--pagination-border-color); + background: var(--added-button); + color: var(--text-main-color); + + font-family: s.$font-family; + font-size: 14px; + cursor: pointer; + } + + &__select-icon { + position: absolute; + top: 30%; + color: var(--text-color-secondary); + right: 16px; + pointer-events: none; + } + + &__pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + margin: 28px 0; + } + + &__page-btn { + padding: 8px 12px; + background: transparent; + border: 1px solid var(--border-color); + border-radius: 6px; + cursor: pointer; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + + &__pages { + display: flex; + align-items: center; + gap: 6px; + color: var(--text-color-secondary); + } + + &__page-current { + font-weight: 800; + color: var(--text-main-color); + } + + &__error { + color: s.$red; + padding: 16px 0; + } + + &__empty { + color: var(--text-main-color); + } +} diff --git a/src/modules/ProductListPage/ProductListPage.tsx b/src/modules/ProductListPage/ProductListPage.tsx new file mode 100644 index 00000000000..edff005da89 --- /dev/null +++ b/src/modules/ProductListPage/ProductListPage.tsx @@ -0,0 +1,186 @@ +import React from 'react'; +import { ProductList } from '../../components/ProductList'; +import { + useProductsForCategory, + type SortKey, +} from '../../modules/shared/hooks'; +import { Loader } from '../../components/Loader'; +import { BreadCrumbs } from '../../components/BreadCrumbs'; +import { Pagination } from '../../components/Pagination'; +import { ChevronIcon } from '../../components/iconsSVG'; +import styles from './ProductListPage.module.scss'; + +type Category = 'phones' | 'tablets' | 'accessories'; + +type Props = { + category: Category; +}; + +const titles: Record = { + phones: 'Mobile phones', + tablets: 'Tablets', + accessories: 'Accessories', +}; + +export const ProductListPage: React.FC = ({ category }) => { + const title = titles[category]; + + const { + items, + totalItems, + totalPages, + isLoading, + error, + query, + sort, + setSort, + page, + setPage, + perPage, + setPerPage, + } = useProductsForCategory({ type: category, syncWithUrl: true }); + + if (isLoading) { + return ; + } + + if (error) { + return ( +
    +
    + +
    +

    Something went wrong while loading products.

    + +
    +
    +
    + ); + } + + if (!items || items.length === 0) { + return ( +
    +
    + +
    + {query + ? `There are no ${category.toLowerCase()} matching the query.` + : `There are no ${category.toLowerCase()} yet.`} +
    +
    +
    + ); + } + + const onPerPageChange = (e: React.ChangeEvent) => { + setPerPage(e.target.value); + }; + + const onSortChange = (e: React.ChangeEvent) => { + setSort(e.target.value as SortKey); + }; + + return ( +
    +
    + + +
    +

    {title}

    +
    +

    + {totalItems} model{totalItems === 1 ? '' : 's'} +

    +
    +
    + +
    +
    + + + +
    +
    + +
    + +
    + + {perPage !== 'all' && totalPages > 1 && ( +
    + setPage(p)} + maxVisible={5} + /> +
    + )} +
    +
    + ); +}; diff --git a/src/modules/ProductListPage/index.ts b/src/modules/ProductListPage/index.ts new file mode 100644 index 00000000000..d4a28061ed3 --- /dev/null +++ b/src/modules/ProductListPage/index.ts @@ -0,0 +1 @@ +export { ProductListPage } from './ProductListPage'; diff --git a/src/modules/shared/helpers/brandNewModels.ts b/src/modules/shared/helpers/brandNewModels.ts new file mode 100644 index 00000000000..f2d0e4a2928 --- /dev/null +++ b/src/modules/shared/helpers/brandNewModels.ts @@ -0,0 +1,25 @@ +import { Product } from '../../../types/product'; + +export function getBrandNewModels(products: Product[], count = 4): Product[] { + const sorted = [...products] + .filter(p => p.year !== undefined) + .sort((a, b) => b.year - a.year || b.id - a.id); + + const seen = new Set(); + const result: Product[] = []; + + for (const p of sorted) { + const key = p.itemId ?? p.name; + + if (!seen.has(key)) { + seen.add(key); + result.push(p); + } + + if (result.length >= count) { + break; + } + } + + return result; +} diff --git a/src/modules/shared/hooks/index.ts b/src/modules/shared/hooks/index.ts new file mode 100644 index 00000000000..04d54045e24 --- /dev/null +++ b/src/modules/shared/hooks/index.ts @@ -0,0 +1,3 @@ +export * from './useCart'; +export * from './useProductActions'; +export * from './useProductsForCategory'; diff --git a/src/modules/shared/hooks/useCart.ts b/src/modules/shared/hooks/useCart.ts new file mode 100644 index 00000000000..bed98512a6f --- /dev/null +++ b/src/modules/shared/hooks/useCart.ts @@ -0,0 +1,30 @@ +import { useAppSelector, useAppDispatch } from '../../../app/hooks'; +import { + addToCart, + removeFromCart, + clearCart, + selectCartItems, + selectCartTotalPrice, + selectCartTotalQuantity, +} from '../../../features/cart/cartSlice'; +import type { Product } from '../../../types/product'; + +export const useCart = () => { + const dispatch = useAppDispatch(); + const cartItems = useAppSelector(selectCartItems); + const totalAmount = useAppSelector(selectCartTotalPrice); + const totalQuantity = useAppSelector(selectCartTotalQuantity); + + const add = (product: Product) => dispatch(addToCart(product)); + const remove = (itemId: string) => dispatch(removeFromCart(itemId)); + const clear = () => dispatch(clearCart()); + + return { + cartItems, + totalAmount, + totalQuantity, + add, + remove, + clear, + }; +}; diff --git a/src/modules/shared/hooks/useProductActions.ts b/src/modules/shared/hooks/useProductActions.ts new file mode 100644 index 00000000000..bc825d2a75d --- /dev/null +++ b/src/modules/shared/hooks/useProductActions.ts @@ -0,0 +1,77 @@ +import { useMemo } from 'react'; + +import { useAppDispatch, useAppSelector } from '../../../app/hooks'; +import { addToCart } from '../../../features/cart/cartSlice'; +import { toggleFavorite } from '../../../features/favorites/favoritesSlice'; +import type { Product } from '../../../types/product'; + +interface UseProductActionsParams { + product?: Product | null; + actionProduct?: Product | null; + hideDiscount?: boolean; +} + +const getActionProduct = (product?: Product | null, hideDiscount = false) => { + if (!product) { + return undefined; + } + + if (!hideDiscount) { + return product; + } + + return { + ...product, + price: product.fullPrice ?? product.price, + fullPrice: undefined, + }; +}; + +export const useProductActions = ({ + product, + actionProduct, + hideDiscount = false, +}: UseProductActionsParams) => { + const dispatch = useAppDispatch(); + const cartItems = useAppSelector(state => state.cart.items); + const favorites = useAppSelector(state => state.favorites.items); + const itemId = product?.itemId; + + const productForAction = useMemo( + () => getActionProduct(actionProduct ?? product, hideDiscount), + [actionProduct, hideDiscount, product], + ); + + const isInCart = useMemo( + () => + itemId ? cartItems.some(item => item.product.itemId === itemId) : false, + [cartItems, itemId], + ); + + const isFavorited = useMemo( + () => (itemId ? favorites.some(item => item.itemId === itemId) : false), + [favorites, itemId], + ); + + const handleCartClick = () => { + if (!itemId || !productForAction || isInCart) { + return; + } + + dispatch(addToCart(productForAction)); + }; + + const handleFavoriteClick = () => { + if (productForAction) { + dispatch(toggleFavorite(productForAction)); + } + }; + + return { + productForAction, + isInCart, + isFavorited, + handleCartClick, + handleFavoriteClick, + }; +}; diff --git a/src/modules/shared/hooks/useProductsForCategory.ts b/src/modules/shared/hooks/useProductsForCategory.ts new file mode 100644 index 00000000000..a617cf42cc5 --- /dev/null +++ b/src/modules/shared/hooks/useProductsForCategory.ts @@ -0,0 +1,226 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { useFetchProducts } from '../../../features/products/useFetchProducts'; +import type { Product } from '../../../types/product'; +import { productMatchesQuery } from '../../../utils/productSearch'; + +export type CategoryType = 'phones' | 'tablets' | 'accessories'; +export type SortKey = 'age' | 'title' | 'price' | ''; + +export interface UseProductsParams { + type: CategoryType; + syncWithUrl?: boolean; + initialQuery?: string; + initialSort?: SortKey; + initialPage?: number; + initialPerPage?: string; +} + +export interface UseProductsResult { + items: Product[]; + totalItems: number; + totalPages: number; + isLoading: boolean; + error: string | null; + query: string; + setQuery: (q: string) => void; + sort: SortKey; + setSort: (s: SortKey) => void; + page: number; + setPage: (p: number) => void; + perPage: string; + setPerPage: (pp: string) => void; +} + +export function useProductsForCategory( + opts: UseProductsParams, +): UseProductsResult { + const { + type, + syncWithUrl = true, + initialQuery = '', + initialSort = '', + initialPage = 1, + initialPerPage = 'all', + } = opts; + + const { products, isLoading, error } = useFetchProducts(); + const [searchParams, setSearchParams] = useSearchParams(); + + const updateParams = useCallback( + (update: (params: URLSearchParams) => void) => { + const params = new URLSearchParams(searchParams); + + update(params); + setSearchParams(params, { replace: true }); + }, + [searchParams, setSearchParams], + ); + + const query = syncWithUrl + ? (searchParams.get('query') ?? initialQuery) + : initialQuery; + const sort = syncWithUrl + ? ((searchParams.get('sort') as SortKey) ?? initialSort) + : initialSort; + const page = syncWithUrl + ? parseInt(searchParams.get('page') ?? '', 10) || initialPage + : initialPage; + const perPage = syncWithUrl + ? (searchParams.get('perPage') ?? initialPerPage) + : initialPerPage; + + const setQuery = useCallback( + (value: string) => { + if (!syncWithUrl) { + return; + } + + updateParams(params => { + const normalized = value.trim(); + + if (normalized) { + params.set('query', normalized); + } else { + params.delete('query'); + } + + params.delete('page'); + }); + }, + [syncWithUrl, updateParams], + ); + + const setSort = useCallback( + (value: SortKey) => { + if (!syncWithUrl) { + return; + } + + updateParams(params => { + if (value) { + params.set('sort', value); + } else { + params.delete('sort'); + } + + params.delete('page'); + }); + }, + [syncWithUrl, updateParams], + ); + + const setPage = useCallback( + (value: number) => { + if (!syncWithUrl) { + return; + } + + updateParams(params => { + if (value && value !== 1) { + params.set('page', String(value)); + } else { + params.delete('page'); + } + }); + }, + [syncWithUrl, updateParams], + ); + + const setPerPage = useCallback( + (value: string) => { + if (!syncWithUrl) { + return; + } + + updateParams(params => { + if (value && value !== 'all') { + params.set('perPage', value); + } else { + params.delete('perPage'); + } + + params.delete('page'); + }); + }, + [syncWithUrl, updateParams], + ); + + const byCategory = useMemo(() => { + if (!products) { + return []; + } + + return products.filter(product => product.category === type); + }, [products, type]); + + const filtered = useMemo(() => { + if (!query) { + return byCategory; + } + + return byCategory.filter(product => productMatchesQuery(product, query)); + }, [byCategory, query]); + + const sorted = useMemo(() => { + if (!sort) { + return filtered; + } + + const items = [...filtered]; + + if (sort === 'age') { + items.sort((a, b) => (b.year ?? 0) - (a.year ?? 0)); + } else if (sort === 'title') { + items.sort((a, b) => a.name.localeCompare(b.name)); + } else if (sort === 'price') { + items.sort((a, b) => (a.price ?? 0) - (b.price ?? 0)); + } + + return items; + }, [filtered, sort]); + + const totalItems = sorted.length; + const perPageNum = + perPage === 'all' ? Infinity : parseInt(perPage, 10) || Infinity; + const totalPages = + perPageNum === Infinity + ? 1 + : Math.max(1, Math.ceil(totalItems / perPageNum)); + + useEffect(() => { + if (page > totalPages) { + setPage(totalPages); + } + + if (page < 1) { + setPage(1); + } + }, [page, setPage, totalPages]); + + const paginated = useMemo(() => { + if (perPageNum === Infinity) { + return sorted; + } + + const start = (page - 1) * perPageNum; + + return sorted.slice(start, start + perPageNum); + }, [page, perPageNum, sorted]); + + return { + items: paginated, + totalItems, + totalPages, + isLoading, + error, + query, + setQuery, + sort, + setSort, + page, + setPage, + perPage, + setPerPage, + }; +} diff --git a/src/routes/AppRoutes.tsx b/src/routes/AppRoutes.tsx new file mode 100644 index 00000000000..f456fcff8f6 --- /dev/null +++ b/src/routes/AppRoutes.tsx @@ -0,0 +1,54 @@ +import React, { Suspense, lazy } from 'react'; +import { Routes, Route } from 'react-router-dom'; +import { Layout } from '../components/Layout'; +import { Loader } from '../components/Loader'; + +const HomePage = lazy(() => + import('../modules/HomePage').then(m => ({ default: m.HomePage })), +); +const ProductListPage = lazy(() => + import('../modules/ProductListPage').then(m => ({ + default: m.ProductListPage, + })), +); +const ProductDetailsPage = lazy(() => + import('../modules/ProductDetailsPage').then(m => ({ + default: m.ProductDetailsPage, + })), +); +const CartPage = lazy(() => + import('../modules/CartPage').then(m => ({ default: m.CartPage })), +); +const FavoritesPage = lazy(() => + import('../modules/FavoritesPage').then(m => ({ default: m.FavoritesPage })), +); +const NotFoundPage = lazy(() => + import('../modules/NotFoundPage').then(m => ({ default: m.NotFoundPage })), +); + +export const AppRoutes: React.FC = () => ( + }> + + }> + } /> + + } /> + } + /> + } + /> + + } /> + + } /> + } /> + + } /> + + + +); diff --git a/src/styles/app.scss b/src/styles/app.scss new file mode 100644 index 00000000000..3cf151efc8e --- /dev/null +++ b/src/styles/app.scss @@ -0,0 +1,3 @@ +@use './themes'; +@use './typography'; +@use './global'; diff --git a/src/styles/global.scss b/src/styles/global.scss new file mode 100644 index 00000000000..cf3b29ff937 --- /dev/null +++ b/src/styles/global.scss @@ -0,0 +1,75 @@ +@use './vars' as v; + +@font-face { + font-family: Mont; + src: + url('../assets/fonts/Mont-Regular.woff2') format('woff2'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: Mont; + src: + url('../assets/fonts/Mont-SemiBold.woff2') format('woff2'); + font-weight: 600; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: Mont; + src: + url('../assets/fonts/Mont-Bold.woff2') format('woff2'); + font-weight: 700 800; + font-style: normal; + font-display: swap; +} + +html, +body { + min-height: 100%; +} + +html { + height: 100%; +} + +body { + background-color: var(--theme-background); + font-family: v.$font-family; + margin: 0; + min-height: 100dvh; + transition: + background-color v.$effect-duration ease, + color v.$effect-duration ease; +} + +a { + color: inherit; + text-decoration: none; + + &:hover, + &:focus, + &:visited, + &:active { + text-decoration: none; + } +} + +.no-scroll { + overflow: hidden; +} + +.visually-hidden { + position: absolute; + overflow: hidden; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + border: 0; + + clip: rect(0, 0, 0, 0); +} diff --git a/src/styles/index.scss b/src/styles/index.scss new file mode 100644 index 00000000000..d65b33720d9 --- /dev/null +++ b/src/styles/index.scss @@ -0,0 +1,2 @@ +@forward './mixins'; +@forward './vars'; diff --git a/src/styles/mixins.scss b/src/styles/mixins.scss new file mode 100644 index 00000000000..d63ca66bd2b --- /dev/null +++ b/src/styles/mixins.scss @@ -0,0 +1,81 @@ +@use './vars' as v; + +@mixin on-mobile { + @media (min-width: v.$mobile-min-width) { + @content; + } +} + +@mixin on-tablet { + @media (min-width: v.$tablet-min-width) { + @content; + } +} + +@mixin on-laptop { + @media (min-width: v.$laptop-min-width) { + @content; + } +} + +@mixin on-desktop { + @media (min-width: v.$desktop-min-width) { + @content; + } +} + +@mixin on-large-desktop { + @media (min-width: v.$large-desktop-min-width) { + @content; + } +} + +@mixin content-padding-inline() { + padding-inline: 16px; + + @include on-tablet { + padding-inline: 24px; + } + + @include on-desktop { + padding-inline: 32px; + } + + @include on-large-desktop { + padding-inline: 152px; + } +} + +@mixin page-container { + max-width: 1400px; + margin: 0 auto; + box-sizing: border-box; + + @include content-padding-inline; +} + +@mixin hover($property, $toValue) { + transition: #{$property} v.$effect-duration; + + &:hover { + #{$property}: $toValue; + } +} + +@mixin page-grid { + & { + --columns: 4; + + display: grid; + gap: 40px 16px; + grid-template-columns: repeat(var(--columns), 1fr); + } + + @include on-tablet { + --columns: 12; + } + + @include on-desktop { + --columns: 24; + } +} diff --git a/src/styles/themes.scss b/src/styles/themes.scss new file mode 100644 index 00000000000..2284e1eaaa2 --- /dev/null +++ b/src/styles/themes.scss @@ -0,0 +1,60 @@ +@use './vars.scss' as v; + +html[data-theme='dark'] { + --theme-background: #0F1121; + --border-color: #323542; + --text-main-color: #F1F2F9; + --text-color-secondary: #75767F; + --icons-hover: #4A4D58; + --button-main-color: #905BFF; + --button-main-color-hover: #A378FF; + --button-text-color: #F1F2F9; + --bg-card-color: #161827; + --border-card: #161827; + --card-btn-border-radius: 4px; + --hover-text-color: #{v.$white}; + --added-text-color: #{v.$white}; + --pagination-bg: var(--bg-card-color); + --pagination-border-color: var(--bg-card-color); + --pagination-hover-bg: var(--icons-hover); + --pagination-hover-border-color: var(--icons-hover); + --pagination-selected-bg: var(--button-main-color); + --pagination-selected-border-color: var(--button-main-color); + --pagination-selected-text-color: var(--button-text-color); + --pagination-disabled-bg: transparent; + --pagination-disabled-border-color: var(--border-color); + --pagination-disabled-text-color: var(--icons-hover); + --added-button: var(--border-color); + --fav-btn-bg: var(--border-color); + --fav-btn-bg-active: var(--bg-card-color); + --fav-btn-border: var(--border-color); +} + +html[data-theme="light"] { + --theme-background: #{v.$white}; + --border-color: #E2E6E9; + --text-main-color: #313237; + --text-color-secondary: #89939A; + --icons-hover: #B4BDC3; + --button-main-color: #313237; + --button-main-color-hover: #17203166; + --button-text-color:#F1F2F9; + --border-card: #E2E6E9; + --card-btn-border-radius: 8px; + --added-text-color: #27AE60; + --hover-text-color: var(--theme-background); + --pagination-bg: var(--theme-background); + --pagination-border-color: var(--border-color); + --pagination-hover-bg: var(--theme-background); + --pagination-hover-border-color: var(--text-main-color); + --pagination-selected-bg: var(--button-main-color); + --pagination-selected-border-color: var(--button-main-color); + --pagination-selected-text-color: var(--button-text-color); + --pagination-disabled-bg: var(--theme-background); + --pagination-disabled-border-color: var(--border-color); + --pagination-disabled-text-color: var(--icons-hover); + --added-button: var(--theme-background); + --fav-btn-bg: var(--theme-background); + --fav-btn-bg-active: var(--theme-background); + --fav-btn-border: var(--icons-hover); +} diff --git a/src/styles/typography.scss b/src/styles/typography.scss new file mode 100644 index 00000000000..f3704aa95d2 --- /dev/null +++ b/src/styles/typography.scss @@ -0,0 +1,68 @@ +@use './vars' as v; +@use './mixins' as m; + +h1 { + margin: 0; + font-size: 32px; + font-weight: 800; + line-height: 41px; + letter-spacing: 0; + + @include m.on-tablet { + font-size: 48px; + line-height: 56px; + } +} + +h2 { + margin: 0; + font-size: 22px; + font-weight: 700; + line-height: 31px; + letter-spacing: 0; + + @include m.on-tablet { + font-size: 32px; + line-height: 41px; + } +} + +h3 { + margin: 0; + font-size: 20px; + font-weight: 700; + line-height: 26px; + letter-spacing: 0; + + @include m.on-tablet { + font-size: 22px; + line-height: 31px; + } +} + +h4 { + margin: 0; + font-size: 16px; + font-weight: 700; + line-height: 20px; + letter-spacing: 0; + + @include m.on-tablet { + font-size: 20px; + line-height: 26px; + } +} + +p { + margin: 0; + font-size: 14px; + line-height: 21px; + letter-spacing: 0; +} + +button { + font-family: v.$font-family; + line-height: 21px; + height: 40px; + cursor: pointer; +} diff --git a/src/styles/vars.scss b/src/styles/vars.scss new file mode 100644 index 00000000000..0cafac7113f --- /dev/null +++ b/src/styles/vars.scss @@ -0,0 +1,14 @@ + +$white: #fff; +$black: #000; +$red: #EB5757; +$font-family: Mont, sans-serif; +$mobile-min-width: 320px; +$tablet-min-width: 640px; +$laptop-min-width: 900px; +$desktop-min-width: 1200px; +$large-desktop-min-width: 1400px; +$effect-duration: 0.3s; +$banner-second-color: #AEAEAE; +$banner-button-color: #444; +$banner-back-color: #222; diff --git a/src/types/accessory.ts b/src/types/accessory.ts new file mode 100644 index 00000000000..857c94c8057 --- /dev/null +++ b/src/types/accessory.ts @@ -0,0 +1,5 @@ +import type { ProductDetails } from './product'; + +export interface Accessory extends ProductDetails { + category: 'accessories'; +} diff --git a/src/types/phone.ts b/src/types/phone.ts new file mode 100644 index 00000000000..41d03ed5abe --- /dev/null +++ b/src/types/phone.ts @@ -0,0 +1,7 @@ +import type { ProductDetails } from './product'; + +export interface Phone extends ProductDetails { + category: 'phones'; + camera: string; + zoom: string; +} diff --git a/src/types/product.ts b/src/types/product.ts new file mode 100644 index 00000000000..cc4815b5009 --- /dev/null +++ b/src/types/product.ts @@ -0,0 +1,47 @@ +export interface Product { + id: number; + category: string; + itemId: string; + name: string; + fullPrice?: number; + price: number; + screen: string; + capacity: string; + color: string; + ram: string; + year: number; + image: string; +} + +export interface ProductDescription { + title?: string; + text: string[]; +} + +export interface ProductDetails { + id: string; + category: string; + namespaceId: string; + name: string; + capacityAvailable: string[]; + capacity: string; + priceRegular: number; + priceDiscount: number; + colorsAvailable: string[]; + color: string; + images: string[]; + description: ProductDescription[]; + screen: string; + resolution: string; + processor: string; + ram: string; + camera?: string; + zoom?: string; + cell: string[]; +} + +type ProductDetailsWithoutId = Partial>; + +export interface ProductWithDetails extends Product, ProductDetailsWithoutId { + description?: ProductDetails['description'] | null; +} diff --git a/src/types/tablet.ts b/src/types/tablet.ts new file mode 100644 index 00000000000..065a61e991d --- /dev/null +++ b/src/types/tablet.ts @@ -0,0 +1,7 @@ +import type { ProductDetails } from './product'; + +export interface Tablet extends ProductDetails { + category: 'tablets'; + camera: string; + zoom: string; +} diff --git a/src/utils/debounce.ts b/src/utils/debounce.ts new file mode 100644 index 00000000000..0991ca4b0dd --- /dev/null +++ b/src/utils/debounce.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from 'react'; + +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/src/utils/productSearch.ts b/src/utils/productSearch.ts new file mode 100644 index 00000000000..b34c240e12f --- /dev/null +++ b/src/utils/productSearch.ts @@ -0,0 +1,35 @@ +import type { Product } from '../types/product'; + +const normalizeSearchText = (value: string) => + value + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim(); + +const getSearchableText = (product: Product) => + [ + product.name, + product.color, + product.capacity, + product.ram, + product.screen, + product.itemId, + product.category, + product.year, + ] + .filter(value => value !== undefined && value !== null) + .map(String) + .map(normalizeSearchText) + .join(' '); + +export const productMatchesQuery = (product: Product, query: string) => { + const tokens = normalizeSearchText(query).split(/\s+/).filter(Boolean); + + if (tokens.length === 0) { + return true; + } + + const searchableText = getSearchableText(product); + + return tokens.every(token => searchableText.includes(token)); +}; diff --git a/src/utils/publicPath.ts b/src/utils/publicPath.ts new file mode 100644 index 00000000000..49ffd1400ab --- /dev/null +++ b/src/utils/publicPath.ts @@ -0,0 +1,4 @@ +const BASE_URL = import.meta.env.BASE_URL; + +export const getPublicUrl = (url: string) => + `${BASE_URL}${url.replace(/^\/+/, '')}`; diff --git a/src/utils/scrollToTop.ts b/src/utils/scrollToTop.ts new file mode 100644 index 00000000000..9552e6d794f --- /dev/null +++ b/src/utils/scrollToTop.ts @@ -0,0 +1,5 @@ +export function scrollToTop(): void { + if (typeof window !== 'undefined' && window.scrollTo) { + window.scrollTo({ top: 0, behavior: 'smooth' }); + } +} diff --git a/tsconfig.json b/tsconfig.json index cfb168bb26c..2e7267f7a81 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ ], "compilerOptions": { "sourceMap": false, - "types": ["node", "cypress"] + "types": ["node", "cypress"], + "ignoreDeprecations": "6.0", } } diff --git a/vite.config.ts b/vite.config.ts index 5a33944a9b4..123cfc3dbbd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,8 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; // https://vitejs.dev/config/ export default defineConfig({ + base: '/react_phone-catalog/', plugins: [react()], -}) +});