diff --git a/index.html b/index.html index 095fb3a4537..18a2c41a955 100644 --- a/index.html +++ b/index.html @@ -2,8 +2,12 @@ + + + - Vite + React + TS + + Nice Gadgets
diff --git a/package-lock.json b/package-lock.json index 836b9e63b46..708c4a3823a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,12 +15,12 @@ "classnames": "^2.5.1", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.25.1", + "react-router-dom": "^6.30.3", "react-transition-group": "^4.4.5" }, "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", @@ -44,7 +44,7 @@ "mochawesome-merge": "^4.3.0", "mochawesome-report-generator": "^6.2.0", "prettier": "^3.3.2", - "sass": "^1.77.8", + "sass": "^1.99.0", "stylelint": "^16.7.0", "typescript": "^5.2.2", "vite": "^5.3.1" @@ -1184,10 +1184,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", @@ -1863,6 +1864,348 @@ "@octokit/openapi-types": "^22.2.0" } }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@pkgr/core": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz", @@ -1876,9 +2219,10 @@ } }, "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.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", "engines": { "node": ">=14.0.0" } @@ -2581,6 +2925,7 @@ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", "dev": true, + "peer": true, "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -2963,6 +3308,7 @@ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", "dev": true, + "peer": true, "engines": { "node": ">=8" }, @@ -3216,6 +3562,7 @@ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "dev": true, + "peer": true, "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -3240,6 +3587,7 @@ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", "dev": true, + "peer": true, "dependencies": { "is-glob": "^4.0.1" }, @@ -3904,6 +4252,17 @@ "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==", "dev": true }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/diff": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", @@ -5937,10 +6296,11 @@ } }, "node_modules/immutable": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.6.tgz", - "integrity": "sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ==", - "dev": true + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", + "dev": true, + "license": "MIT" }, "node_modules/import-fresh": { "version": "3.3.0", @@ -6096,6 +6456,7 @@ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", "dev": true, + "peer": true, "dependencies": { "binary-extensions": "^2.0.0" }, @@ -7876,6 +8237,14 @@ "path-to-regexp": "^1.7.0" } }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -8744,11 +9113,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.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", "dependencies": { - "@remix-run/router": "1.18.0" + "@remix-run/router": "1.23.2" }, "engines": { "node": ">=14.0.0" @@ -8758,12 +9128,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.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", "dependencies": { - "@remix-run/router": "1.18.0", - "react-router": "6.25.1" + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" }, "engines": { "node": ">=14.0.0" @@ -8856,6 +9227,7 @@ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", "dev": true, + "peer": true, "dependencies": { "picomatch": "^2.2.1" }, @@ -9219,13 +9591,14 @@ "dev": true }, "node_modules/sass": { - "version": "1.77.8", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.77.8.tgz", - "integrity": "sha512-4UHg6prsrycW20fqLGPShtEvo/WyHRVRHwOP4DzkUrObWoWI05QBSfzU71TVB7PFaL104TwNaHpjlWXAZbQiNQ==", + "version": "1.99.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.99.0.tgz", + "integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==", "dev": true, + "license": "MIT", "dependencies": { - "chokidar": ">=3.0.0 <4.0.0", - "immutable": "^4.0.0", + "chokidar": "^4.0.0", + "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, "bin": { @@ -9233,6 +9606,39 @@ }, "engines": { "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/sass/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/sass/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, "node_modules/scheduler": { diff --git a/package.json b/package.json index ae251685c8b..38ef8f1d743 100644 --- a/package.json +++ b/package.json @@ -11,12 +11,12 @@ "classnames": "^2.5.1", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.25.1", + "react-router-dom": "^6.30.3", "react-transition-group": "^4.4.5" }, "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", @@ -40,7 +40,7 @@ "mochawesome-merge": "^4.3.0", "mochawesome-report-generator": "^6.2.0", "prettier": "^3.3.2", - "sass": "^1.77.8", + "sass": "^1.99.0", "stylelint": "^16.7.0", "typescript": "^5.2.2", "vite": "^5.3.1" diff --git a/public/img/banner-iphone-mobile.svg b/public/img/banner-iphone-mobile.svg new file mode 100644 index 00000000000..9d893c123d4 --- /dev/null +++ b/public/img/banner-iphone-mobile.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/img/banner-iphone.svg b/public/img/banner-iphone.svg new file mode 100644 index 00000000000..934b1408ecf --- /dev/null +++ b/public/img/banner-iphone.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/img/category-phones.svg b/public/img/category-phones.svg new file mode 100644 index 00000000000..f9072efcf8c --- /dev/null +++ b/public/img/category-phones.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/img/icons/arrow-left.svg b/public/img/icons/arrow-left.svg new file mode 100644 index 00000000000..98d0607f549 --- /dev/null +++ b/public/img/icons/arrow-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/arrow-right.svg b/public/img/icons/arrow-right.svg new file mode 100644 index 00000000000..4ecd8405960 --- /dev/null +++ b/public/img/icons/arrow-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/arrow-up.svg b/public/img/icons/arrow-up.svg new file mode 100644 index 00000000000..d5a3d0e658e --- /dev/null +++ b/public/img/icons/arrow-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/cart.svg b/public/img/icons/cart.svg new file mode 100644 index 00000000000..425ee639766 --- /dev/null +++ b/public/img/icons/cart.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/img/icons/close.svg b/public/img/icons/close.svg new file mode 100644 index 00000000000..882d79d3821 --- /dev/null +++ b/public/img/icons/close.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/heart-filled.svg b/public/img/icons/heart-filled.svg new file mode 100644 index 00000000000..0a33fa8d98e --- /dev/null +++ b/public/img/icons/heart-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/heart.svg b/public/img/icons/heart.svg new file mode 100644 index 00000000000..59e11f2671a --- /dev/null +++ b/public/img/icons/heart.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/home.svg b/public/img/icons/home.svg new file mode 100644 index 00000000000..e16ca7d7943 --- /dev/null +++ b/public/img/icons/home.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/img/icons/logo.svg b/public/img/icons/logo.svg new file mode 100644 index 00000000000..8d87e4c1070 --- /dev/null +++ b/public/img/icons/logo.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/img/icons/menu.svg b/public/img/icons/menu.svg new file mode 100644 index 00000000000..c8c52c08a95 --- /dev/null +++ b/public/img/icons/menu.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/img/logo.png b/public/img/logo.png new file mode 100644 index 00000000000..87265ef2aae Binary files /dev/null and b/public/img/logo.png differ diff --git a/src/App.scss b/src/App.scss index 71bc413aade..f5cf1abc318 100644 --- a/src/App.scss +++ b/src/App.scss @@ -1 +1,71 @@ -// not empty +@import 'https://fonts.cdnfonts.com/css/mont'; + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +a { + color: inherit; + text-decoration: none; + + &:hover { + text-decoration: none; + } +} + +.container { + width: 100%; + max-width: none; + margin: 0 auto; + + padding-left: 16px; + padding-right: 16px; + + @media screen and (min-width: 640px) { + padding-left: 24px; + padding-right: 24px; + } + + @media screen and (min-width: 1200px) { + padding-left: 32px; + padding-right: 32px; + } + + @media screen and (min-width: 1440px) { + padding-left: 152px; + padding-right: 152px; + } +} + +html, +body { + width: 100%; + height: 100%; + background-color: #0f1117; + color: #f1f2f9; + font-family: Mont, sans-serif; +} + +body.is-menu-open { + overflow: hidden; + height: 100vh; +} + +/* stylelint-disable-next-line selector-max-id */ +#root { + height: 100%; +} + +.app { + display: flex; + flex-direction: column; + min-height: 100vh; + + &__main { + display: flex; + flex: 1 0 auto; + flex-direction: column; + } +} diff --git a/src/App.tsx b/src/App.tsx index 372e4b42066..d725bb197df 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,91 @@ +/* eslint-disable max-len */ +import React, { useEffect, useState } from 'react'; +import { Routes, Route, Navigate } from 'react-router-dom'; +import { Product } from './types/Product'; +import { ProductDetails } from './types/ProductDetails'; +import * as api from './api/api'; + +import { Header } from './components/Header/Header'; +import { Footer } from './components/Footer/Footer'; +import { HomePage } from './components/pages/HomePage/HomePage'; +import { PhonesPage } from './components/pages/PhonesPage/PhonesPage'; +import { ProductDetailsPage } from './components/pages/ProductDetailsPage/ProductDetailsPage'; +import { CartPage } from './components/pages/CartPage/CartPage'; +import { FavoritesPage } from './components/pages/FavoritesPage/FavoritesPage'; +import { ScrollToTop } from './utils/ScrollToTop'; + import './App.scss'; -export const App = () => ( -
-

Product Catalog

-
-); +export const App: React.FC = () => { + const [allProducts, setAllProducts] = useState([]); + const [phones, setPhones] = useState([]); + const [tablets, setTablets] = useState([]); + const [accessories, setAccessories] = useState([]); + const [details, setDetails] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + Promise.all([api.getProducts(), api.getProductsDetails()]) + .then(([productsData, detailsData]) => { + setAllProducts(productsData); + setPhones(productsData.filter(item => item.category === 'phones')); + setTablets(productsData.filter(item => item.category === 'tablets')); + setAccessories( + productsData.filter(item => item.category === 'accessories'), + ); + setDetails(detailsData); + }) + .catch(() => {}) + .finally(() => { + setLoading(false); + }); + }, []); + + if (loading) { + return
Loading...
; + } + + return ( +
+ + +
+ +
+ + } /> + } /> + + } + /> + + } + /> + + } + /> + + + } + /> + + } /> + } /> + + Page not found} /> + +
+ +
+
+ ); +}; diff --git a/src/api/api.ts b/src/api/api.ts new file mode 100644 index 00000000000..8a67df56020 --- /dev/null +++ b/src/api/api.ts @@ -0,0 +1,36 @@ +/* eslint-disable max-len */ +import { Product } from '../types/Product'; +import { ProductDetails } from '../types/ProductDetails'; + +const BASE_URL = + window.location.hostname === 'localhost' ? '' : '/react_phone-catalog'; + +const fetchData = async (path: string): Promise => { + const url = `${BASE_URL}${path}`.replace(/\/+/g, '/'); + + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Failed to fetch ${url}: ${response.statusText}`); + } + + return response.json(); +}; + +export const getProducts = (): Promise => { + return fetchData('/api/products.json'); +}; + +export const getProductsDetails = async (): Promise => { + try { + const [phones, tablets, accessories] = await Promise.all([ + fetchData('/api/phones.json'), + fetchData('/api/tablets.json'), + fetchData('/api/accessories.json'), + ]); + + return [...phones, ...tablets, ...accessories]; + } catch (error) { + return []; + } +}; diff --git a/src/components/Banner/Banner.scss b/src/components/Banner/Banner.scss new file mode 100644 index 00000000000..c709eeb6989 --- /dev/null +++ b/src/components/Banner/Banner.scss @@ -0,0 +1,148 @@ +.banner-section { + padding-top: 32px; + max-width: 1136px; + margin: 0 auto; + padding-left: 16px; + padding-right: 16px; + + @media (min-width: 640px) { + padding-top: 56px; + padding-left: 0; + padding-right: 0; + } + + &__title { + font-family: Mont, sans-serif; + font-weight: 800; + font-size: 32px; + line-height: 41px; + letter-spacing: -0.01em; + color: #fff; + margin-top: 0; + margin-bottom: 24px; + text-align: left; + + @media (min-width: 640px) { + font-size: 48px; + line-height: 56px; + margin-bottom: 32px; + } + } +} + +.banner { + &__main { + display: grid; + grid-template-columns: 1fr; + grid-column-gap: 0; + height: 400px; + width: 100%; + position: relative; + overflow: hidden; + + @media (min-width: 640px) { + grid-template-columns: 32px 1fr 32px; + grid-column-gap: 16px; + } + } + + &__viewer { + grid-column: 1; + + @media (min-width: 640px) { + grid-column: 2; + } + + overflow: hidden; + height: 100%; + } + + &__list { + display: flex; + height: 100%; + transition: transform 0.5s ease-in-out; + } + + &__slide { + min-width: 100%; + height: 100%; + } + + &__img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + border-radius: 8px; + } + + &__btn { + display: none; + + @media (min-width: 640px) { + display: flex; + border: none; + padding: 0; + margin: 0; + height: 100%; + width: 32px; + background-color: #323542; + cursor: pointer; + align-items: center; + justify-content: center; + z-index: 10; + transition: background-color 0.3s; + + &:hover { + background-color: #4a4d58; + } + } + + &--prev { + grid-column: 1; + &::after { + content: ''; + width: 8px; + height: 8px; + border-left: 2px solid #fff; + border-bottom: 2px solid #fff; + transform: rotate(45deg); + margin-left: 4px; + } + } + + &--next { + grid-column: 3; + &::after { + content: ''; + width: 8px; + height: 8px; + border-right: 2px solid #fff; + border-top: 2px solid #fff; + transform: rotate(45deg); + margin-right: 4px; + } + } + } + + &__dots { + display: flex; + justify-content: center; + gap: 12px; + margin-top: 16px; + } + + &__dot { + border: none; + padding: 0; + width: 14px; + height: 4px; + background-color: #323542; + cursor: pointer; + transition: background-color 0.3s; + + &.is-active { + background-color: #f1f2f9; + } + } +} diff --git a/src/components/Banner/Banner.tsx b/src/components/Banner/Banner.tsx new file mode 100644 index 00000000000..ea101d2ba99 --- /dev/null +++ b/src/components/Banner/Banner.tsx @@ -0,0 +1,95 @@ +/* eslint-disable max-len */ +import React, { useState, useEffect } from 'react'; +import './Banner.scss'; + +const BASE = import.meta.env.BASE_URL.endsWith('/') + ? import.meta.env.BASE_URL + : `${import.meta.env.BASE_URL}/`; + +const images = [ + `${BASE}img/banner-iphone.svg`, + `${BASE}img/banner-tablets.png`, + `${BASE}img/banner-accessories.png`, +]; + +const firstBannerMobile = `${BASE}img/banner-iphone-mobile.svg`; + +export const Banner: React.FC = () => { + const [index, setIndex] = useState(0); + + useEffect(() => { + const interval = setInterval(() => { + setIndex(prev => (prev + 1) % images.length); + }, 5000); + + return () => clearInterval(interval); + }, []); + + const nextSlide = () => { + setIndex(prev => (prev + 1) % images.length); + }; + + const prevSlide = () => { + setIndex(prev => (prev - 1 + images.length) % images.length); + }; + + return ( +
+
+ + +
+
+ {images.map((img, i) => ( +
+ {i === 0 ? ( + + + Promo + + ) : ( + Promo + )} +
+ ))} +
+
+ + +
+ +
+ {images.map((_, i) => ( +
+
+ ); +}; diff --git a/src/components/Breadcrumbs/Breadcrumbs.scss b/src/components/Breadcrumbs/Breadcrumbs.scss new file mode 100644 index 00000000000..7681aa8908f --- /dev/null +++ b/src/components/Breadcrumbs/Breadcrumbs.scss @@ -0,0 +1,58 @@ +.breadcrumbs { + display: flex; + align-items: center; + gap: 8px; + margin-top: 24px; + margin-bottom: 24px; + + &__home { + width: 16px; + height: 16px; + display: block; + background: url('/img/icons/home.svg') no-repeat center; + background-size: contain; + filter: brightness(0) invert(1); + transition: opacity 0.3s; + + &:hover { + opacity: 0.7; + } + } + + &__separator { + width: 16px; + height: 16px; + background: url('/img/icons/arrow-right.svg') no-repeat center; + background-size: contain; + filter: brightness(0) invert(0.6); + opacity: 1; + } + + &__link { + text-decoration: none; + color: #fff !important; + font-weight: 700; + font-size: 12px; + transition: opacity 0.3s; + + &:visited { + color: #fff; + } + + &:hover { + opacity: 0.7; + } + } + + &__current { + font-weight: 700; + font-size: 12px; + color: #89939a; + cursor: default; + + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 300px; + } +} diff --git a/src/components/Breadcrumbs/Breadcrumbs.tsx b/src/components/Breadcrumbs/Breadcrumbs.tsx new file mode 100644 index 00000000000..2004e105752 --- /dev/null +++ b/src/components/Breadcrumbs/Breadcrumbs.tsx @@ -0,0 +1,36 @@ +/* eslint-disable max-len */ +import './Breadcrumbs.scss'; +import React from 'react'; +import { Link, useLocation } from 'react-router-dom'; + +export const Breadcrumbs: React.FC = () => { + const { pathname } = useLocation(); + const pathnames = pathname.split('/').filter(x => x); + + return ( + + ); +}; diff --git a/src/components/CartItem/CartItem.scss b/src/components/CartItem/CartItem.scss new file mode 100644 index 00000000000..5d9fac02966 --- /dev/null +++ b/src/components/CartItem/CartItem.scss @@ -0,0 +1,110 @@ +.cart-item { + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px; + border: 1px solid #3b3e44; + background: #161827; + + @media (min-width: 640px) { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + padding: 24px; + } + + &__info { + display: flex; + align-items: center; + gap: 16px; + } + + &__remove { + border: none; + background: none; + color: #89939a; + font-size: 24px; + cursor: pointer; + padding: 0; + line-height: 1; + + &:hover { + color: #fff; + } + } + + &__image { + width: 64px; + height: 64px; + object-fit: contain; + } + + &__name { + font-size: 14px; + line-height: 21px; + color: #fff; + max-width: 180px; + } + + &__actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + + @media (min-width: 640px) { + justify-content: flex-end; + } + } + + &__quantity { + display: flex; + align-items: center; + gap: 12px; + + &-btn { + width: 32px; + height: 32px; + border: 1px solid #3b3e44; + border-radius: 0; + background: #323542; + color: #fff; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + transition: all 0.3s ease; + + // Исправленный порядок: сначала специфичное состояние :disabled + &:disabled { + background: #161827; + border-color: #21232e; + color: #4a4d58; + opacity: 0.6; + cursor: default; + } + + &:hover:not(:disabled) { + border-color: #fff; + background: #4a4d58; + } + } + + &-val { + min-width: 20px; + text-align: center; + font-weight: 600; + color: #fff; + } + } + + &__price { + font-size: 16px; + font-weight: 700; + color: #fff; + min-width: 80px; + text-align: right; + } +} diff --git a/src/components/CartItem/CartItem.tsx b/src/components/CartItem/CartItem.tsx new file mode 100644 index 00000000000..9760eb40a33 --- /dev/null +++ b/src/components/CartItem/CartItem.tsx @@ -0,0 +1,56 @@ +import './CartItem.scss'; +import React from 'react'; +import { useCart, CartItem as CartItemType } from '../../context/CartContext'; + +interface Props { + item: CartItemType; +} + +export const CartItem: React.FC = ({ item }) => { + const { removeFromCart, changeQuantity } = useCart(); + + const imagePath = item.image.startsWith('/') + ? item.image.slice(1) + : item.image; + + return ( +
+
+ + + {item.name} + +

{item.name}

+
+ +
+
+ + {item.quantity} + +
+ +

{`$${item.price * item.quantity}`}

+
+
+ ); +}; 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/Categories/Categories.scss b/src/components/Categories/Categories.scss new file mode 100644 index 00000000000..a8258f34cc6 --- /dev/null +++ b/src/components/Categories/Categories.scss @@ -0,0 +1,97 @@ +.categories { + margin-top: 56px; + + @media (min-width: 640px) { + margin-top: 80px; + } + + &__title { + font-family: Mont, sans-serif; + font-weight: 800; + font-size: 24px; + margin-bottom: 24px; + color: #fff; + + @media (min-width: 640px) { + font-size: 32px; + } + } + + &__list { + display: grid; + grid-template-columns: 1fr; + gap: 32px; + + @media (min-width: 640px) { + grid-template-columns: repeat(3, 1fr); + gap: 24px; + } + } + + &__img-container { + position: relative; + width: 100%; + aspect-ratio: 1 / 1; + overflow: hidden; + margin-bottom: 12px; + display: flex; + justify-content: center; + align-items: center; + border-radius: 0; + + &--phones { background-color: #6D6474; } + &--tablets { background-color: #8d8d92; } + &--accessories { background-color: #973451; } + + @media (min-width: 640px) { + margin-bottom: 24px; + } + } + + &__img { + display: block; + width: 100% !important; + height: auto; + max-width: none !important; + object-fit: contain; + transition: transform 0.3s ease-in-out; + transform-origin: center; + } + + &__img-container--phones &__img { + transform: translate(5%, 10%) scale(0.9); + } + + &__img-container--tablets &__img { + transform: translate(30%, 28%) scale(1.3); + } + + &__img-container--accessories &__img { + transform: translate(50%, 30%) scale(1.8); + } + + &__link { + text-decoration: none; + display: block; + + &:hover { + .categories__img { + filter: brightness(1.1); + } + } + } + + &__name { + font-family: Mont, sans-serif; + font-size: 20px; + font-weight: 700; + color: #fff; + margin-bottom: 4px; + } + + &__count { + font-family: Mont, sans-serif; + font-size: 14px; + color: #89939a; + } +} diff --git a/src/components/Categories/Categories.tsx b/src/components/Categories/Categories.tsx new file mode 100644 index 00000000000..7ade70d382d --- /dev/null +++ b/src/components/Categories/Categories.tsx @@ -0,0 +1,70 @@ +/* eslint-disable max-len */ +import './Categories.scss'; +import React from 'react'; +import { Link } from 'react-router-dom'; +import { Product } from '../../types/Product'; + +interface Props { + products: Product[]; +} + +const BASE = import.meta.env.BASE_URL.endsWith('/') + ? import.meta.env.BASE_URL + : `${import.meta.env.BASE_URL}/`; + +export const Categories: React.FC = ({ products }) => { + const getCount = (cat: string) => + products.filter((p: Product) => p.category === cat).length; + + const baseClass = 'categories__img-container'; + + return ( +
+

Shop by category

+ +
+
+ +
+ Phones +
+

Mobile phones

+

{`${getCount('phones')} models`}

+ +
+ +
+ +
+ Tablets +
+

Tablets

+

{`${getCount('tablets')} models`}

+ +
+ +
+ +
+ Accessories +
+

Accessories

+

{`${getCount('accessories')} models`}

+ +
+
+
+ ); +}; diff --git a/src/components/Dropdown/Dropdown.scss b/src/components/Dropdown/Dropdown.scss new file mode 100644 index 00000000000..08d7e2702aa --- /dev/null +++ b/src/components/Dropdown/Dropdown.scss @@ -0,0 +1,65 @@ +.dropdown { + position: relative; + display: flex; + flex-direction: column; + gap: 4px; + width: 100%; + + &__label { + font-size: 12px; + font-weight: 700; + color: #89939a; + } + + &__button { + height: 40px; + padding: 0 12px; + display: flex; + justify-content: space-between; + align-items: center; + border: 1px solid #b4bdc3; + background: #fff; + cursor: pointer; + font-size: 14px; + color: #313237; + + &:hover { + border-color: #313237; + } + } + + &__arrow { + width: 16px; + height: 16px; + background: url('../../assets/icons/arrow-down.svg') no-repeat center; + transition: transform 0.3s; + + &.is-active { + transform: rotate(180deg); + } + } + + &__list { + position: absolute; + top: 64px; + left: 0; + right: 0; + background: #fff; + border: 1px solid #e2e6e9; + box-shadow: 0 2px 15px rgba(0, 0, 0, 0.1); + z-index: 10; + margin: 0; + padding: 0; + list-style: none; + } + + &__item { + padding: 10px 12px; + cursor: pointer; + font-size: 14px; + + &:hover { + background: #f1f2f3; + } + } +} diff --git a/src/components/Dropdown/Dropdown.tsx b/src/components/Dropdown/Dropdown.tsx new file mode 100644 index 00000000000..25c6f9d5208 --- /dev/null +++ b/src/components/Dropdown/Dropdown.tsx @@ -0,0 +1,74 @@ +import './Dropdown.scss'; +import React, { useState, useRef, useEffect } from 'react'; +import { useSearchParams } from 'react-router-dom'; + +interface Option { + value: string; + label: string; +} + +interface Props { + label: string; + options: Option[]; + paramName: string; +} + +export const Dropdown: React.FC = ({ label, options, paramName }) => { + const [searchParams, setSearchParams] = useSearchParams(); + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + const selectedValue = searchParams.get(paramName) || options[0].value; + const selectedOption = options.find(opt => opt.value === selectedValue); + + const handleSelect = (value: string) => { + const params = new URLSearchParams(searchParams); + + params.set(paramName, value); + setSearchParams(params); + setIsOpen(false); + }; + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + return ( +
+ {label} + + + {isOpen && ( +
    + {options.map(option => ( +
  • handleSelect(option.value)} + > + {option.label} +
  • + ))} +
+ )} +
+ ); +}; diff --git a/src/components/Dropdown/index.ts b/src/components/Dropdown/index.ts new file mode 100644 index 00000000000..2f29bad4e67 --- /dev/null +++ b/src/components/Dropdown/index.ts @@ -0,0 +1 @@ +export * from './Dropdown'; diff --git a/src/components/Footer/Footer.scss b/src/components/Footer/Footer.scss new file mode 100644 index 00000000000..cceabc02ea5 --- /dev/null +++ b/src/components/Footer/Footer.scss @@ -0,0 +1,147 @@ +.footer { + width: 100%; + background-color: #0f1117; + border-top: 1px solid #323542; + flex-shrink: 0; + padding: 32px 0; + margin-top: 56px; + + @media (min-width: 640px) { + height: 96px; + margin-top: 64px; + padding: 0; + display: flex; + align-items: center; + } + + .container { + width: 100%; + margin: 0 auto; + padding: 0 16px; + + @media (min-width: 640px) { + padding: 0 24px; + } + + @media (min-width: 1200px) { + max-width: 1136px; + padding: 0; + } + } + + &__content { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 32px; + + @media (min-width: 640px) { + flex-direction: row; + justify-content: space-between; + align-items: center; + gap: 0; + width: 100%; + } + } + + @media (max-width: 639px) { + &__logo, + &__list { + display: flex; + justify-content: flex-start; + width: 100%; + } + + &__back-to-top { + justify-content: center; + width: 100%; + margin-top: 16px; + } + } + + &__logo { + display: flex; + align-items: center; + height: 28px; + flex-shrink: 0; + + &-img { + height: 100%; + width: auto; + } + } + + &__list { + display: flex; + flex-direction: column; + gap: 16px; + list-style: none; + margin: 0; + padding: 0; + flex-shrink: 0; + + @media (min-width: 640px) { + flex-direction: row; + gap: 32px; + } + + @media (min-width: 1200px) { + gap: 106px; + } + } + + &__link { + font-size: 12px; + font-weight: 800; + text-transform: uppercase; + text-decoration: none; + color: #fff; + transition: opacity 0.3s; + + &:hover { + opacity: 0.7; + } + } + + &__back-to-top { + display: flex; + align-items: center; + gap: 16px; + flex-shrink: 0; + + @media (min-width: 640px) { + justify-content: flex-end; + } + } + + &__back-text { + font-size: 12px; + font-weight: 700; + color: #89939a; + flex-shrink: 0; + } + + &__back-button { + width: 32px; + height: 32px; + background-color: #323542; + border: none; + cursor: pointer; + flex-shrink: 0; + display: flex; + justify-content: center; + align-items: center; + transition: background-color 0.3s; + + &:hover { + background-color: #4a4d58; + } + + .footer__back-icon { + width: 16px; + height: 16px; + display: block; + filter: brightness(0) invert(1); + } + } +} diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 00000000000..619502c8a25 --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,71 @@ +/* eslint-disable max-len */ +import './Footer.scss'; +import React from 'react'; +import { Link } from 'react-router-dom'; + +export const Footer: React.FC = () => { + const scrollToTop = () => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + return ( + + ); +}; diff --git a/src/components/Header/Header.scss b/src/components/Header/Header.scss new file mode 100644 index 00000000000..3c6388d1885 --- /dev/null +++ b/src/components/Header/Header.scss @@ -0,0 +1,293 @@ +$white: #f1f2f9; +$secondary: #89939a; +$accent: #313237; +$bg-dark: #0f1117; +$border-color: #323542; + +.header { + position: fixed; + top: 0; + left: 0; + z-index: 1001; + display: flex; + width: 100%; + height: 48px; + align-items: center; + justify-content: space-between; + background-color: $bg-dark; + border-bottom: 1px solid $border-color; + + @media screen and (min-width: 640px) { + position: static; + } + + @media screen and (min-width: 1200px) { + height: 64px; + } + + &__left { + display: flex; + height: 100%; + align-items: center; + flex-grow: 1; + } + + &__logo { + display: flex; + height: 100%; + padding: 0 16px; + align-items: center; + + @media screen and (min-width: 640px) { + padding: 0 24px; + } + + &-img { + height: 28px; + } + } + + &__right { + display: flex; + height: 100%; + } + + &__desktop-icons { + display: none; + height: 100%; + + @media screen and (min-width: 640px) { + display: flex !important; + } + } + + &__icon { + width: 20px; + height: 20px; + } + + &__icon-link { + position: relative; + display: flex; + width: 64px; + height: 100%; + align-items: center; + justify-content: center; + border-left: 1px solid $border-color; + text-decoration: none; + transition: background-color 0.3s; + + &:hover { + background-color: $accent; + } + + &.is-active::after { + position: absolute; + bottom: -1px; + left: 0; + right: 0; + height: 3px; + content: ''; + background-color: $white; + } + + &.is-active .header__icon { + filter: brightness(0) invert(1); + } + } + + &__burger { + z-index: 1010; + display: flex; + width: 48px; + height: 100%; + align-items: center; + justify-content: center; + background: none; + border: none; + border-left: 1px solid $border-color; + cursor: pointer; + + @media screen and (min-width: 640px) { + display: none !important; + } + } + + &__count { + position: absolute; + top: 10px; + right: 10px; + display: flex; + width: 14px; + height: 14px; + align-items: center; + justify-content: center; + border: 2px solid $bg-dark; + border-radius: 50%; + background-color: #eb5757; + color: #fff; + font-size: 9px; + font-weight: 700; + } +} + +.nav { + display: none; + + @media screen and (min-width: 639px) { + display: block !important; + height: 100%; + } + + &__list { + display: flex; + height: 100%; + margin: 0; + padding: 0; + list-style: none; + + @media screen and (min-width: 1200px) { + gap: 64px; + } + } + + &__link { + position: relative; + display: flex; + height: 100%; + padding: 0 16px; + align-items: center; + color: $secondary; + font-size: 12px; + font-weight: 800; + text-decoration: none; + text-transform: uppercase; + transition: color 0.3s; + + @media screen and (min-width: 1200px) { + padding: 0; + } + + &:hover, + &.is-active { + color: $white; + } + + &.is-active::after { + position: absolute; + bottom: -1px; + left: 0; + right: 0; + height: 3px; + content: ''; + background-color: $white; + } + } +} + +.menu { + position: fixed; + inset: 48px 0 0; + z-index: 1000; + display: flex; + flex-direction: column; + background-color: $bg-dark; + transform: translateX(-100%); + transition: transform 0.3s ease-in-out; + + overflow: hidden; + height: calc(100vh - 48px); + + @media screen and (min-width: 640px) { + display: none !important; + } + + &.is-open { + transform: translateX(0); + } + + &__nav { + display: flex; + flex-grow: 1; + flex-direction: column; + padding-top: 48px; + align-items: center; + gap: 16px; + overflow-y: auto; + } + + &__link { + position: relative; + padding: 12px; + color: $secondary; + font-size: 16px; + font-weight: 800; + text-decoration: none; + text-transform: uppercase; + transition: color 0.3s; + + &:hover, + &.is-active { + color: $white; + } + + &.is-active::after { + position: absolute; + bottom: 0; + left: 12px; + right: 12px; + height: 3px; + content: ''; + background-color: $white; + } + } + + &__footer { + display: flex; + height: 64px; + border-top: 1px solid $border-color; + } + + &__icon-link { + position: relative; + display: flex; + flex: 1; + align-items: center; + justify-content: center; + border-right: 1px solid $border-color; + + &:last-child { + border-right: none; + } + + &.is-active .header__icon { + filter: brightness(0) invert(1); + } + + .header__count { + position: absolute; + top: 15px; + right: calc(50% - 15px); + } + } +} + +.main-title { + margin: 24px 0; + color: #fff; + font-family: Mont, sans-serif; + font-size: 32px; + font-weight: 800; + line-height: 41px; + letter-spacing: -0.01em; + + @media screen and (min-width: 640px) { + margin: 40px 0; + font-size: 48px; + line-height: 56px; + } + + @media screen and (min-width: 1200px) { + margin: 56px 0; + } +} diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 00000000000..b7f8efa7591 --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,167 @@ +/* eslint-disable max-len */ +import React, { useState, useEffect } from 'react'; +import { Link, NavLink, useLocation } from 'react-router-dom'; +import { useCart } from '../../context/CartContext'; +import { useFavorites } from '../../context/FavoritesContext'; +import './Header.scss'; + +export const Header: React.FC = () => { + const { cart } = useCart(); + const { favorites } = useFavorites(); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const location = useLocation(); + + const totalItemsInCart = cart.reduce((sum, item) => sum + item.quantity, 0); + + useEffect(() => { + if (isMenuOpen) { + document.body.classList.add('is-menu-open'); + } else { + document.body.classList.remove('is-menu-open'); + } + + return () => { + document.body.classList.remove('is-menu-open'); + }; + }, [isMenuOpen]); + + useEffect(() => { + setIsMenuOpen(false); + }, [location]); + + useEffect(() => { + const handleResize = () => { + if (window.innerWidth >= 640) { + setIsMenuOpen(false); + } + }; + + window.addEventListener('resize', handleResize); + + return () => window.removeEventListener('resize', handleResize); + }, []); + + const getActiveClass = ({ isActive }: { isActive: boolean }) => + isActive ? 'nav__link is-active' : 'nav__link'; + + const getActiveIconClass = ({ isActive }: { isActive: boolean }) => + isActive ? 'header__icon-link is-active' : 'header__icon-link'; + + const getMenuLinkClass = ({ isActive }: { isActive: boolean }) => + isActive ? 'menu__link is-active' : 'menu__link'; + + return ( +
+
+ + Logo + + + +
+ +
+
+ + Favorites + {favorites.length > 0 && ( + {favorites.length} + )} + + + + Cart + {totalItemsInCart > 0 && ( + {totalItemsInCart} + )} + +
+ + +
+ +
+ + +
+ + isActive ? 'menu__icon-link is-active' : 'menu__icon-link' + } + > + Favorites + {favorites.length > 0 && ( + {favorites.length} + )} + + + isActive ? 'menu__icon-link is-active' : 'menu__icon-link' + } + > + Cart + {totalItemsInCart > 0 && ( + {totalItemsInCart} + )} + +
+
+
+ ); +}; diff --git a/src/components/ItemsPerPage/ItemsPerPage.scss b/src/components/ItemsPerPage/ItemsPerPage.scss new file mode 100644 index 00000000000..6dc0cf29688 --- /dev/null +++ b/src/components/ItemsPerPage/ItemsPerPage.scss @@ -0,0 +1,47 @@ +.items-per-page { + display: flex; + flex-direction: column; + gap: 4px; + + &__label { + font-size: 12px; + font-weight: 700; + color: #89939a; + } + + &__select { + height: 40px; + min-width: 128px; + padding: 0 12px; + + background-color: #323542; + color: #f1f2f9; + border: none; + border-radius: 0; + + font-family: Mont, sans-serif; + font-size: 14px; + font-weight: 700; + cursor: pointer; + outline: none; + transition: background-color 0.3s; + + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + + background-image: url("data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 6L8 10L4 6' stroke='%23F1F2F9' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + background-size: 16px; + padding-right: 40px; + + &:hover { + background-color: #4a4d58; + } + + &:focus { + background-color: #4a4d58; + } + } +} diff --git a/src/components/ItemsPerPage/ItemsPerPage.tsx b/src/components/ItemsPerPage/ItemsPerPage.tsx new file mode 100644 index 00000000000..260f7550336 --- /dev/null +++ b/src/components/ItemsPerPage/ItemsPerPage.tsx @@ -0,0 +1,35 @@ +import './ItemsPerPage.scss'; +import React from 'react'; +import { useSearchParams } from 'react-router-dom'; + +export const ItemsPerPage: React.FC = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const perPage = searchParams.get('perPage') || '16'; + + const handleChange = (e: React.ChangeEvent) => { + const params = new URLSearchParams(searchParams); + + params.set('perPage', e.target.value); + params.set('page', '1'); + setSearchParams(params); + }; + + return ( +
+ + +
+ ); +}; diff --git a/src/components/Pagination/Pagination.scss b/src/components/Pagination/Pagination.scss new file mode 100644 index 00000000000..76a43999f9a --- /dev/null +++ b/src/components/Pagination/Pagination.scss @@ -0,0 +1,51 @@ +.pagination { + display: flex; + margin-top: 40px; + margin-bottom: 80px; + align-items: center; + justify-content: center; + gap: 8px; + list-style: none; + + &__btn { + display: flex; + width: 32px; + height: 32px; + align-items: center; + justify-content: center; + border: 1px solid #323542; + background-color: transparent; + color: #f1f2f9; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s; + border-radius: 0; + + // Сначала специфичные состояния (выше hover) + &.is-active { + background-color: #8e7cea; + border-color: #8e7cea; + color: #fff; + } + + &:disabled { + cursor: default; + opacity: 0.5; + } + + // Hover идет после них + &:hover:not(:disabled, .is-active) { + border-color: #f1f2f9; + } + + &--prev { + background: url('../../../public/img/icons/arrow-left.svg') no-repeat center #323542; + } + + &--next { + background: url('../../../public/img/icons/arrow-left.svg') no-repeat center #323542; + transform: rotate(180deg); + } + } +} diff --git a/src/components/Pagination/Pagination.tsx b/src/components/Pagination/Pagination.tsx new file mode 100644 index 00000000000..0b8c5cc1453 --- /dev/null +++ b/src/components/Pagination/Pagination.tsx @@ -0,0 +1,84 @@ +import './Pagination.scss'; +import React from 'react'; +import { useSearchParams } from 'react-router-dom'; + +interface Props { + total: number; + perPage: number; +} + +export const Pagination: React.FC = ({ total, perPage }) => { + const [searchParams, setSearchParams] = useSearchParams(); + const currentPage = Number(searchParams.get('page')) || 1; + const totalPages = Math.ceil(total / perPage); + + const handlePageChange = (page: number) => { + const params = new URLSearchParams(searchParams); + + params.set('page', page.toString()); + setSearchParams(params); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + if (totalPages <= 1) { + return null; + } + + const getVisiblePages = () => { + const allPages = Array.from({ length: totalPages }, (_, i) => i + 1); + + if (totalPages <= 4) { + return allPages; + } + + let start = currentPage - 1; + + if (start < 1) { + start = 1; + } + + if (start + 3 > totalPages) { + start = totalPages - 3; + } + + return allPages.slice(start - 1, start + 3); + }; + + const visiblePages = getVisiblePages(); + + return ( +
    +
  • +
  • + + {visiblePages.map(page => ( +
  • + +
  • + ))} + +
  • +
  • +
+ ); +}; diff --git a/src/components/ProductCard/ProductCard.scss b/src/components/ProductCard/ProductCard.scss new file mode 100644 index 00000000000..cc30de42dce --- /dev/null +++ b/src/components/ProductCard/ProductCard.scss @@ -0,0 +1,156 @@ +.product-card { + display: flex; + width: 100%; + height: 100%; + flex-direction: column; + flex-shrink: 0; + flex-grow: 0; + padding: 16px; + background-color: #161827; + border: 1px solid #3b3e49; + box-sizing: border-box; + transition: border-color 0.3s ease; + + @media (min-width: 640px) { + padding: 32px; + } + + &:hover { + border-color: #f1f2f9; + } + + &__image-container { + display: flex; + width: 100%; + height: 160px; + margin-bottom: 24px; + align-items: center; + justify-content: center; + + @media (min-width: 640px) { + height: 196px; + } + } + + &__image { + max-width: 100%; + max-height: 100%; + object-fit: contain; + } + + &__title { + margin: 0 0 8px; + min-height: 42px; + color: #f1f2f9; + font-family: Mont, sans-serif; + font-size: 14px; + font-weight: 600; + line-height: 21px; + } + + &__price { + display: flex; + margin-bottom: 8px; + align-items: baseline; + gap: 8px; + + &-new { + color: #f1f2f9; + font-size: 22px; + font-weight: 800; + } + + &-old { + color: #75767f; + font-size: 14px; + text-decoration: line-through; + } + } + + &__divider { + height: 1px; + margin-bottom: 8px; + background-color: #3b3e49; + } + + &__specs { + display: flex; + flex-grow: 1; + flex-direction: column; + gap: 8px; + margin-bottom: 24px; + } + + &__spec { + display: flex; + justify-content: space-between; + font-size: 12px; + + &-label { + color: #75767f; + } + + &-value { + font-weight: 600; + color: #f1f2f9; + } + } + + &__buttons { + display: flex; + margin-top: auto; + gap: 8px; + } + + &__btn-cart { + flex: 1; + height: 40px; + border: none; + background-color: #8970ff; + color: #fff; + font-size: 14px; + font-weight: 700; + cursor: pointer; + transition: background-color 0.3s; + + &:hover { + background-color: #a38fff; + } + + &.is-active { + border: 1px solid #3b3e49; + background-color: transparent; + color: #f1f2f9; + } + } + + &__btn-fav { + display: flex; + width: 40px; + height: 40px; + flex-shrink: 0; + align-items: center; + justify-content: center; + border: 1px solid #3b3e49; + background-color: #323542; + cursor: pointer; + + .product-card__icon-heart { + width: 16px; + height: 16px; + background: url('/img/icons/heart.svg') no-repeat center; + background-size: contain; + } + + &.is-active .product-card__icon-heart { + background: url('/img/icons/heart-filled.svg') no-repeat center; + } + } + + @media (min-width: 1200px) { + width: 272px !important; + height: 506px !important; + min-width: 272px !important; + max-width: 272px !important; + } +} diff --git a/src/components/ProductCard/ProductCard.tsx b/src/components/ProductCard/ProductCard.tsx new file mode 100644 index 00000000000..b0d103c2c08 --- /dev/null +++ b/src/components/ProductCard/ProductCard.tsx @@ -0,0 +1,97 @@ +/* eslint-disable max-len */ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { Product } from '../../types/Product'; +import { useCart } from '../../context/CartContext'; +import { useFavorites } from '../../context/FavoritesContext'; +import './ProductCard.scss'; + +interface Props { + product: Product; +} + +export const ProductCard: React.FC = ({ product }) => { + const { cart, addToCart, removeFromCart } = useCart(); + const { favorites, toggleFavorite } = useFavorites(); + + const isInCart = cart.some(item => item.id === product.id); + const isFavorite = favorites.some(item => item.id === product.id); + + const handleCartClick = () => { + if (isInCart) { + removeFromCart(product.id); + } else { + addToCart(product); + } + }; + + const handleFavoriteClick = (event: React.MouseEvent) => { + event.preventDefault(); + toggleFavorite(product); + }; + + const imagePath = product.image; + + return ( +
+ +
+ {product.name} +
+

{product.name}

+ + +
+ {`$${product.price}`} + {product.fullPrice && ( + {`$${product.fullPrice}`} + )} +
+ +
+ +
+
+ Screen + {product.screen} +
+
+ + {product.category === 'accessories' ? 'Size' : 'Capacity'} + + {product.capacity} +
+
+ RAM + {product.ram} +
+
+ +
+ + + +
+
+ ); +}; 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/ProductsSlider/ProductsSlider.scss b/src/components/ProductsSlider/ProductsSlider.scss new file mode 100644 index 00000000000..23b4f67bf49 --- /dev/null +++ b/src/components/ProductsSlider/ProductsSlider.scss @@ -0,0 +1,104 @@ +.products-slider { + margin-bottom: 56px; + + @media (min-width: 640px) { + margin-bottom: 72px; + } + + @media (min-width: 1200px) { + margin-bottom: 80px; + } + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + } + + &__title { + font-size: 22px; + line-height: 31px; + font-weight: 800; + color: #f1f2f9; + + @media (min-width: 640px) { + font-size: 32px; + line-height: 41px; + } + } + + &__buttons { + display: flex; + gap: 16px; + } + + &__btn { + width: 32px; + height: 32px; + border: 1px solid #323542; + background: transparent; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s; + + &:disabled { + opacity: 0.5; + cursor: default; + border-color: #3b3e49; + } + + &:not(:disabled):hover { + border-color: #f1f2f9; + } + + &::before { + content: ''; + width: 8px; + height: 8px; + border-left: 2px solid #f1f2f9; + border-bottom: 2px solid #f1f2f9; + transform: rotate(45deg); + } + + &--next::before { + transform: rotate(-135deg); + } + } + + &__container { + overflow: hidden; + width: 100%; + } + + &__content { + display: flex; + flex-wrap: nowrap; + gap: 16px; + transition: transform 0.5s ease-in-out; + } + + &__card-wrapper { + flex-shrink: 0; + box-sizing: border-box; + width: 212px; + height: 440px; + + @media (min-width: 640px) { + width: 237px; + height: 512px; + } + + @media (min-width: 1200px) { + width: 272px; + height: 506px; + } + + .product-card { + width: 100%; + height: 100%; + } + } +} diff --git a/src/components/ProductsSlider/ProductsSlider.tsx b/src/components/ProductsSlider/ProductsSlider.tsx new file mode 100644 index 00000000000..902fdb1a757 --- /dev/null +++ b/src/components/ProductsSlider/ProductsSlider.tsx @@ -0,0 +1,65 @@ +import React, { useState } from 'react'; +import './ProductsSlider.scss'; +import { Product } from '../../types/Product'; +import { ProductCard } from '../ProductCard/ProductCard'; + +interface Props { + title: string; + products: Product[]; +} + +export const ProductsSlider: React.FC = ({ title, products }) => { + const [offset, setOffset] = useState(0); + const step = 1; + const frameSize = 4; + const gap = 16; + + const handleNext = () => { + setOffset(prev => Math.min(prev + step, products.length - frameSize)); + }; + + const handlePrev = () => { + setOffset(prev => Math.max(prev - step, 0)); + }; + + return ( +
+
+

{title}

+
+
+
+ +
+
+ {products.map(product => ( +
+ +
+ ))} +
+
+
+ ); +}; diff --git a/src/components/SortSelector/SortSelector.scss b/src/components/SortSelector/SortSelector.scss new file mode 100644 index 00000000000..a1b8f515919 --- /dev/null +++ b/src/components/SortSelector/SortSelector.scss @@ -0,0 +1,44 @@ +.sort-selector { + display: flex; + flex-direction: column; + gap: 4px; + + &__label { + font-size: 12px; + font-weight: 700; + color: #89939a; + } + + &__select { + height: 40px; + padding: 0 12px; + background-color: #323542; + border: none; + color: #f1f2f9; + font-family: Mont, sans-serif; + font-weight: 700; + cursor: pointer; + outline: none; + border-radius: 0; + + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + background-image: url("data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 6L8 10L4 6' stroke='%23F1F2F9' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 12px center; + background-size: 16px; + + padding-right: 40px; + + &:hover { + background-color: #4a4d58; + border-color: #4a4d58; + } + + option { + background-color: #0f1117; + color: #f1f2f9; + } + } +} diff --git a/src/components/SortSelector/SortSelector.tsx b/src/components/SortSelector/SortSelector.tsx new file mode 100644 index 00000000000..abeda077b76 --- /dev/null +++ b/src/components/SortSelector/SortSelector.tsx @@ -0,0 +1,33 @@ +import './SortSelector.scss'; +import React from 'react'; +import { useSearchParams } from 'react-router-dom'; + +export const SortSelector: React.FC = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const sortBy = searchParams.get('sort') || 'age'; + + const handleChange = (e: React.ChangeEvent) => { + const params = new URLSearchParams(searchParams); + + params.set('sort', e.target.value); + setSearchParams(params); + }; + + return ( +
+ + +
+ ); +}; diff --git a/src/components/pages/CartPage/CartPage.scss b/src/components/pages/CartPage/CartPage.scss new file mode 100644 index 00000000000..6834e2263c2 --- /dev/null +++ b/src/components/pages/CartPage/CartPage.scss @@ -0,0 +1,145 @@ +$white: #f1f2f9; +$secondary: #89939a; +$accent: #313237; +$bg-dark: #0f1117; +$border-color: #323542; + +.cart-page { + padding-bottom: 0; + padding-top: 48px; + + @media (min-width: 640px) { + padding-top: 0; + } + + &__container { + margin: 0 auto; + width: 100%; + padding: 0 16px; + + @media (min-width: 640px) { + padding: 0 32px; + } + + @media (min-width: 1200px) { + max-width: 1136px; + padding: 0; + } + } + + &__back { + background: none; + border: none; + color: $white; + font-size: 12px; + font-weight: 700; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + padding: 24px 0; + transition: opacity 0.3s; + + &:hover { + opacity: 0.7; + } + + &-icon { + width: 16px; + height: 16px; + display: block; + filter: brightness(0) invert(1); + } + } + + &__title { + color: $white; + font-size: 32px; + font-weight: 800; + line-height: 41px; + margin: 0 0 32px; + letter-spacing: -0.01em; + + @media (min-width: 640px) { + font-size: 48px; + line-height: 56px; + } + } + + &__content { + display: flex; + gap: 16px; + align-items: flex-start; + + @media (max-width: 991px) { + flex-direction: column; + align-items: stretch; + gap: 32px; + } + } + + &__list { + flex-grow: 1; + display: flex; + flex-direction: column; + gap: 16px; + } +} + +.summary { + padding: 24px; + background-color: #161827; + border: 1px solid $border-color; + display: flex; + flex-direction: column; + align-items: center; + box-sizing: border-box; + + @media (min-width: 992px) { + width: 368px; + flex-shrink: 0; + } + + &__total { + color: $white; + font-size: 32px; + font-weight: 700; + margin-bottom: 8px; + } + + &__count { + color: $secondary; + font-size: 16px; + font-weight: 600; + margin-bottom: 24px; + } + + &__line { + width: 100%; + height: 1px; + background: $border-color; + margin-bottom: 24px; + } + + &__checkout-btn { + width: 100%; + height: 48px; + background: #8a4fff; + color: #fff; + border: none; + font-weight: 700; + font-size: 16px; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 0 4px 14px rgba(138, 79, 255, 0.2); + + &:hover { + background: #9d6fff; + transform: translateY(-1px); + } + + &:active { + transform: translateY(0); + } + } +} diff --git a/src/components/pages/CartPage/CartPage.tsx b/src/components/pages/CartPage/CartPage.tsx new file mode 100644 index 00000000000..fec62d7fb70 --- /dev/null +++ b/src/components/pages/CartPage/CartPage.tsx @@ -0,0 +1,71 @@ +/* eslint-disable max-len */ +import React from 'react'; +import { useCart } from '../../../context/CartContext'; +import { CartItem } from '../../CartItem/CartItem'; +import './CartPage.scss'; + +const BASE = import.meta.env.BASE_URL.endsWith('/') + ? import.meta.env.BASE_URL + : `${import.meta.env.BASE_URL}/`; + +export const CartPage: React.FC = () => { + const { cart, totalAmount, totalItems, handleCheckout, isCheckout } = + useCart(); + + return ( +
+
+ + +

Cart

+ +
+ {cart.length > 0 ? ( + <> +
+ {cart.map(item => ( + + ))} +
+ +
+
${totalAmount}
+
+ Total for {totalItems} items +
+
+ +
+ + ) : ( +

Your cart is empty

+ )} +
+
+ + {isCheckout && ( +
+

Checkout is not implemented yet

+
+ )} +
+ ); +}; diff --git a/src/components/pages/CartPage/index.ts b/src/components/pages/CartPage/index.ts new file mode 100644 index 00000000000..90c010237a0 --- /dev/null +++ b/src/components/pages/CartPage/index.ts @@ -0,0 +1 @@ +export * from './CartPage'; diff --git a/src/components/pages/FavoritesPage/FavoritesPage.scss b/src/components/pages/FavoritesPage/FavoritesPage.scss new file mode 100644 index 00000000000..0c901c162e3 --- /dev/null +++ b/src/components/pages/FavoritesPage/FavoritesPage.scss @@ -0,0 +1,83 @@ +.favorites-page { + padding-bottom: 80px; + + padding-top: 56px; + + @media (min-width: 639.5px) { + padding-top: 0; + } + + &__container { + margin: 0 auto; + width: 100%; + padding: 0 16px; + box-sizing: border-box; + + @media (min-width: 639.5px) { + padding: 0 24px; + } + + @media (min-width: 1200px) { + max-width: 1136px; + padding: 0; + } + } + + &__title { + color: #fff; + font-size: 32px; + font-weight: 700; + line-height: 40px; + margin: 24px 0 8px; + letter-spacing: -0.01em; + + @media (min-width: 639.5px) { + font-size: 48px; + line-height: 56px; + } + } + + &__count { + color: #89939a; + font-size: 16px; + font-weight: 600; + margin-bottom: 40px; + } + + &__list { + display: grid; + grid-template-columns: repeat(auto-fill, 287px); + gap: 40px 16px; + justify-content: center; + + @media (min-width: 639.5px) { + grid-template-columns: repeat(auto-fill, 288px); + justify-content: start; + } + + @media (min-width: 1200px) { + grid-template-columns: repeat(auto-fill, 272px); + } + } + + &__item { + width: 287px; + height: 440px; + + @media (min-width: 639.5px) { + width: 288px; + height: 506px; + } + + @media (min-width: 1200px) { + width: 272px; + height: 506px; + } + } + + &__empty { + color: #fff; + font-size: 20px; + margin-top: 40px; + } +} diff --git a/src/components/pages/FavoritesPage/FavoritesPage.tsx b/src/components/pages/FavoritesPage/FavoritesPage.tsx new file mode 100644 index 00000000000..b6287504e18 --- /dev/null +++ b/src/components/pages/FavoritesPage/FavoritesPage.tsx @@ -0,0 +1,30 @@ +import './FavoritesPage.scss'; +import React from 'react'; +import { useFavorites } from '../../../context/FavoritesContext'; +import { ProductCard } from '../../ProductCard/ProductCard'; +import { Breadcrumbs } from '../../Breadcrumbs/Breadcrumbs'; + +export const FavoritesPage: React.FC = () => { + const { favorites } = useFavorites(); + + return ( +
+
+ + +

Favourites

+

{`${favorites.length} items`}

+ + {favorites.length > 0 ? ( +
+ {favorites.map(product => ( + + ))} +
+ ) : ( +

Your favorites list is empty

+ )} +
+
+ ); +}; diff --git a/src/components/pages/FavoritesPage/index.ts b/src/components/pages/FavoritesPage/index.ts new file mode 100644 index 00000000000..b3a884b1889 --- /dev/null +++ b/src/components/pages/FavoritesPage/index.ts @@ -0,0 +1 @@ +export * from './FavoritesPage'; diff --git a/src/components/pages/HomePage/HomePage.scss b/src/components/pages/HomePage/HomePage.scss new file mode 100644 index 00000000000..b4f1c35e2af --- /dev/null +++ b/src/components/pages/HomePage/HomePage.scss @@ -0,0 +1,78 @@ +.home-page { + padding-left: 0; + padding-right: 0; + padding-top: 48px; + + @media (min-width: 640px) { + padding-top: 0; + } + + &__section { + margin-bottom: 56px; + + @media (min-width: 640px) { + margin-bottom: 72px; + } + + @media (min-width: 1200px) { + margin-bottom: 80px; + } + + &:last-child { + margin-bottom: 0; + } + } + + &__product-card { + background-color: #323542; + border: 1px solid #4a4d58; + display: flex; + flex-direction: column; + padding: 32px; + box-sizing: border-box; + + width: 212px; + height: auto; + + @media (min-width: 640px) { + width: 237px !important; + min-width: 237px !important; + max-width: 237px !important; + height: 512px !important; + } + + @media (min-width: 1200px) { + width: 272px !important; + min-width: 272px !important; + max-width: 272px !important; + height: 506px !important; + } + } +} + +.main-title { + font-family: Mont, sans-serif; + font-weight: 800; + color: #fff; + letter-spacing: -0.01em; + + font-size: 32px; + line-height: 41px; + margin-top: 24px; + margin-bottom: 24px; + max-width: 288px; + + @media (min-width: 640px) { + font-size: 48px; + line-height: 56px; + margin-top: 32px; + margin-bottom: 32px; + max-width: 592px; + } + + @media (min-width: 1200px) { + margin-top: 56px; + margin-bottom: 56px; + max-width: 100%; + } +} diff --git a/src/components/pages/HomePage/HomePage.tsx b/src/components/pages/HomePage/HomePage.tsx new file mode 100644 index 00000000000..8f208e7f2c9 --- /dev/null +++ b/src/components/pages/HomePage/HomePage.tsx @@ -0,0 +1,47 @@ +/* eslint-disable max-len */ +import React from 'react'; +import './HomePage.scss'; +import { Product } from '../../../types/Product'; +import { Banner } from '../../Banner/Banner'; +import { ProductsSlider } from '../../ProductsSlider/ProductsSlider'; +import { Categories } from '../../Categories/Categories'; + +interface Props { + products: Product[]; +} + +export const HomePage: React.FC = ({ products }) => { + const brandNew = products + .filter( + product => product.year >= 2022 && product.fullPrice > product.price, + ) + .sort((a, b) => b.price - a.price); + + const hotPrices = products + .filter(product => product.fullPrice - product.price > 0) + .sort((a, b) => b.fullPrice - b.price - (a.fullPrice - a.price)); + + return ( +
+
+

Welcome to Nice Gadgets store!

+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+
+ ); +}; diff --git a/src/components/pages/NotFoundPage/NotFoundPage.scss b/src/components/pages/NotFoundPage/NotFoundPage.scss new file mode 100644 index 00000000000..3d26654068d --- /dev/null +++ b/src/components/pages/NotFoundPage/NotFoundPage.scss @@ -0,0 +1,52 @@ +.not-found { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: calc(100vh - 200px); + padding: 24px; + text-align: center; + + &__title { + font-size: 120px; + font-weight: 800; + line-height: 1; + color: #313237; + margin: 0; + + @media (min-width: 640px) { + font-size: 160px; + } + } + + &__subtitle { + font-size: 32px; + color: #313237; + margin-bottom: 16px; + } + + &__text { + font-size: 16px; + color: #89939a; + max-width: 400px; + margin-bottom: 32px; + line-height: 1.5; + } + + &__link { + display: inline-flex; + align-items: center; + justify-content: center; + height: 48px; + padding: 0 32px; + background-color: #313237; + color: #fff; + text-decoration: none; + font-weight: 600; + transition: background-color 0.3s; + + &:hover { + background-color: #4a4d58; + } + } +} diff --git a/src/components/pages/NotFoundPage/NotFoundPage.tsx b/src/components/pages/NotFoundPage/NotFoundPage.tsx new file mode 100644 index 00000000000..db6ffc275c2 --- /dev/null +++ b/src/components/pages/NotFoundPage/NotFoundPage.tsx @@ -0,0 +1,18 @@ +import './NotFoundPage.scss'; +import React from 'react'; +import { Link } from 'react-router-dom'; + +export const NotFoundPage: React.FC = () => { + return ( +
+

404

+

Page not found

+

+ Sorry, the page you are looking for does not exist or has been moved. +

+ + Go to Home Page + +
+ ); +}; diff --git a/src/components/pages/PhonesPage/PhonesPage.scss b/src/components/pages/PhonesPage/PhonesPage.scss new file mode 100644 index 00000000000..22c54d6616a --- /dev/null +++ b/src/components/pages/PhonesPage/PhonesPage.scss @@ -0,0 +1,70 @@ +.phones-page { + padding-bottom: 40px; + padding-top: 48px; + + @media (min-width: 640px) { + padding-top: 0; + padding-left: 24px; + padding-right: 24px; + } + + &__title { + font-family: Mont, sans-serif; + font-weight: 800; + font-size: 32px; + line-height: 41px; + letter-spacing: -0.01em; + color: #f1f2f9; + + margin: 24px 0 8px; + + @media (min-width: 640px) { + font-size: 48px; + line-height: 56px; + margin-top: 24px; + } + } + + &__count { + font-size: 14px; + color: #89939a; + margin-bottom: 40px; + } + + &__filters { + display: flex; + gap: 16px; + margin-bottom: 24px; + } + + &__list { + display: grid; + grid-template-columns: 288px; + row-gap: 40px; + justify-content: center; + + @media (min-width: 640px) { + grid-template-columns: repeat(2, 288px); + gap: 40px 16px; + justify-content: center; + } + + @media (min-width: 1200px) { + grid-template-columns: repeat(4, 272px); + column-gap: 16px; + justify-content: center; + } + + .product-card { + @media (min-width: 640px) { + height: 506px; + width: 288px; + } + + @media (min-width: 1200px) { + height: auto; + width: 272px; + } + } + } +} diff --git a/src/components/pages/PhonesPage/PhonesPage.tsx b/src/components/pages/PhonesPage/PhonesPage.tsx new file mode 100644 index 00000000000..0c436e9f115 --- /dev/null +++ b/src/components/pages/PhonesPage/PhonesPage.tsx @@ -0,0 +1,73 @@ +/* eslint-disable max-len */ +import './PhonesPage.scss'; +import React from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { Product } from '../../../types/Product'; +import { ProductCard } from '../../ProductCard/ProductCard'; +import { SortSelector } from '../../SortSelector/SortSelector'; +import { ItemsPerPage } from '../../ItemsPerPage/ItemsPerPage'; +import { Pagination } from '../../Pagination/Pagination'; +import { Breadcrumbs } from '../../Breadcrumbs/Breadcrumbs'; + +interface Props { + products: Product[]; + title: string; +} + +export const PhonesPage: React.FC = ({ products, title }) => { + const [searchParams] = useSearchParams(); + const sortBy = searchParams.get('sort') || 'age'; + const perPage = searchParams.get('perPage') || '16'; + const currentPage = Number(searchParams.get('page')) || 1; + + const sortedProducts = [...products].sort((a, b) => { + switch (sortBy) { + case 'name': + return a.name.localeCompare(b.name); + case 'price': + return a.price - b.price; + default: + return b.year - a.year; + } + }); + + const total = sortedProducts.length; + const isAll = perPage === 'all'; + const itemsCount = isAll ? total : Number(perPage); + + const start = (currentPage - 1) * itemsCount; + const end = isAll ? total : start + itemsCount; + const visibleProducts = sortedProducts.slice(start, end); + + return ( +
+ + +

{title}

+

{`${total} models`}

+ + {total > 0 ? ( + <> +
+ + +
+ +
+ {visibleProducts.map(product => ( + + ))} +
+ + {!isAll && total > itemsCount && ( + + )} + + ) : ( +

+ {`No ${title.toLowerCase()} found`} +

+ )} +
+ ); +}; diff --git a/src/components/pages/ProductDetailsPage/ProductDetailsPage.scss b/src/components/pages/ProductDetailsPage/ProductDetailsPage.scss new file mode 100644 index 00000000000..bd91e397846 --- /dev/null +++ b/src/components/pages/ProductDetailsPage/ProductDetailsPage.scss @@ -0,0 +1,452 @@ +.details { + background-color: #0f1121; + color: #fff; + font-family: Mont, sans-serif; + padding-top: 72px; + padding-bottom: 56px; + width: 100%; + overflow-x: hidden; + box-sizing: border-box; + + @media screen and (min-width: 639.5px) { + padding-top: 40px; + padding-bottom: 80px; + } + + &__top-nav { + display: flex; + align-items: center; + gap: 8px; + padding-bottom: 24px; + font-size: 12px; + line-height: 1; + + @media screen and (min-width: 639.5px) { + padding-bottom: 0; + margin-bottom: 32px; + } + } + + &__home-icon { + display: block; + width: 16px; + height: 16px; + background: url('/img/icons/home.svg') no-repeat center; + background-size: contain; + flex-shrink: 0; + } + + &__arrow { + display: block; + width: 16px; + height: 16px; + background: url('/img/icons/arrow-right.svg') no-repeat center; + background-size: contain; + filter: brightness(0) invert(0.6); + } + + &__category-link { + color: #fff !important; + text-decoration: none; + font-weight: 700; + transition: opacity 0.3s; + + &:visited { + color: #fff !important; + } + + &:hover { + opacity: 0.7; + } + } + + &__model-name { + display: flex; + align-items: center; + color: #89939a; + text-decoration: none; + height: 16px; + white-space: nowrap; + font-weight: 700; + } + + &__back-link { + display: flex; + align-items: center; + gap: 8px; + text-decoration: none; + color: #fff; + font-size: 12px; + font-weight: 700; + margin-bottom: 24px; + + &::before { + content: ''; + width: 16px; + height: 16px; + background: url('/img/icons/arrow-left.svg') no-repeat center; + background-size: contain; + } + } + + &__title { + font-size: 32px; + font-weight: 700; + margin-bottom: 40px; + word-wrap: break-word; + } + + &__content { + display: flex; + flex-direction: column; + gap: 32px; + margin-bottom: 80px; + + @media screen and (min-width: 639.5px) { + flex-direction: row; + justify-content: space-between; + align-items: flex-start; + gap: 20px; + } + + @media screen and (min-width: 1200px) { + display: grid; + grid-template-columns: 1fr 320px; + gap: 64px; + } + } + + &__gallery { + display: flex; + flex-direction: column; + gap: 16px; + align-items: center; + + @media screen and (min-width: 639.5px) { + flex-direction: row-reverse; + justify-content: flex-end; + align-items: flex-start; + flex: 1; + max-width: calc(100% - 257px); + } + + @media screen and (min-width: 1200px) { + max-width: none; + } + } + + &__thumbnails { + display: flex; + flex-direction: row; + gap: 8px; + flex-shrink: 0; + + @media screen and (min-width: 639.5px) { + flex-direction: column; + gap: 12px; + } + } + + &__thumb { + width: 50px; + height: 50px; + background: #fff; + border: 1px solid #4a4d58; + cursor: pointer; + padding: 4px; + + &.is-active { + border-color: #fff; + } + + img { + width: 100%; + height: 100%; + object-fit: contain; + } + + @media screen and (min-width: 1200px) { + width: 80px; + height: 80px; + } + } + + &__main-img { + width: 288px; + height: 288px; + display: flex; + justify-content: center; + align-items: center; + + img { + width: 100%; + height: 100%; + object-fit: contain; + } + + @media screen and (min-width: 639.5px) { + flex: 1; + height: 350px; + } + + @media screen and (min-width: 1200px) { + height: 440px; + } + } + + &__actions-panel { + @media screen and (min-width: 639.5px) { + width: 237px; + flex-shrink: 0; + } + + @media screen and (min-width: 1200px) { + width: 320px; + } + } + + &__section { + margin-bottom: 24px; + padding-bottom: 24px; + border-bottom: 1px solid #323542; + + &-label { + display: block; + font-size: 12px; + color: #89939a; + margin-bottom: 8px; + } + } + + &__colors { + display: flex; + gap: 8px; + } + + &__color { + width: 30px; + height: 30px; + border-radius: 50%; + border: 2px solid #0f1121; + outline: 1px solid #3b3e49; + cursor: pointer; + + &.is-active { + outline-color: #fff; + } + } + + &__capacity { + display: flex; + gap: 8px; + + &-btn { + padding: 10px 12px; + border: 1px solid #4a4d58; + color: #fff; + text-decoration: none; + font-size: 14px; + + &.is-active { + background: #fff; + color: #0f1121; + border-color: #fff; + } + } + } + + &__price-block { + display: flex; + align-items: center; + gap: 8px; + margin: 32px 0 16px; + } + + &__price { + font-size: 32px; + font-weight: 800; + } + + &__price-old { + font-size: 22px; + color: #89939a; + text-decoration: line-through; + } + + &__actions { + display: flex; + gap: 8px; + margin-bottom: 32px; + } + + &__cart-btn { + flex-grow: 1; + height: 48px; + background: #6c5ece; + color: #fff; + border: none; + font-weight: 700; + cursor: pointer; + } + + &__favorite-btn { + width: 48px; + height: 48px; + border: 1px solid #4a4d58; + background: #323542 url('/img/icons/heart.svg') no-repeat center; + cursor: pointer; + + &.is-active { + background-image: url('/img/icons/heart-filled.svg'); + } + } + + &__specs-short { + .details__specs-item { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 8px; + font-size: 12px; + gap: 12px; + } + + .details__specs-label { + color: #89939a; + flex-shrink: 0; + } + + .details__specs-value { + color: #f1f2f9; + font-weight: 600; + text-align: right; + word-break: break-word; + } + } + + &__description { + margin-top: 56px; + + @media screen and (min-width: 1200px) { + display: grid; + grid-template-columns: 1fr 320px; + gap: 64px; + } + } + + &__subtitle { + font-size: 24px; + font-weight: 700; + border-bottom: 1px solid #323542; + padding-bottom: 16px; + margin-bottom: 32px; + } + + &__section-title { + font-size: 20px; + font-weight: 700; + margin: 32px 0 16px; + } + + &__paragraph { + font-size: 14px; + line-height: 1.5; + color: #89939a; + margin-bottom: 24px; + } + + &__specs-full { + .details__specs-list { + display: flex; + flex-direction: column; + gap: 12px; + } + + .details__specs-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + font-size: 14px; + } + + .details__specs-label { + color: #89939a; + flex-shrink: 0; + min-width: 90px; + } + + .details__specs-value { + color: #f1f2f9; + font-weight: 600; + text-align: right; + word-wrap: break-word; + } + } + + &__suggested { + margin-top: 80px; + + &-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + } + + &-title { + font-size: 22px; + font-weight: 700; + } + + &-arrows { + display: flex; + gap: 16px; + } + + &-arrow { + width: 32px; + height: 32px; + border: 1px solid #4a4d58; + background: #323542 no-repeat center; + cursor: pointer; + + &--left { + background-image: url('/img/icons/arrow-left.svg'); + } + + &--right { + background-image: url('/img/icons/arrow-right.svg'); + } + + &.is-disabled { + opacity: 0.5; + cursor: default; + } + } + + &-overflow { + overflow: hidden; + width: 100%; + } + + &-list { + display: flex; + gap: 16px; + transition: transform 0.5s ease; + } + } + + &__card-container { + flex-shrink: 0; + width: 212px; + + @media screen and (min-width: 639.5px) { + width: 237px; + } + + @media screen and (min-width: 1200px) { + width: 272px; + } + } +} diff --git a/src/components/pages/ProductDetailsPage/ProductDetailsPage.tsx b/src/components/pages/ProductDetailsPage/ProductDetailsPage.tsx new file mode 100644 index 00000000000..fc50d534759 --- /dev/null +++ b/src/components/pages/ProductDetailsPage/ProductDetailsPage.tsx @@ -0,0 +1,302 @@ +/* eslint-disable max-len */ +import React, { useState, useEffect } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import { useCart } from '../../../context/CartContext'; +import { useFavorites } from '../../../context/FavoritesContext'; +import { Product } from '../../../types/Product'; +import { ProductDetails } from '../../../types/ProductDetails'; +import { ProductCard } from '../../ProductCard/ProductCard'; +import './ProductDetailsPage.scss'; + +interface Props { + products: Product[]; + details: ProductDetails[]; +} + +export const ProductDetailsPage: React.FC = ({ products, details }) => { + const { productId } = useParams(); + const { cart, addToCart, removeFromCart } = useCart(); + const { favorites, toggleFavorite } = useFavorites(); + + const product = details.find(d => d.id === productId); + const baseProduct = products.find(p => p.itemId === productId); + + const [selectedImage, setSelectedImage] = useState(''); + const [currentIndex, setCurrentIndex] = useState(0); + const [step, setStep] = useState(288); + const visibleCards = 4; + + useEffect(() => { + const handleResize = () => { + if (window.innerWidth >= 639.5 && window.innerWidth < 1200) { + setStep(253); + } else { + setStep(288); + } + }; + + handleResize(); + window.addEventListener('resize', handleResize); + + return () => window.removeEventListener('resize', handleResize); + }, []); + + useEffect(() => { + if (product) { + setSelectedImage(product.images[0]); + setCurrentIndex(0); + } + }, [product]); + + if (!product || !baseProduct) { + return ( +
+

Product not found

+
+ ); + } + + const categoryPath = `/${baseProduct.category}`; + const categoryName = + baseProduct.category.charAt(0).toUpperCase() + + baseProduct.category.slice(1); + + const recommendedProducts = products + .filter(p => p.category === baseProduct.category && p.itemId !== productId) + .slice(0, 12); + + const isInCart = cart.some(item => item.id === baseProduct.id); + const isInFavorites = favorites.some(item => item.id === baseProduct.id); + + const handleCartClick = () => { + if (isInCart) { + removeFromCart(baseProduct.id); + } else { + addToCart(baseProduct); + } + }; + + const handlePrev = () => { + if (currentIndex > 0) { + setCurrentIndex(prev => prev - 1); + } + }; + + const handleNext = () => { + if (currentIndex < recommendedProducts.length - visibleCards) { + setCurrentIndex(prev => prev + 1); + } + }; + + const getColorHex = (colorName: string) => { + const normalizedName = colorName.toLowerCase().replace(/\s+/g, ''); + const colors: { [key: string]: string } = { + gold: '#FCDBC1', + grey: '#3B3E4A', + spacegray: '#3B3E4A', + spacegrey: '#3B3E4A', + black: '#0F1121', + white: '#FFFFFF', + midnightgreen: '#004953', + rosegold: '#FADADD', + graphite: '#41424C', + sierrablue: '#9BB5CE', + blue: '#215E7C', + silver: '#E2E3E4', + pink: '#FAE3E3', + midnight: '#191970', + starlight: '#F8F9EC', + }; + + return colors[normalizedName] || colorName; + }; + + return ( +
+ + + + Back + + +

{product.name}

+ +
+
+
+ {selectedImage && {product.name}} +
+ +
+ {product.images.map(img => ( + + ))} +
+
+ +
+
+ Available colors +
+ {product.colorsAvailable.map(color => ( + + ))} +
+
+ +
+ Select capacity +
+ {product.capacityAvailable.map(cap => ( + + {cap} + + ))} +
+
+ +
+ {`$${product.priceDiscount}`} + {`$${product.priceRegular}`} +
+ +
+ +
+ +
+ {[ + { label: 'Screen', value: product.screen }, + { label: 'Resolution', value: product.resolution }, + { label: 'Processor', value: product.processor }, + { label: 'RAM', value: product.ram }, + ].map(spec => ( +
+ {spec.label} + {spec.value} +
+ ))} +
+
+
+ +
+
+

About

+ {product.description.map(item => ( +
+

{item.title}

+ {item.text.map(paragraph => ( +

+ {paragraph} +

+ ))} +
+ ))} +
+ +
+

Tech specs

+
+ {[ + { label: 'Screen', value: product.screen }, + { label: 'Resolution', value: product.resolution }, + { label: 'Processor', value: product.processor }, + { label: 'RAM', value: product.ram }, + { label: 'Built in memory', value: product.capacity }, + { label: 'Camera', value: product.camera }, + { label: 'Zoom', value: product.zoom }, + { label: 'Cell', value: product.cell.join(', ') }, + ] + .filter(spec => spec.value && spec.value.length > 0) + .map(spec => ( +
+ {spec.label} + {spec.value} +
+ ))} +
+
+
+ +
+
+

You may also like

+
+
+
+ +
+
+ {recommendedProducts.map(recProduct => ( +
+ +
+ ))} +
+
+
+
+ ); +}; diff --git a/src/components/pages/ProductsPage/ProductsPage.scss b/src/components/pages/ProductsPage/ProductsPage.scss new file mode 100644 index 00000000000..3ce9c4cac32 --- /dev/null +++ b/src/components/pages/ProductsPage/ProductsPage.scss @@ -0,0 +1,48 @@ +.products-page { + padding: 24px; + + &__title { + margin: 0 0 8px; + font-size: 32px; + line-height: 41px; + color: #313237; + } + + &__count { + margin: 0 0 32px; + font-weight: 600; + font-size: 14px; + line-height: 21px; + color: #89939a; + } + + &__filters { + display: flex; + gap: 16px; + margin-bottom: 24px; + } + + &__filter { + &-sort { + width: 176px; + } + + &-per-page { + width: 128px; + } + } + + &__grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); + gap: 16px; + row-gap: 40px; + margin-bottom: 40px; + } +} + +.products-page__pagination { + display: flex; + justify-content: center; + margin-top: 40px; +} diff --git a/src/components/pages/ProductsPage/ProductsPage.tsx b/src/components/pages/ProductsPage/ProductsPage.tsx new file mode 100644 index 00000000000..d52783de63f --- /dev/null +++ b/src/components/pages/ProductsPage/ProductsPage.tsx @@ -0,0 +1,92 @@ +import './ProductsPage.scss'; +import React, { useMemo } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { Product } from '../../../types/Product'; +import { Dropdown } from '../../Dropdown'; +import { ProductCard } from '../../ProductCard'; +import { Pagination } from '../../Pagination/Pagination'; + +interface Props { + products: Product[]; + title: string; +} + +const sortOptions = [ + { value: 'age', label: 'Newest' }, + { value: 'title', label: 'Alphabetically' }, + { value: 'price', label: 'Cheapest' }, +]; + +const perPageOptions = [ + { value: 'all', label: 'All' }, + { value: '4', label: '4' }, + { value: '8', label: '8' }, + { value: '16', label: '16' }, +]; + +export const ProductsPage: React.FC = ({ products, title }) => { + const [searchParams] = useSearchParams(); + const sortBy = searchParams.get('sort') || 'age'; + const perPage = searchParams.get('perPage') || 'all'; + const currentPage = Number(searchParams.get('page')) || 1; + + const sortedProducts = useMemo(() => { + const copy = [...products]; + + return copy.sort((a, b) => { + switch (sortBy) { + case 'title': + return a.name.localeCompare(b.name); + case 'price': + return a.price - b.price; + default: + return b.year - a.year; + } + }); + }, [products, sortBy]); + + const itemsToShow = useMemo(() => { + if (perPage === 'all') { + return sortedProducts; + } + + const limit = Number(perPage); + const start = (currentPage - 1) * limit; + const end = start + limit; + + return sortedProducts.slice(start, end); + }, [sortedProducts, perPage, currentPage]); + + return ( +
+

{title}

+

+ {`${sortedProducts.length} models`} +

+ +
+
+ +
+ +
+ +
+
+ +
+ {itemsToShow.map(product => ( + + ))} +
+ + {perPage !== 'all' && ( + + )} +
+ ); +}; diff --git a/src/components/pages/ProductsPage/index.ts b/src/components/pages/ProductsPage/index.ts new file mode 100644 index 00000000000..8e350f20bf9 --- /dev/null +++ b/src/components/pages/ProductsPage/index.ts @@ -0,0 +1 @@ +export * from './ProductsPage'; diff --git a/src/context/CartContext.tsx b/src/context/CartContext.tsx new file mode 100644 index 00000000000..c6a1d8f9429 --- /dev/null +++ b/src/context/CartContext.tsx @@ -0,0 +1,138 @@ +import React, { + createContext, + useContext, + useMemo, + useCallback, + useState, +} from 'react'; +import { Product } from '../types/Product'; +import { useLocalStorage } from '../hooks/useLocalStorage'; + +export interface CartItem extends Product { + quantity: number; +} + +interface CartContextType { + cart: CartItem[]; + totalAmount: number; + totalItems: number; + isCheckout: boolean; + addToCart: (product: Product) => void; + removeFromCart: (productId: number) => void; + changeQuantity: (productId: number, delta: number) => void; + handleCheckout: () => void; +} + +const CartContext = createContext(undefined); + +export const CartProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [cart, setCart] = useLocalStorage('cart', []); + const [isCheckout, setIsCheckout] = useState(false); + + const addToCart = useCallback( + (product: Product) => { + setCart((currentCart: CartItem[]) => { + const existingItem = currentCart.find( + (item: CartItem) => item.id === product.id, + ); + + if (existingItem) { + return currentCart.map((item: CartItem) => { + return item.id === product.id + ? { ...item, quantity: item.quantity + 1 } + : item; + }); + } + + return [...currentCart, { ...product, quantity: 1 }]; + }); + }, + [setCart], + ); + + const removeFromCart = useCallback( + (productId: number) => { + setCart((currentCart: CartItem[]) => { + return currentCart.filter((item: CartItem) => item.id !== productId); + }); + }, + [setCart], + ); + + const changeQuantity = useCallback( + (productId: number, delta: number) => { + setCart((currentCart: CartItem[]) => { + return currentCart.map((item: CartItem) => { + if (item.id === productId) { + const newQuantity = item.quantity + delta; + + return { ...item, quantity: newQuantity > 0 ? newQuantity : 1 }; + } + + return item; + }); + }); + }, + [setCart], + ); + + const handleCheckout = useCallback(() => { + if (cart.length === 0) { + return; + } + + setIsCheckout(true); + setCart([]); + + setTimeout(() => { + setIsCheckout(false); + }, 3000); + }, [cart.length, setCart]); + + const totalAmount = useMemo( + () => cart.reduce((acc, item) => acc + item.price * item.quantity, 0), + [cart], + ); + + const totalItems = useMemo( + () => cart.reduce((acc, item) => acc + item.quantity, 0), + [cart], + ); + + const value = useMemo( + () => ({ + cart, + totalAmount, + totalItems, + isCheckout, + addToCart, + removeFromCart, + changeQuantity, + handleCheckout, + }), + [ + cart, + totalAmount, + totalItems, + isCheckout, + addToCart, + removeFromCart, + changeQuantity, + handleCheckout, + ], + ); + + return {children}; +}; + +export const useCart = () => { + const context = useContext(CartContext); + + if (!context) { + throw new Error('useCart must be used within a CartProvider'); + } + + return context; +}; diff --git a/src/context/FavoritesContext.tsx b/src/context/FavoritesContext.tsx new file mode 100644 index 00000000000..7d0ee5134d9 --- /dev/null +++ b/src/context/FavoritesContext.tsx @@ -0,0 +1,61 @@ +import React, { createContext, useContext, useMemo, useCallback } from 'react'; +import { Product } from '../types/Product'; +import { useLocalStorage } from '../hooks/useLocalStorage'; + +interface FavoritesContextType { + favorites: Product[]; + toggleFavorite: (product: Product) => void; +} + +const FavoritesContext = createContext( + undefined, +); + +export const FavoritesProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [favorites, setFavorites] = useLocalStorage('favorites', []); + + const toggleFavorite = useCallback( + (product: Product) => { + setFavorites((currentFavorites: Product[]) => { + const isFav = currentFavorites.some( + (item: Product) => item.id === product.id, + ); + + if (isFav) { + return currentFavorites.filter( + (item: Product) => item.id !== product.id, + ); + } + + return [...currentFavorites, product]; + }); + }, + [setFavorites], + ); + + const value = useMemo( + () => ({ + favorites, + toggleFavorite, + }), + [favorites, toggleFavorite], + ); + + return ( + + {children} + + ); +}; + +export const useFavorites = () => { + const context = useContext(FavoritesContext); + + if (!context) { + throw new Error('useFavorites must be used within a FavoritesProvider'); + } + + return context; +}; diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts new file mode 100644 index 00000000000..4565e3e3382 --- /dev/null +++ b/src/hooks/useLocalStorage.ts @@ -0,0 +1,22 @@ +import { useState, useEffect, Dispatch, SetStateAction } from 'react'; + +export function useLocalStorage( + key: string, + initialValue: T, +): [T, Dispatch>] { + const [value, setValue] = useState(() => { + try { + const saved = localStorage.getItem(key); + + return saved ? JSON.parse(saved) : initialValue; + } catch { + return initialValue; + } + }); + + useEffect(() => { + localStorage.setItem(key, JSON.stringify(value)); + }, [key, value]); + + return [value, setValue]; +} diff --git a/src/index.tsx b/src/index.tsx index 50470f1508d..37d714b80c5 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,22 @@ -import { createRoot } from 'react-dom/client'; +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { HashRouter as Router } from 'react-router-dom'; import { App } from './App'; +import { FavoritesProvider } from './context/FavoritesContext'; +import { CartProvider } from './context/CartContext'; -createRoot(document.getElementById('root') as HTMLElement).render(); +const root = ReactDOM.createRoot( + document.getElementById('root') as HTMLElement, +); + +root.render( + + + + + + + + + , +); diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts new file mode 100644 index 00000000000..c7238d7e0a7 --- /dev/null +++ b/src/react-app-env.d.ts @@ -0,0 +1,4 @@ +declare module '*.json' { + const value: unknown[]; + export default value; +} diff --git a/src/types/Product.ts b/src/types/Product.ts new file mode 100644 index 00000000000..122619d402c --- /dev/null +++ b/src/types/Product.ts @@ -0,0 +1,16 @@ +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 type SortType = 'age' | 'title' | 'price'; diff --git a/src/types/ProductDetails.ts b/src/types/ProductDetails.ts new file mode 100644 index 00000000000..b7462c8e434 --- /dev/null +++ b/src/types/ProductDetails.ts @@ -0,0 +1,26 @@ +export interface DescriptionSection { + 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: DescriptionSection[]; + screen: string; + resolution: string; + processor: string; + ram: string; + camera: string; + zoom: string; + cell: string[]; +} diff --git a/src/utils/ScrollToTop.tsx b/src/utils/ScrollToTop.tsx new file mode 100644 index 00000000000..4b224efdb0a --- /dev/null +++ b/src/utils/ScrollToTop.tsx @@ -0,0 +1,12 @@ +import { useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; + +export const ScrollToTop = () => { + const { pathname } = useLocation(); + + useEffect(() => { + window.scrollTo(0, 0); + }, [pathname]); + + return null; +}; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 11f02fe2a00..848c2cd9268 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1 +1,6 @@ /// + +declare module '*.json' { + const value: Record[]; + export default value; +} diff --git a/vite.config.ts b/vite.config.ts index 5a33944a9b4..57b0e922991 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,5 +3,6 @@ import react from '@vitejs/plugin-react' // https://vitejs.dev/config/ export default defineConfig({ + base: '/react_phone-catalog/', plugins: [react()], })