diff --git a/index.html b/index.html index 095fb3a4537..c737d6615a4 100644 --- a/index.html +++ b/index.html @@ -1,12 +1,16 @@ - - - - Vite + React + TS - - -
- - - + + + + + + NICE GADGETS + + + +
+ + + + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 836b9e63b46..3feea2a069d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,16 +11,21 @@ "license": "GPL-3.0", "dependencies": { "@fortawesome/fontawesome-free": "^6.5.2", + "@radix-ui/react-select": "^2.2.6", + "@reduxjs/toolkit": "^2.11.2", "bulma": "^1.0.1", "classnames": "^2.5.1", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.25.1", - "react-transition-group": "^4.4.5" + "react-redux": "^9.2.0", + "react-router": "^7.10.1", + "react-router-dom": "^6.30.2", + "react-transition-group": "^4.4.5", + "swiper": "^12.1.1" }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^2.1.2", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", @@ -90,6 +95,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.9.tgz", "integrity": "sha512-5e3FI4Q3M3Pbr21+5xJwCv6ZT6KmGkI0vw3Tozy5ODAQFTIWe37iT8Cr7Ice2Ntb+M3iSKCEWMB1MBgKrW3whg==", "dev": true, + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.24.7", @@ -449,6 +455,7 @@ "url": "https://opencollective.com/csstools" } ], + "peer": true, "engines": { "node": "^14 || ^16 || >=18" }, @@ -471,6 +478,7 @@ "url": "https://opencollective.com/csstools" } ], + "peer": true, "engines": { "node": "^14 || ^16 || >=18" } @@ -1069,6 +1077,40 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz", + "integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==", + "dependencies": { + "@floating-ui/dom": "^1.7.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==" + }, "node_modules/@fortawesome/fontawesome-free": { "version": "6.5.2", "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.5.2.tgz", @@ -1184,9 +1226,9 @@ } }, "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.2", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-2.1.2.tgz", + "integrity": "sha512-gUXFdqqOfYzF9R3RSx2pCa5GLdOkxB9bFbF+dpUpzucdgGAANqOGdqpmNnMj+e3xA9YHraUWq3xo9cwe5vD9pQ==", "dev": true, "dependencies": { "@octokit/rest": "^17.11.2", @@ -1224,15 +1266,13 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-2.0.0.tgz", "integrity": "sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==", - "dev": true, - "peer": true + "dev": true }, "node_modules/@mate-academy/stylelint-config/node_modules/cosmiconfig": { "version": "8.3.6", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", "dev": true, - "peer": true, "dependencies": { "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", @@ -1259,7 +1299,6 @@ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-5.0.1.tgz", "integrity": "sha512-VfxadyCECXgQlkoEAjeghAr5gY3Hf+IKjKb+X8tGVDtveCjN+USwprd2q3QXBR9T1+x2DG0XZF5/w+7HAtSaXA==", "dev": true, - "peer": true, "engines": { "node": ">=10" }, @@ -1272,7 +1311,6 @@ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-7.0.2.tgz", "integrity": "sha512-TfW7/1iI4Cy7Y8L6iqNdZQVvdXn0f8B4QcIXmkIbtTIe/Okm/nSlHb4IwGzRVOd3WfSieCgvf5cMzEfySAIl0g==", "dev": true, - "peer": true, "dependencies": { "flat-cache": "^3.2.0" }, @@ -1291,7 +1329,6 @@ "resolved": "https://registry.npmjs.org/meow/-/meow-10.1.5.tgz", "integrity": "sha512-/d+PQ4GKmGvM9Bee/DPa8z3mXs/pkvJE2KEThngVNOqtmljC6K7NMPxtc2JeZYTmpWb9k/TmxjeL18ez3h7vCw==", "dev": true, - "peer": true, "dependencies": { "@types/minimist": "^1.2.2", "camelcase-keys": "^7.0.0", @@ -1318,7 +1355,6 @@ "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-6.0.0.tgz", "integrity": "sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==", "dev": true, - "peer": true, "engines": { "node": ">=12.0" }, @@ -1335,7 +1371,6 @@ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", "dev": true, - "peer": true, "engines": { "node": ">=8" } @@ -1486,7 +1521,6 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", "dev": true, - "peer": true, "engines": { "node": ">=10" }, @@ -1534,7 +1568,6 @@ "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-5.1.1.tgz", "integrity": "sha512-rh3G3wDO8J9wSjfI436JUKzHIxq8NaiL0tVeB2aXmG6p/9859aUOAjA9pmSPNGGZxfwmaJ9ozOJImuNVJdpvbA==", "dev": true, - "peer": true, "engines": { "node": ">= 18" } @@ -1563,7 +1596,6 @@ "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.1.tgz", "integrity": "sha512-JYjh5rMOwXMJyUpj028cu0Gbp7qe/ihxfJMLc8VZBMMqSwLgOxDI1911gV4Enl1QSavAQNJcwmwBF9M0VvLh6Q==", "dev": true, - "peer": true, "dependencies": { "@octokit/types": "^13.0.0", "universal-user-agent": "^7.0.2" @@ -1577,7 +1609,6 @@ "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-8.1.1.tgz", "integrity": "sha512-ukiRmuHTi6ebQx/HFRCXKbDlOh/7xEV6QUXaE7MJEKGNAncGI/STSbOkl12qVXZrfZdpXctx5O9X1AIaebiDBg==", "dev": true, - "peer": true, "dependencies": { "@octokit/request": "^9.0.0", "@octokit/types": "^13.0.0", @@ -1591,8 +1622,7 @@ "version": "22.2.0", "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-22.2.0.tgz", "integrity": "sha512-QBhVjcUa9W7Wwhm6DBFu6ZZ+1/t/oYxqc2tp81Pi41YNuJinbFRx8B133qVOrAaBbF7D/m0Et6f9/pZt9Rc+tg==", - "dev": true, - "peer": true + "dev": true }, "node_modules/@octokit/plugin-paginate-rest": { "version": "2.21.3", @@ -1654,7 +1684,6 @@ "resolved": "https://registry.npmjs.org/@octokit/request/-/request-9.1.3.tgz", "integrity": "sha512-V+TFhu5fdF3K58rs1pGUJIDH5RZLbZm5BI+MNF+6o/ssFNT4vWlCh/tVpF3NxGtP15HUxTTMUbsG5llAuU2CZA==", "dev": true, - "peer": true, "dependencies": { "@octokit/endpoint": "^10.0.0", "@octokit/request-error": "^6.0.1", @@ -1670,7 +1699,6 @@ "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.4.tgz", "integrity": "sha512-VpAhIUxwhWZQImo/dWAN/NpPqqojR6PSLgLYAituLM6U+ddx9hCioFGwBr5Mi+oi5CLeJkcAs3gJ0PYYzU6wUg==", "dev": true, - "peer": true, "dependencies": { "@octokit/types": "^13.0.0" }, @@ -1858,7 +1886,6 @@ "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.5.0.tgz", "integrity": "sha512-HdqWTf5Z3qwDVlzCrP8UJquMwunpDiMPt5er+QjGzL4hqr/vBVY/MauQgS1xWxCDT1oMx1EULyqxncdCY/NVSQ==", "dev": true, - "peer": true, "dependencies": { "@octokit/openapi-types": "^22.2.0" } @@ -1875,10 +1902,506 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==" + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@remix-run/router": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.18.0.tgz", - "integrity": "sha512-L3jkqmqoSVBVKHfpGZmLrex0lxR5SucGA0sUfFzGctehw+S/ggL9L/0NnC5mw6P8HUWpFZ3nQw3cRApjjWx9Sw==", + "version": "1.23.1", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.1.tgz", + "integrity": "sha512-vDbaOzF7yT2Qs4vO6XV1MHcJv+3dgR1sT+l3B8xxOVhUC336prMvqrvsLL/9Dnw2xr6Qhz4J0dmS0llNAbnUmQ==", "engines": { "node": ">=14.0.0" } @@ -2126,6 +2649,18 @@ "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", "dev": true }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2193,8 +2728,7 @@ "version": "1.2.5", "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", - "dev": true, - "peer": true + "dev": true }, "node_modules/@types/node": { "version": "20.14.10", @@ -2209,20 +2743,20 @@ "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", - "dev": true, - "peer": true + "dev": true }, "node_modules/@types/prop-types": { "version": "15.7.12", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", - "dev": true + "devOptional": true }, "node_modules/@types/react": { "version": "18.3.3", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz", "integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==", - "dev": true, + "devOptional": true, + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -2232,7 +2766,8 @@ "version": "18.3.0", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz", "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==", - "dev": true, + "devOptional": true, + "peer": true, "dependencies": { "@types/react": "*" } @@ -2258,6 +2793,12 @@ "integrity": "sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg==", "dev": true }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -2307,6 +2848,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.16.0.tgz", "integrity": "sha512-ar9E+k7CU8rWi2e5ErzQiC93KKEFAXA2Kky0scAlPcxYblLt8+XZuHUZwlyfXILyQa95P6lQg+eZgh/dDs3+Vw==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.16.0", "@typescript-eslint/types": "7.16.0", @@ -2352,7 +2894,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.16.0.tgz", "integrity": "sha512-j0fuUswUjDHfqV/UdW6mLtOQQseORqfdmoBNDFOqs9rvNVR2e+cmu6zJu/Ku4SDuqiJko6YnhwcL8x45r8Oqxg==", "dev": true, - "peer": true, "dependencies": { "@typescript-eslint/typescript-estree": "7.16.0", "@typescript-eslint/utils": "7.16.0", @@ -2421,7 +2962,6 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.16.0.tgz", "integrity": "sha512-PqP4kP3hb4r7Jav+NiRCntlVzhxBNWq6ZQ+zQwII1y/G/1gdIPeYDCKr2+dH6049yJQsWZiHU6RlwvIFBXXGNA==", "dev": true, - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@typescript-eslint/scope-manager": "7.16.0", @@ -2486,6 +3026,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2615,6 +3156,17 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/aria-query": { "version": "5.1.3", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", @@ -2809,7 +3361,6 @@ "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", "dev": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -2955,8 +3506,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-3.0.2.tgz", "integrity": "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==", - "dev": true, - "peer": true + "dev": true }, "node_modules/binary-extensions": { "version": "2.3.0", @@ -3007,8 +3557,7 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", - "dev": true, - "peer": true + "dev": true }, "node_modules/browserslist": { "version": "4.23.2", @@ -3029,6 +3578,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001640", "electron-to-chromium": "^1.4.820", @@ -3122,7 +3672,6 @@ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", "dev": true, - "peer": true, "engines": { "node": ">=10" }, @@ -3135,7 +3684,6 @@ "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-7.0.2.tgz", "integrity": "sha512-Rjs1H+A9R+Ig+4E/9oyB66UC5Mj9Xq3N//vcLf2WzgdTi/3gUu3Z9KoqmlrEG4VuuLK8wJHofxzdQXz/knhiYg==", "dev": true, - "peer": true, "dependencies": { "camelcase": "^6.3.0", "map-obj": "^4.1.0", @@ -3154,7 +3702,6 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", "dev": true, - "peer": true, "engines": { "node": ">=10" }, @@ -3324,7 +3871,6 @@ "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", "dev": true, - "peer": true, "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", @@ -3412,6 +3958,18 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", @@ -3521,6 +4079,7 @@ "integrity": "sha512-ou/MQUDq4tcDJI2FsPaod2FZpex4kpIK43JJlcBgWrX8WX7R/05ZxGTuxedOuZBfxjZxja+fbijZGyxiLP6CFA==", "dev": true, "hasInstallScript": true, + "peer": true, "dependencies": { "@cypress/request": "^3.0.0", "@cypress/xvfb": "^1.2.4", @@ -3772,7 +4331,6 @@ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", "dev": true, - "peer": true, "engines": { "node": ">=10" }, @@ -3785,7 +4343,6 @@ "resolved": "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.1.tgz", "integrity": "sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==", "dev": true, - "peer": true, "dependencies": { "decamelize": "^1.1.0", "map-obj": "^1.0.0" @@ -3802,7 +4359,6 @@ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", "dev": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3812,7 +4368,6 @@ "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==", "dev": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3904,6 +4459,11 @@ "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==", "dev": true }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==" + }, "node_modules/diff": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", @@ -3997,6 +4557,7 @@ "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", "dev": true, + "peer": true, "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" @@ -4268,6 +4829,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -4365,6 +4927,7 @@ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", "dev": true, + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -4462,6 +5025,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz", "integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==", "dev": true, + "peer": true, "dependencies": { "array-includes": "^3.1.7", "array.prototype.findlastindex": "^1.2.3", @@ -5209,7 +5773,6 @@ "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", "dev": true, - "peer": true, "bin": { "flat": "cli.js" } @@ -5380,6 +5943,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "engines": { + "node": ">=6" + } + }, "node_modules/get-port": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz", @@ -5564,7 +6135,6 @@ "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, - "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -5596,7 +6166,6 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", "dev": true, - "peer": true, "dependencies": { "brace-expansion": "^2.0.1" }, @@ -5743,7 +6312,6 @@ "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", "integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==", "dev": true, - "peer": true, "engines": { "node": ">=6" } @@ -5834,7 +6402,6 @@ "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", "dev": true, - "peer": true, "bin": { "he": "bin/he" } @@ -5844,7 +6411,6 @@ "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", "dev": true, - "peer": true, "dependencies": { "lru-cache": "^6.0.0" }, @@ -5857,7 +6423,6 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "dev": true, - "peer": true, "dependencies": { "yallist": "^4.0.0" }, @@ -5869,8 +6434,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "peer": true + "dev": true }, "node_modules/html-tags": { "version": "3.3.1", @@ -5936,6 +6500,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/immutable": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.6.tgz", @@ -5963,7 +6537,6 @@ "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz", "integrity": "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==", "dev": true, - "peer": true, "engines": { "node": ">=8" } @@ -6338,7 +6911,6 @@ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", "dev": true, - "peer": true, "engines": { "node": ">=8" } @@ -7079,7 +7651,6 @@ "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", "dev": true, - "peer": true, "engines": { "node": ">=8" }, @@ -7178,7 +7749,6 @@ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", "dev": true, - "peer": true, "engines": { "node": ">=4" } @@ -7212,7 +7782,6 @@ "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==", "dev": true, - "peer": true, "dependencies": { "arrify": "^1.0.1", "is-plain-obj": "^1.1.0", @@ -7227,7 +7796,6 @@ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", "dev": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7237,7 +7805,6 @@ "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.6.0.tgz", "integrity": "sha512-hxjt4+EEB0SA0ZDygSS015t65lJw/I2yRCS3Ae+SJ5FrbzrXgfYwJr96f0OvIXdj7h4lv/vLCrH3rkiuizFSvw==", "dev": true, - "peer": true, "dependencies": { "ansi-colors": "^4.1.3", "browser-stdout": "^1.3.1", @@ -7273,7 +7840,6 @@ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, - "peer": true, "engines": { "node": ">=10" }, @@ -7286,7 +7852,6 @@ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, - "peer": true, "engines": { "node": ">=8" } @@ -7296,7 +7861,6 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", "dev": true, - "peer": true, "dependencies": { "brace-expansion": "^2.0.1" }, @@ -7308,15 +7872,13 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "peer": true + "dev": true }, "node_modules/mocha/node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -7907,7 +8469,6 @@ "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", "integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==", "dev": true, - "peer": true, "dependencies": { "hosted-git-info": "^4.0.1", "is-core-module": "^2.5.0", @@ -8452,6 +9013,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.0.1", @@ -8530,6 +9092,7 @@ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.1.tgz", "integrity": "sha512-b4dlw/9V8A71rLIDsSwVmak9z2DuBUB7CA1/wSdelNEzqsjoSPeADTWNO09lpH49Diy3/JIZ2bSPB1dI3LJCHg==", "dev": true, + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -8558,6 +9121,7 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", "dev": true, + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -8688,7 +9252,6 @@ "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", "dev": true, - "peer": true, "engines": { "node": ">=10" }, @@ -8701,7 +9264,6 @@ "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", "dev": true, - "peer": true, "dependencies": { "safe-buffer": "^5.1.0" } @@ -8710,6 +9272,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -8721,6 +9284,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -8734,6 +9298,30 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", @@ -8743,27 +9331,79 @@ "node": ">=0.10.0" } }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "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": "7.10.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.10.1.tgz", + "integrity": "sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==", "dependencies": { - "@remix-run/router": "1.18.0" + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" }, "peerDependencies": { - "react": ">=16.8" + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } } }, "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.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.2.tgz", + "integrity": "sha512-l2OwHn3UUnEVUqc6/1VMmR1cvZryZ3j3NzapC2eUXO1dB0sYp5mvwdjiXhpUbRb21eFow3qSxpP8Yv6oAU824Q==", "dependencies": { - "@remix-run/router": "1.18.0", - "react-router": "6.25.1" + "@remix-run/router": "1.23.1", + "react-router": "6.30.2" }, "engines": { "node": ">=14.0.0" @@ -8773,6 +9413,41 @@ "react-dom": ">=16.8" } }, + "node_modules/react-router-dom/node_modules/react-router": { + "version": "6.30.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.2.tgz", + "integrity": "sha512-H2Bm38Zu1bm8KUE5NVWRMzuIyAV8p/JrOaBJAwVmp37AXG72+CZJlEBw6pdn9i5TBgLMhNDgijS4ZlblpHyWTA==", + "dependencies": { + "@remix-run/router": "1.23.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -8793,7 +9468,6 @@ "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-6.0.0.tgz", "integrity": "sha512-X1Fu3dPuk/8ZLsMhEj5f4wFAF0DWoK7qhGJvgaijocXxBmSToKfbFtqbxMO7bVjNA1dmE5huAzjXj/ey86iw9Q==", "dev": true, - "peer": true, "dependencies": { "@types/normalize-package-data": "^2.4.0", "normalize-package-data": "^3.0.2", @@ -8812,7 +9486,6 @@ "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-8.0.0.tgz", "integrity": "sha512-snVCqPczksT0HS2EC+SxUndvSzn6LRCwpfSvLrIfR5BKDQQZMaI6jPRC9dYvYFDRAuFEAnkwww8kBBNE/3VvzQ==", "dev": true, - "peer": true, "dependencies": { "find-up": "^5.0.0", "read-pkg": "^6.0.0", @@ -8830,7 +9503,6 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", "dev": true, - "peer": true, "engines": { "node": ">=10" }, @@ -8843,7 +9515,6 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", "dev": true, - "peer": true, "engines": { "node": ">=10" }, @@ -8868,7 +9539,6 @@ "resolved": "https://registry.npmjs.org/redent/-/redent-4.0.0.tgz", "integrity": "sha512-tYkDkVVtYkSVhuQ4zBgfvciymHaeuel+zFKXShfDnFP5SyVEP7qo70Rf1jTOTCx3vGNAbnEi/xFkcfQVMIBWag==", "dev": true, - "peer": true, "dependencies": { "indent-string": "^5.0.0", "strip-indent": "^4.0.0" @@ -8885,7 +9555,6 @@ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", "dev": true, - "peer": true, "engines": { "node": ">=12" }, @@ -8893,6 +9562,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT", + "peer": true + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.6.tgz", @@ -8976,6 +9661,12 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "dev": true }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -9223,6 +9914,7 @@ "resolved": "https://registry.npmjs.org/sass/-/sass-1.77.8.tgz", "integrity": "sha512-4UHg6prsrycW20fqLGPShtEvo/WyHRVRHwOP4DzkUrObWoWI05QBSfzU71TVB7PFaL104TwNaHpjlWXAZbQiNQ==", "dev": true, + "peer": true, "dependencies": { "chokidar": ">=3.0.0 <4.0.0", "immutable": "^4.0.0", @@ -9260,7 +9952,6 @@ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", "dev": true, - "peer": true, "dependencies": { "randombytes": "^2.1.0" } @@ -9271,6 +9962,11 @@ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "dev": true }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -9467,7 +10163,6 @@ "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", "dev": true, - "peer": true, "dependencies": { "spdx-expression-parse": "^3.0.0", "spdx-license-ids": "^3.0.0" @@ -9477,15 +10172,13 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", - "dev": true, - "peer": true + "dev": true }, "node_modules/spdx-expression-parse": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", "dev": true, - "peer": true, "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" @@ -9495,8 +10188,7 @@ "version": "3.0.18", "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.18.tgz", "integrity": "sha512-xxRs31BqRYHwiMzudOrpSiHtZ8i/GeionCBDSilhYRj+9gIcI8wCZTlXZKu9vZIVqViP3dcp9qE5G6AlIaD+TQ==", - "dev": true, - "peer": true + "dev": true }, "node_modules/sshpk": { "version": "1.18.0", @@ -9694,7 +10386,6 @@ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.0.0.tgz", "integrity": "sha512-mnVSV2l+Zv6BLpSD/8V87CW/y9EmmbYzGCIavsnsI6/nwn26DwffM/yztm30Z/I2DY9wdS3vXVCMnHDgZaVNoA==", "dev": true, - "peer": true, "dependencies": { "min-indent": "^1.0.1" }, @@ -9733,8 +10424,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/style-search/-/style-search-0.1.0.tgz", "integrity": "sha512-Dj1Okke1C3uKKwQcetra4jSuk0DqbzbYtXipzFlFMZtowbF1x7BKJwB9AayVMyFARvU8EDrZdcax4At/452cAg==", - "dev": true, - "peer": true + "dev": true }, "node_modules/stylelint": { "version": "16.7.0", @@ -9930,6 +10620,25 @@ "integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==", "dev": true }, + "node_modules/swiper": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/swiper/-/swiper-12.1.1.tgz", + "integrity": "sha512-NB8Uvpu6m725Xf68l2hZBHD184v/ZAi6WIvAorDuR3gRuwCWbGSam233/8seeeIrMJ9isHGk/CTKFdpgeT5u2Q==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/swiperjs" + }, + { + "type": "open_collective", + "url": "http://opencollective.com/swiper" + } + ], + "license": "MIT", + "engines": { + "node": ">= 4.7.0" + } + }, "node_modules/synckit": { "version": "0.8.8", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.8.tgz", @@ -10144,7 +10853,6 @@ "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-4.1.1.tgz", "integrity": "sha512-jRKj0n0jXWo6kh62nA5TEh3+4igKDXLvzBJcPpiizP7oOolUrYIxmVBG9TOtHYFHoddUk6YvAkGeGoSVTXfQXQ==", "dev": true, - "peer": true, "engines": { "node": ">=12" }, @@ -10203,8 +10911,7 @@ "node_modules/tslib": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", - "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", - "dev": true + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" }, "node_modules/tunnel-agent": { "version": "0.6.0", @@ -10335,6 +11042,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10368,8 +11076,7 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.2.tgz", "integrity": "sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q==", - "dev": true, - "peer": true + "dev": true }, "node_modules/universalify": { "version": "2.0.1", @@ -10438,6 +11145,56 @@ "requires-port": "^1.0.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -10458,7 +11215,6 @@ "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", "dev": true, - "peer": true, "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" @@ -10492,6 +11248,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.3.tgz", "integrity": "sha512-NPQdeCU0Dv2z5fu+ULotpuq5yfCS1BzKUIPhNbP3YBfAMGJXbt2nS+sbTFu+qchaqWTD+H3JK++nRwr6XIcp6A==", "dev": true, + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.39", @@ -10804,8 +11561,7 @@ "version": "6.5.1", "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz", "integrity": "sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==", - "dev": true, - "peer": true + "dev": true }, "node_modules/wrap-ansi": { "version": "7.0.0", @@ -10908,7 +11664,6 @@ "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", "dev": true, - "peer": true, "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", @@ -10927,7 +11682,6 @@ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", "dev": true, - "peer": true, "engines": { "node": ">=10" } @@ -10937,7 +11691,6 @@ "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", "dev": true, - "peer": true, "dependencies": { "camelcase": "^6.0.0", "decamelize": "^4.0.0", diff --git a/package.json b/package.json index ae251685c8b..a7f723f972f 100644 --- a/package.json +++ b/package.json @@ -7,16 +7,21 @@ "license": "GPL-3.0", "dependencies": { "@fortawesome/fontawesome-free": "^6.5.2", + "@radix-ui/react-select": "^2.2.6", + "@reduxjs/toolkit": "^2.11.2", "bulma": "^1.0.1", "classnames": "^2.5.1", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.25.1", - "react-transition-group": "^4.4.5" + "react-redux": "^9.2.0", + "react-router": "^7.10.1", + "react-router-dom": "^6.30.2", + "react-transition-group": "^4.4.5", + "swiper": "^12.1.1" }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^2.1.2", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", diff --git a/public/img/banner-accessories.png b/public/img/banner-accessories.png index ba41c4e8f0d..8f91ca6e155 100644 Binary files a/public/img/banner-accessories.png and b/public/img/banner-accessories.png differ diff --git a/public/img/banner-phones.png b/public/img/banner-phones.png index c8fea5b6ee9..cc854f67397 100644 Binary files a/public/img/banner-phones.png and b/public/img/banner-phones.png differ diff --git a/public/img/banner-tablets.png b/public/img/banner-tablets.png index d8079734bcd..dfefc12720f 100644 Binary files a/public/img/banner-tablets.png and b/public/img/banner-tablets.png differ diff --git a/public/img/banner.webp b/public/img/banner.webp new file mode 100644 index 00000000000..3a11d111351 Binary files /dev/null and b/public/img/banner.webp differ diff --git a/public/img/banner01.webp b/public/img/banner01.webp new file mode 100644 index 00000000000..b857c12dcef Binary files /dev/null and b/public/img/banner01.webp differ diff --git a/public/img/banner02.webp b/public/img/banner02.webp new file mode 100644 index 00000000000..1eabb88b110 Binary files /dev/null and b/public/img/banner02.webp differ diff --git a/public/img/cart.svg b/public/img/cart.svg new file mode 100644 index 00000000000..380aed0a0a0 --- /dev/null +++ b/public/img/cart.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/img/category-accessories.png b/public/img/category-accessories.png index 67c5bfdb35b..ebc363c806f 100644 Binary files a/public/img/category-accessories.png and b/public/img/category-accessories.png differ diff --git a/public/img/category-phones.png b/public/img/category-phones.png index fd7616042f2..d02b4549ea6 100644 Binary files a/public/img/category-phones.png and b/public/img/category-phones.png differ diff --git a/public/img/category-tablets.png b/public/img/category-tablets.png index 57e33c5807e..981d07e52e3 100644 Binary files a/public/img/category-tablets.png and b/public/img/category-tablets.png differ diff --git a/public/img/favourites.svg b/public/img/favourites.svg new file mode 100644 index 00000000000..8caddd8d94d --- /dev/null +++ b/public/img/favourites.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/favouritesActive.svg b/public/img/favouritesActive.svg new file mode 100644 index 00000000000..be5c1fc9943 --- /dev/null +++ b/public/img/favouritesActive.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/Close.svg b/public/img/icons/Close.svg new file mode 100644 index 00000000000..925e5fce497 --- /dev/null +++ b/public/img/icons/Close.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/arrowBottom.svg b/public/img/icons/arrowBottom.svg new file mode 100644 index 00000000000..241b27ea859 --- /dev/null +++ b/public/img/icons/arrowBottom.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/arrowLeft.svg b/public/img/icons/arrowLeft.svg new file mode 100644 index 00000000000..055df43d010 --- /dev/null +++ b/public/img/icons/arrowLeft.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/arrowLeftWhite.svg b/public/img/icons/arrowLeftWhite.svg new file mode 100644 index 00000000000..e686c1ac063 --- /dev/null +++ b/public/img/icons/arrowLeftWhite.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/arrowRight.svg b/public/img/icons/arrowRight.svg new file mode 100644 index 00000000000..6e938ce2452 --- /dev/null +++ b/public/img/icons/arrowRight.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/arrowRightWhite.svg b/public/img/icons/arrowRightWhite.svg new file mode 100644 index 00000000000..4ecd8405960 --- /dev/null +++ b/public/img/icons/arrowRightWhite.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/arrowTop.svg b/public/img/icons/arrowTop.svg new file mode 100644 index 00000000000..e5b42a9df74 --- /dev/null +++ b/public/img/icons/arrowTop.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/arrowTopWhite.svg b/public/img/icons/arrowTopWhite.svg new file mode 100644 index 00000000000..7b4feaf0176 --- /dev/null +++ b/public/img/icons/arrowTopWhite.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/delete.png b/public/img/icons/delete.png new file mode 100644 index 00000000000..d16cfc5cb4a Binary files /dev/null and b/public/img/icons/delete.png differ diff --git a/public/img/icons/favicon.ico b/public/img/icons/favicon.ico new file mode 100644 index 00000000000..8a18de7ea7e Binary files /dev/null and b/public/img/icons/favicon.ico differ diff --git a/public/img/icons/menu.png b/public/img/icons/menu.png new file mode 100644 index 00000000000..22606e22ca1 Binary files /dev/null and b/public/img/icons/menu.png differ diff --git a/public/img/logo.svg b/public/img/logo.svg new file mode 100644 index 00000000000..fab0b583064 --- /dev/null +++ b/public/img/logo.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/img/picthree.bdd2e0fc.png b/public/img/picthree.bdd2e0fc.png deleted file mode 100644 index 28b5c4c99a6..00000000000 Binary files a/public/img/picthree.bdd2e0fc.png and /dev/null differ diff --git a/src/App.scss b/src/App.scss deleted file mode 100644 index 71bc413aade..00000000000 --- a/src/App.scss +++ /dev/null @@ -1 +0,0 @@ -// not empty diff --git a/src/App.tsx b/src/App.tsx index 372e4b42066..791e8576e1d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,29 @@ -import './App.scss'; +import { Outlet, ScrollRestoration } from 'react-router-dom'; +import { Header } from './modules/shared/components/Header'; +import { Footer } from './modules/shared/components/Footer/Footer'; +import { useEffect } from 'react'; +import { useAppSelector } from './app/hooks'; +import './styles/global.scss'; -export const App = () => ( -
-

Product Catalog

-
-); +export const App = () => { + const favorites = useAppSelector(state => state.favorites); + const addedToCart = useAppSelector(state => state.cart); + + useEffect(() => { + localStorage.setItem('favorites', JSON.stringify(favorites)); + localStorage.setItem('cart', JSON.stringify(addedToCart)); + }, [favorites, addedToCart]); + + return ( + <> + +
+
+
+ +
+
+
+ + ); +}; diff --git a/src/app/hooks.ts b/src/app/hooks.ts new file mode 100644 index 00000000000..e6b426b371e --- /dev/null +++ b/src/app/hooks.ts @@ -0,0 +1,8 @@ +import { TypedUseSelectorHook, useSelector } from 'react-redux'; +import { useDispatch } from 'react-redux'; +import type { RootState } from './store'; +import type { AppDispatch } from './store'; + +export const useAppSelector: TypedUseSelectorHook = useSelector; + +export const useAppDispatch = () => useDispatch(); diff --git a/src/app/store.ts b/src/app/store.ts new file mode 100644 index 00000000000..4017dc6c44d --- /dev/null +++ b/src/app/store.ts @@ -0,0 +1,13 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { favoritesSlice } from '../features/favorite/favoritesSlice'; +import { cartSlice } from '../features/cart/cartSlice'; + +export const store = configureStore({ + reducer: { + favorites: favoritesSlice.reducer, + cart: cartSlice.reducer, + }, +}); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; diff --git a/src/features/cart/cartSlice.ts b/src/features/cart/cartSlice.ts new file mode 100644 index 00000000000..e48c5c2d5d3 --- /dev/null +++ b/src/features/cart/cartSlice.ts @@ -0,0 +1,52 @@ +import { createSlice } from '@reduxjs/toolkit'; + +type InitialState = { + id: string; + title: string; + img: string; + price: string; + count: number; +}; + +const initialState: InitialState[] = + JSON.parse(localStorage.getItem('cart')) !== null + ? JSON.parse(localStorage.getItem('cart')) + : []; + +export const cartSlice = createSlice({ + name: 'cart', + initialState: initialState, + reducers: { + addProductToCart: (state, action) => { + const exists = state.find(product => product.id === action.payload.id); + + if (!exists) { + state.push(action.payload); + } + }, + removeProductFromCart: (state, action) => { + return state.filter(product => product.id !== action.payload.id); + }, + + removeProduct: (state, action) => { + return state.filter(product => product.id !== action.payload); + }, + clearCart: state => { + return []; + }, + plusCount: (state, action) => { + state.map(product => { + if (product.id === action.payload) { + product.count += 1; + } + }); + }, + minusCount: (state, action) => { + state.map(product => { + if (product.id === action.payload) { + product.count -= 1; + } + }); + }, + }, +}); diff --git a/src/features/favorite/favoritesSlice.ts b/src/features/favorite/favoritesSlice.ts new file mode 100644 index 00000000000..37d7017c8eb --- /dev/null +++ b/src/features/favorite/favoritesSlice.ts @@ -0,0 +1,17 @@ +import { createSlice } from '@reduxjs/toolkit'; + +export const favoritesSlice = createSlice({ + name: 'favorites', + initialState: + JSON.parse(localStorage.getItem('favorites')) !== null + ? JSON.parse(localStorage.getItem('favorites')) + : [], + reducers: { + addProduct: (state, action) => { + state.push(action.payload); + }, + removeProduct: (state, action) => { + return state.filter(id => id !== action.payload); + }, + }, +}); diff --git a/src/index.tsx b/src/index.tsx index 50470f1508d..bc7ee7cf505 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,83 @@ +import './styles/reset.scss'; +import './styles/fonts.scss'; +import './styles/variables.scss'; +import './styles/global.scss'; import { createRoot } from 'react-dom/client'; + +import { createHashRouter } from 'react-router-dom'; +import { RouterProvider } from 'react-router-dom'; +import { HomePage } from './modules/HomePage'; +import { CatalogPage } from './modules/CatalogPage'; +// import { phones } from './../public/api/phones.json'; +import products from './../public/api/products.json'; import { App } from './App'; +import { ProductPage } from './modules/ProductPage'; +import { FavoritesPage } from './modules/FavoritesPage'; +import { Provider } from 'react-redux'; +import { store } from './app/store'; +import { CartPage } from './modules/CartPage'; + +const filteredProducts = (category: string) => { + return products.filter(product => product.category === category); +}; + +const router = createHashRouter([ + { + path: '/', + element: , + children: [ + { + index: true, + element: , + }, + { + path: 'phones', + element: ( + + ), + }, + { + path: 'tablets', + element: ( + + ), + }, + { + path: 'accessories', + element: ( + + ), + }, + { + path: 'product/:productId', + element: , + }, + { + path: 'favorites', + element: , + }, + { + path: 'cart', + element: , + }, + ], + }, +]); -createRoot(document.getElementById('root') as HTMLElement).render(); +createRoot(document.getElementById('root') as HTMLElement).render( + + + , +); diff --git a/src/modules/CartPage/CartPage.tsx b/src/modules/CartPage/CartPage.tsx new file mode 100644 index 00000000000..3892af9fa0b --- /dev/null +++ b/src/modules/CartPage/CartPage.tsx @@ -0,0 +1,32 @@ +import { FC } from 'react'; +import { useAppSelector } from '../../app/hooks'; +import { CatalogProducts } from '../shared/components/CatalogProducts'; +import { PageContainer } from '../shared/components/PageContainer'; +import { Path } from '../shared/components/Path'; +import { Title } from '../shared/components/Title'; +import styles from './CartPage.module.scss'; +import { Product } from '../../types/Product'; + +type Props = { + products: Product[]; +}; + +export const CartPage: FC = ({ products }) => { + const addedProducts = useAppSelector(state => state.cart); + + const visibleProducts = products.filter(product => + addedProducts.some(item => item.id === product.itemId), + ); + + return ( + + + + {visibleProducts.length > 0 ? ( + <CatalogProducts cart={true} visibleProducts={visibleProducts} /> + ) : ( + <h2 className="message">Your cart is empty</h2> + )} + </PageContainer> + ); +}; diff --git a/src/modules/CartPage/components/ProductCardForCart/ProductCardForCart.module.scss b/src/modules/CartPage/components/ProductCardForCart/ProductCardForCart.module.scss new file mode 100644 index 00000000000..b802e8709ed --- /dev/null +++ b/src/modules/CartPage/components/ProductCardForCart/ProductCardForCart.module.scss @@ -0,0 +1,113 @@ +@use './../../../../styles/variables.scss' as *; + +.card__cart { + display: flex; + gap: 16px; +} + +.wrapper { + display: flex; + width: 100%; + gap: 20px; + justify-content: space-between; + align-items: center; +} + +.card__content__cart { + display: flex; + justify-content: space-between; + gap: 24px; + align-items: center; + padding: 24px; + + width: 100%; + + background-color: $secondary__background-color; +} + +.delete__cart { + font-size: 16px; + color: $secondary__element-color; + cursor: pointer; +} + +.card__img__cart img { + height: 80px; + width: 80px; + object-fit: contain; +} + +.quantity { + display: flex; +} + +.card__title__cart { + max-width: 300px; + + font-family: 'Mont', sans-serif; + color: $primary__text-color; + font-size: 14px; + line-height: 21px; + font-weight: 600; + + transition: all 0.3s ease; +} + +.card__title__cart:hover { + color: $accent__color-hover; +} + +.card__price__cart { + width: 100px; + display: flex; + justify-content: center; + align-items: center; + + color: $primary__text-color; + font-size: 22px; + line-height: 140%; + font-weight: 800; +} + +.minus, +.plus { + width: 32px; + height: 32px; + border: 1px solid $secondary__element-color; + color: $primary__text-color; + background-color: $secondary__element-color; + + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + user-select: none; +} +.minus__disabled { + width: 32px; + height: 32px; + border: 1px solid $secondary__element-color; + color: $primary__text-color; + + display: flex; + justify-content: center; + align-items: center; +} + +.count { + width: 50px; + height: 32px; + color: $primary__text-color; + + user-select: none; + + display: flex; + justify-content: center; + align-items: center; +} + +@media (max-width: 639px) { + .card__content__cart { + flex-direction: column; + } +} diff --git a/src/modules/CartPage/components/ProductCardForCart/ProductCardForCart.tsx b/src/modules/CartPage/components/ProductCardForCart/ProductCardForCart.tsx new file mode 100644 index 00000000000..6d10e579dd7 --- /dev/null +++ b/src/modules/CartPage/components/ProductCardForCart/ProductCardForCart.tsx @@ -0,0 +1,77 @@ +import { FC } from 'react'; +import styles from './ProductCardForCart.module.scss'; +import { Link } from 'react-router-dom'; +import { useAppDispatch, useAppSelector } from '../../../../app/hooks'; +import { cartSlice } from '../../../../features/cart/cartSlice'; + +type Props = { + title: string; + img: string; + currentPrice: number; + productId: string; +}; + +export const ProductCardForCart: FC<Props> = ({ + title, + img, + currentPrice, + productId, +}) => { + const dispatch = useAppDispatch(); + + const productInCart = useAppSelector(state => + state.cart.find(item => item.id === productId), + ); + + const count = productInCart ? productInCart.count : 1; + const price = productInCart ? productInCart.price : currentPrice; + + return ( + <article className={styles.card__cart}> + <div className={styles.card__content__cart}> + <div className={styles.wrapper}> + <button + onClick={() => { + dispatch(cartSlice.actions.removeProduct(productId)); + }} + className={styles.delete__cart} + > + ✕ + </button> + + <Link to={`/product/${productId}`} className={styles.card__img__cart}> + <img loading="lazy" src={img} alt="product image" /> + </Link> + <Link to={`/product/${productId}`}> + <h4 className={styles.card__title__cart}>{title}</h4> + </Link> + </div> + <div className={styles.wrapper}> + <div className={styles.quantity}> + <div + onClick={() => { + if (count > 1) { + dispatch(cartSlice.actions.minusCount(productId)); + } + }} + className={count === 1 ? styles.minus__disabled : styles.minus} + > + - + </div> + <div className={styles.count}>{count}</div> + <div + onClick={() => { + dispatch(cartSlice.actions.plusCount(productId)); + }} + className={styles.plus} + > + + + </div> + </div> + + <p className={styles.card__price__cart}>${Number(price) * count}</p> + </div> + </div> + </article> + ); +}; diff --git a/src/modules/CartPage/components/ProductCardForCart/index.ts b/src/modules/CartPage/components/ProductCardForCart/index.ts new file mode 100644 index 00000000000..3e387cb5cb3 --- /dev/null +++ b/src/modules/CartPage/components/ProductCardForCart/index.ts @@ -0,0 +1 @@ +export { ProductCardForCart } from './ProductCardForCart'; diff --git a/src/modules/CartPage/components/TotalAmount/TotalAmount.module.scss b/src/modules/CartPage/components/TotalAmount/TotalAmount.module.scss new file mode 100644 index 00000000000..b70d828f3c1 --- /dev/null +++ b/src/modules/CartPage/components/TotalAmount/TotalAmount.module.scss @@ -0,0 +1,54 @@ +@use './../../../../styles/variables.scss' as *; +.total__amount { + padding: 25px; + border: 1px solid $secondary__element-color; + max-height: 210px; + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 23px; +} + +.price { + font-weight: 800; + color: $primary__text-color; + font-size: 32px; + line-height: 41px; + letter-spacing: -1%; +} + +.desc { + color: $secondary__element-color; + font-size: 14px; + line-height: 21px; +} + +.line { + width: 100%; + height: 1px; + background-color: $secondary__element-color; +} + +.btn { + width: 320px; + background-color: $accent__color; + padding: 14px; + color: $primary__text-color; + font-weight: 700; + font-size: 14px; + line-height: 21px; + + transition: all 0.3s ease; +} + +.btn:hover { + background-color: $accent__color-hover; +} + +@media (max-width: 1199px) { + .btn { + width: 100%; + } +} diff --git a/src/modules/CartPage/components/TotalAmount/TotalAmount.tsx b/src/modules/CartPage/components/TotalAmount/TotalAmount.tsx new file mode 100644 index 00000000000..7d6cdf58c7a --- /dev/null +++ b/src/modules/CartPage/components/TotalAmount/TotalAmount.tsx @@ -0,0 +1,44 @@ +import { FC } from 'react'; +import styles from './TotalAmount.module.scss'; +import { Product } from '../../../../types/Product'; +import { useAppDispatch, useAppSelector } from '../../../../app/hooks'; +import { cartSlice } from '../../../../features/cart/cartSlice'; + +type Props = { + products: Product[]; +}; + +export const TotalAmount: FC<Props> = ({ products }) => { + const dispatch = useAppDispatch(); + + const cartProducts = useAppSelector(state => state.cart); + + const count = cartProducts.reduce((accum, curr) => accum + curr.count, 0); + + const totalAmount = useAppSelector(state => + state.cart.reduce((sum, item) => sum + item.price * item.count, 0), + ); + + const handleCheckout = () => { + const isConfirmed = window.confirm( + 'Checkout is not implemented yet. Do you want to clear the Cart?', + ); + + if (isConfirmed) { + dispatch(cartSlice.actions.clearCart()); + } + }; + + return ( + <div className={styles.total__amount}> + <div className={styles.main__desc}> + <h2 className={styles.price}>{`$${totalAmount}`}</h2> + <div className={styles.desc}>{`total for ${count} items`}</div> + </div> + <div className={styles.line}> </div> + <button onClick={handleCheckout} className={styles.btn}> + Checkout + </button> + </div> + ); +}; diff --git a/src/modules/CartPage/components/TotalAmount/index.ts b/src/modules/CartPage/components/TotalAmount/index.ts new file mode 100644 index 00000000000..b19eebb2f70 --- /dev/null +++ b/src/modules/CartPage/components/TotalAmount/index.ts @@ -0,0 +1 @@ +export { TotalAmount } from './TotalAmount'; diff --git a/src/modules/CartPage/index.ts b/src/modules/CartPage/index.ts new file mode 100644 index 00000000000..203fb0ea4bd --- /dev/null +++ b/src/modules/CartPage/index.ts @@ -0,0 +1 @@ +export { CartPage } from './CartPage'; diff --git a/src/modules/CatalogPage/CatalogPage.tsx b/src/modules/CatalogPage/CatalogPage.tsx new file mode 100644 index 00000000000..09af4a06a74 --- /dev/null +++ b/src/modules/CatalogPage/CatalogPage.tsx @@ -0,0 +1,64 @@ +import { FC } from 'react'; + +import { Title } from '../shared/components/Title'; +import { CatalogOptions } from './components/CatalogOptions'; +import { Product } from '../../types/Product'; +import { CatalogPagination } from './components/CatalogPagination'; +import { CatalogProducts } from '../shared/components/CatalogProducts'; +import { useCatalog } from './hooks'; +import { PageContainer } from '../shared/components/PageContainer'; +import { Path } from '../shared/components/Path'; + +type Props = { + pathName: string; + title: string; + products: Product[]; +}; + +export const CatalogPage: FC<Props> = ({ pathName, title, products }) => { + const { + handleSort, + handlePerPage, + handlePage, + sort, + currentPage, + itemsPerPage, + pages, + visibleProducts, + } = useCatalog(products); + + return ( + <PageContainer> + <Path pathName={pathName} /> + + {visibleProducts.length > 0 ? ( + <> + <Title title={title} amountPage={products.length} /> + <CatalogOptions + sort={sort} + itemsPerPage={itemsPerPage} + currentPage={currentPage} + handlePerPage={handlePerPage} + handleSort={handleSort} + handlePage={handlePage} + /> + + <CatalogProducts + visibleProducts={visibleProducts} + pathName={pathName} + /> + + <CatalogPagination + pages={pages} + handlePage={handlePage} + currentPage={currentPage} + /> + </> + ) : ( + <h1 style={{ color: 'white', fontSize: '24px' }}> + There are no {title} yet + </h1> + )} + </PageContainer> + ); +}; diff --git a/src/modules/CatalogPage/components/CatalogOptions/CatalogOptions.module.scss b/src/modules/CatalogPage/components/CatalogOptions/CatalogOptions.module.scss new file mode 100644 index 00000000000..3290e8ddde2 --- /dev/null +++ b/src/modules/CatalogPage/components/CatalogOptions/CatalogOptions.module.scss @@ -0,0 +1,90 @@ +@use './../../../../styles/global.scss' as *; +@use './../../../../styles/variables.scss' as *; + +.catalogOptions { + display: flex; + gap: 16px; + + margin-top: 40px; +} + +.title { + margin-bottom: 4px; + color: $secondary__text-color; +} + +.trigger { + width: 176px; + display: flex; + align-items: center; + justify-content: space-between; + + font-size: 14px; + padding: 10px 12px; + + color: $primary__text-color; + background-color: $secondary__element-color; + border: 1px solid $secondary__element-color; + + transition: all 0.3s ease; + + &:hover { + border: 1px solid $secondary__text-color; + } +} + +.triggerActive { + border-color: $accent__color; +} + +.icon { + margin-left: 8px; + font-size: 12px; + + color: $secondary__text-color; + transition: all 0.3s ease; +} + +.iconOpen { + transform: rotate(180deg); +} + +.content { + border: 1px solid $secondary__element-color; + + width: var(--radix-select-trigger-width); + background: #fff; + border-radius: 0; + overflow: hidden; + z-index: 1000; +} + +.item { + padding: 10px 12px; + cursor: pointer; + color: $secondary__text-color; + font-weight: 600; + + background-color: $primary__background-color; + + &[data-highlighted] { + border: none; + outline: none; + color: $primary__text-color; + background-color: $secondary__element-color; + } + + &[data-state='checked'] { + background: $secondary__element-color; + color: $primary__text-color; + } +} + +@media (max-width: 639px) { + .trigger { + width: 135px; + } + .catalogOptions { + margin-top: 20px; + } +} diff --git a/src/modules/CatalogPage/components/CatalogOptions/CatalogOptions.tsx b/src/modules/CatalogPage/components/CatalogOptions/CatalogOptions.tsx new file mode 100644 index 00000000000..a7bf34103ab --- /dev/null +++ b/src/modules/CatalogPage/components/CatalogOptions/CatalogOptions.tsx @@ -0,0 +1,140 @@ +import styles from './CatalogOptions.module.scss'; +import './../../../../styles/global.scss'; +import { FC, SetStateAction, useEffect, useState } from 'react'; +import { SortName } from '../../../../types/SortName'; +// eslint-disable-next-line import/no-extraneous-dependencies +import * as Select from '@radix-ui/react-select'; +import { useSearchParams } from 'react-router-dom'; +import { ItemsPerPage } from './../../../../types/ItemsPerPage'; +import arrowBottom from './../../../../../public/img/icons/arrowBottom.svg'; + +type Props = { + sort: SortName; + itemsPerPage: ItemsPerPage; + currentPage: number; + handlePerPage: (val: ItemsPerPage) => void; + handleSort: (val: SortName) => void; + handlePage: (val: number) => void; +}; + +export const CatalogOptions: FC<Props> = ({ + sort, + itemsPerPage, + handlePerPage, + handleSort, + handlePage, +}) => { + const [isSortOpen, setIsSortOpen] = useState(false); + const [isItemsOpen, setIsItemsOpen] = useState(false); + + const [searchParams] = useSearchParams(); + + const sortParam = searchParams.get('sort') as SortName | null; + // const perPageParam = searchParams.get('perPage') as ItemsPerPage | null; + + useEffect(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + sortParam !== null ? handleSort(sortParam) : handleSort(sort); + }, []); + + return ( + <div className={styles.catalogOptions}> + <div className={styles.options}> + <h4 className={styles.title}>Sort by</h4> + <div className={styles.sort__select}> + <Select.Root + value={sort} + onValueChange={(value: SortName) => { + handlePage(1); + handleSort(value); + }} + onOpenChange={setIsSortOpen} + > + <Select.Trigger + className={`${styles.trigger} ${isSortOpen ? styles.triggerActive : ''}`} + > + <Select.Value /> + <Select.Icon + className={`${styles.icon} ${isSortOpen ? styles.iconOpen : ''}`} + > + <img src={arrowBottom} alt="arrowBottom" /> + </Select.Icon> + </Select.Trigger> + + <Select.Portal> + <Select.Content + className={styles.content} + position="popper" + sideOffset={8} + > + <Select.Viewport> + <Select.Item value="age" className={styles.item}> + <Select.ItemText>Newest</Select.ItemText> + </Select.Item> + + <Select.Item value="title" className={styles.item}> + <Select.ItemText>Alphabetically</Select.ItemText> + </Select.Item> + + <Select.Item value="price" className={styles.item}> + <Select.ItemText>Cheapest</Select.ItemText> + </Select.Item> + </Select.Viewport> + </Select.Content> + </Select.Portal> + </Select.Root> + </div> + </div> + + <div className={styles.items}> + <h4 className={styles.title}>Items on page</h4> + <div className={styles.items__select}> + <Select.Root + value={itemsPerPage} + onValueChange={value => { + handlePage(1); + handlePerPage(value); + }} + onOpenChange={setIsItemsOpen} + > + <Select.Trigger + className={`${styles.trigger} ${isItemsOpen ? styles.triggerActive : ''}`} + > + <Select.Value /> + <Select.Icon + className={`${styles.icon} ${isItemsOpen ? styles.iconOpen : ''}`} + > + <img src={arrowBottom} alt="arrowBottom" /> + </Select.Icon> + </Select.Trigger> + + <Select.Portal> + <Select.Content + className={styles.content} + position="popper" + sideOffset={8} + > + <Select.Viewport> + <Select.Item value="4" className={styles.item}> + <Select.ItemText>4</Select.ItemText> + </Select.Item> + + <Select.Item value="8" className={styles.item}> + <Select.ItemText>8</Select.ItemText> + </Select.Item> + + <Select.Item value="16" className={styles.item}> + <Select.ItemText>16</Select.ItemText> + </Select.Item> + <Select.Item value="all" className={styles.item}> + <Select.ItemText>all</Select.ItemText> + </Select.Item> + </Select.Viewport> + </Select.Content> + </Select.Portal> + </Select.Root> + </div> + </div> + </div> + ); +}; diff --git a/src/modules/CatalogPage/components/CatalogOptions/constants.ts b/src/modules/CatalogPage/components/CatalogOptions/constants.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/modules/CatalogPage/components/CatalogOptions/index.ts b/src/modules/CatalogPage/components/CatalogOptions/index.ts new file mode 100644 index 00000000000..0e17969628c --- /dev/null +++ b/src/modules/CatalogPage/components/CatalogOptions/index.ts @@ -0,0 +1 @@ +export { CatalogOptions } from './CatalogOptions'; diff --git a/src/modules/CatalogPage/components/CatalogPagination/CatalogPagination.module.scss b/src/modules/CatalogPage/components/CatalogPagination/CatalogPagination.module.scss new file mode 100644 index 00000000000..1c3fad0794f --- /dev/null +++ b/src/modules/CatalogPage/components/CatalogPagination/CatalogPagination.module.scss @@ -0,0 +1,64 @@ +@use './../../../../styles/global.scss' as *; +@use './../../../../styles/variables.scss' as *; + +.pagination { + display: flex; + align-self: center; + gap: 16px; + padding: 0px 0 40px 0; +} + +.arrow { + width: 32px; + height: 32px; + + display: flex; + justify-content: center; + align-items: center; + + color: $primary__text-color; + background-color: $secondary__element-color; + + transition: all 0.3s ease; + + &:hover { + background-color: $accent__color; + } + &:disabled { + pointer-events: none; + border: 1px solid $secondary__element-color; + background-color: $primary__background-color; + } +} + +.pages__buttons { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.button { + display: flex; + align-items: center; + justify-content: center; + + width: 32px; + height: 32px; + background-color: $secondary__background-color; + color: $primary__text-color; + + transition: all 0.3s ease; + + &:hover { + background-color: $secondary__element-color; + } + + &__active { + background-color: $accent__color; + + &:hover { + background-color: $accent__color; + } + } +} diff --git a/src/modules/CatalogPage/components/CatalogPagination/CatalogPagination.tsx b/src/modules/CatalogPage/components/CatalogPagination/CatalogPagination.tsx new file mode 100644 index 00000000000..fc197abf623 --- /dev/null +++ b/src/modules/CatalogPage/components/CatalogPagination/CatalogPagination.tsx @@ -0,0 +1,81 @@ +/* eslint-disable max-len */ +import { FC } from 'react'; +import styles from './CatalogPagination.module.scss'; +import arrowRightWhite from './../../../../../public/img/icons/arrowRightWhite.svg'; +import arrowLeftWhite from './../../../../../public/img/icons/arrowLeftWhite.svg'; + +type Props = { + pages: number[]; + handlePage: (val: number) => void; + currentPage: number; +}; + +export const CatalogPagination: FC<Props> = ({ + pages, + handlePage, + currentPage, +}) => { + const widthPagination = 5; + const totalPages = pages.length; + const center = Math.ceil(widthPagination / 2); + let from = currentPage - (center - 1); + let to = from + widthPagination - 1; + + if (from < 1) { + from = 1; + to = Math.min(widthPagination, totalPages); + } + + if (to > totalPages) { + to = totalPages; + from = Math.max(1, totalPages - widthPagination + 1); + } + + const visiblePages = []; + + for (let i = from; i <= to; i++) { + visiblePages.push(i); + } + + return ( + <> + {totalPages !== 0 && ( + <div className={styles.pagination}> + <button + disabled={currentPage === 1} + onClick={() => { + handlePage(currentPage - 1); + }} + className={styles.arrow} + > + <img src={arrowLeftWhite} alt="arrowLeftWhite" /> + </button> + <div className={styles.pages__buttons}> + {visiblePages.map((page, index) => { + return ( + <button + onClick={() => { + handlePage(page); + }} + key={index} + className={`${styles.button} ${Number(currentPage) === page ? styles.button__active : ''}`} + > + {page} + </button> + ); + })} + </div> + <button + disabled={currentPage === totalPages} + onClick={() => { + handlePage(currentPage + 1); + }} + className={styles.arrow} + > + <img src={arrowRightWhite} alt="arrowRightWhite" /> + </button> + </div> + )} + </> + ); +}; diff --git a/src/modules/CatalogPage/components/CatalogPagination/index.ts b/src/modules/CatalogPage/components/CatalogPagination/index.ts new file mode 100644 index 00000000000..35f2b3973c5 --- /dev/null +++ b/src/modules/CatalogPage/components/CatalogPagination/index.ts @@ -0,0 +1 @@ +export { CatalogPagination } from './CatalogPagination'; diff --git a/src/modules/CatalogPage/constants.ts b/src/modules/CatalogPage/constants.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/modules/CatalogPage/hooks.ts b/src/modules/CatalogPage/hooks.ts new file mode 100644 index 00000000000..f73a21cf5b5 --- /dev/null +++ b/src/modules/CatalogPage/hooks.ts @@ -0,0 +1,98 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useLocation, useSearchParams } from 'react-router-dom'; +import { ItemsPerPage } from '../../types/ItemsPerPage'; +import { SortName } from '../../types/SortName'; +import { Product } from '../../types/Product'; + +export const useCatalog = (products: Product[]) => { + const [sort, setSort] = useState<SortName>('age'); + + const { pathname } = useLocation(); + + const [searchParams, setSearchParams] = useSearchParams(); + + const pageParam: string | null = searchParams.get('page'); + const perPageParam = searchParams.get('perPage') as ItemsPerPage | null; + + const defaultCurrentPage = pageParam !== null ? Number(pageParam) : 1; + + const [currentPage, setCurrentPage] = useState<number>(defaultCurrentPage); + const [itemsPerPage, setItemsPerPage] = useState<ItemsPerPage>( + perPageParam !== null ? perPageParam : 'all', + ); + + const handleSort = (value: SortName) => { + setSort(value); + searchParams.set('sort', value); + setSearchParams(searchParams, { replace: true }); + }; + + const handlePerPage = (value: ItemsPerPage) => { + setItemsPerPage(value); + if (value !== 'all') { + searchParams.set('perPage', value); + } + + setSearchParams(searchParams, { replace: true }); + }; + + const handlePage = (value: number) => { + setCurrentPage(value); + if (Number(value)) { + searchParams.set('page', String(value)); + } + + setSearchParams(searchParams, { replace: true }); + }; + + const sortedProducts = useMemo(() => { + return [...products].sort((a: Product, b: Product): number => { + switch (sort) { + case 'age': + return b.year - a.year; + case 'title': + return a.name.localeCompare(b.name); + case 'price': + return a.price - b.price; + default: + return 0; + } + }); + }, [products, sort]); + + const amountPages = + itemsPerPage !== 'all' + ? Math.ceil(sortedProducts.length / Number(itemsPerPage)) + : 0; + const pages = Array.from({ length: amountPages }, (_, index) => index + 1); + + const startIndex = (currentPage - 1) * Number(itemsPerPage); + const endIndex = startIndex + Number(itemsPerPage); + + const visibleProducts = + itemsPerPage === 'all' + ? [...sortedProducts] + : [...sortedProducts].slice(startIndex, endIndex); + + useEffect(() => { + handleSort('age'); + handlePerPage('all'); + handlePage(1); + + if (visibleProducts.length === 0) { + searchParams.delete('sort'); + setSearchParams(searchParams, { replace: true }); + } + }, [pathname]); + + return { + handleSort, + handlePerPage, + handlePage, + sort, + currentPage, + itemsPerPage, + pages, + visibleProducts, + }; +}; diff --git a/src/modules/CatalogPage/index.ts b/src/modules/CatalogPage/index.ts new file mode 100644 index 00000000000..8decfd6bf14 --- /dev/null +++ b/src/modules/CatalogPage/index.ts @@ -0,0 +1 @@ +export { CatalogPage } from './CatalogPage'; diff --git a/src/modules/FavoritesPage/FavoritesPage.tsx b/src/modules/FavoritesPage/FavoritesPage.tsx new file mode 100644 index 00000000000..20469f00b7b --- /dev/null +++ b/src/modules/FavoritesPage/FavoritesPage.tsx @@ -0,0 +1,32 @@ +import { PageContainer } from '../shared/components/PageContainer'; +import { Path } from '../shared/components/Path'; +import { Title } from '../shared/components/Title'; +import { FC } from 'react'; +import { Product } from '../../types/Product'; +import { CatalogProducts } from '../shared/components/CatalogProducts'; +import { useAppSelector } from '../../app/hooks'; +import './../../styles/global.scss'; + +type Props = { + products: Product[]; +}; + +export const FavoritesPage: FC<Props> = ({ products }) => { + const favoritesIds = useAppSelector(state => state.favorites); + + const favoritesProducts = products.filter(product => + favoritesIds.includes(product.itemId), + ); + + return ( + <PageContainer> + <Path pathName={'favorites'} /> + <Title title={'Favorites'} amountPage={favoritesProducts.length} /> + {favoritesProducts.length > 0 ? ( + <CatalogProducts visibleProducts={favoritesProducts} /> + ) : ( + <h2 className="message">You don't have favorite products!</h2> + )} + </PageContainer> + ); +}; diff --git a/src/modules/FavoritesPage/constants.ts b/src/modules/FavoritesPage/constants.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/modules/FavoritesPage/hooks.ts b/src/modules/FavoritesPage/hooks.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/modules/FavoritesPage/index.ts b/src/modules/FavoritesPage/index.ts new file mode 100644 index 00000000000..cc5cab74bca --- /dev/null +++ b/src/modules/FavoritesPage/index.ts @@ -0,0 +1 @@ +export { FavoritesPage } from './FavoritesPage'; diff --git a/src/modules/HomePage/HomePage.tsx b/src/modules/HomePage/HomePage.tsx new file mode 100644 index 00000000000..94704aee729 --- /dev/null +++ b/src/modules/HomePage/HomePage.tsx @@ -0,0 +1,18 @@ +import { ProductSection } from '../shared/components/ProductSection'; +import { Banner } from './components/Banner/Banner'; +import { CategorySection } from './components/Category'; +import './../../styles/global.scss'; +import './../../styles/global.scss'; +import { PageContainer } from '../shared/components/PageContainer'; + +export const HomePage = () => { + return ( + <PageContainer> + <h1 className="visuallyHidden">Product Catalog</h1> + <Banner /> + <ProductSection title={'Brand new models'} type={'new'} /> + <CategorySection /> + <ProductSection title={'Hot models'} type={'hot'} /> + </PageContainer> + ); +}; diff --git a/src/modules/HomePage/components/Banner/Banner.module.scss b/src/modules/HomePage/components/Banner/Banner.module.scss new file mode 100644 index 00000000000..cfc65d495bd --- /dev/null +++ b/src/modules/HomePage/components/Banner/Banner.module.scss @@ -0,0 +1,145 @@ +@use '../../../../styles/variables.scss' as *; +@use './../../../../styles/global.scss' as *; + +.banner { + padding: 56px 0 40px; +} + +.title { + font-size: 48px; + font-weight: 800; + letter-spacing: -1%; + line-height: 56px; + margin-bottom: 56px; + color: $primary__text-color; +} + +.banner__swiper { + :global(.swiper) { + width: 100%; + height: 432px; + margin: auto; + } + + :global(.swiper-slide img) { + display: block; + margin: 0 auto; + + width: 90%; + height: 400px; + + object-fit: cover; + } +} + +.prev, +.next { + position: absolute; + top: 46.3%; + transform: translateY(-50%); + z-index: 10; + + width: 32px; + height: 400px; + border: none; + + background-color: $secondary__element-color; + color: $primary__text-color; + font-size: 24px; + cursor: pointer; + + transition: background-color 0.3s ease; +} + +.prev { + left: 0; +} + +.next { + right: 0; +} + +.prev:hover, +.next:hover { + background-color: $accent__color; +} + +:global { + .swiper-pagination.swiper-pagination-bullets { + .swiper-pagination-bullet { + width: 14px; + height: 4px; + background: $primary__text-color; + border-radius: 0; + } + } +} + +@media (max-width: 1199px) { + .banner { + padding: 32px 0; + width: calc(100vw - 40px); + margin: 0 auto; + } + + .title { + max-width: 500px; + margin-bottom: 32px; + } + + .banner__swiper { + :global(.swiper) { + width: 100% !important; + height: auto; + margin: 0 auto; + } + + :global(.swiper-slide img) { + width: calc(100% - 40px); + } + } + + .prev, + .next { + width: 32px; + height: 140%; + } +} + +@media (max-width: 639px) { + .banner { + padding: 24px 0 20px; + width: calc(100vw - 24px) !important; + margin: 0 auto; + } + .title { + font-size: 32px; + max-width: 300px; + line-height: 40px; + margin-bottom: 24px; + } + + .prev, + .next { + display: none; + } + + .fullWidthSwiper { + position: relative; + width: 100vw; + margin-left: calc(-50vw + 49%); + height: 320px; + + :global(.swiper) { + width: 100%; + height: 100%; + } + + :global(.swiper-slide img) { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } + } +} diff --git a/src/modules/HomePage/components/Banner/Banner.tsx b/src/modules/HomePage/components/Banner/Banner.tsx new file mode 100644 index 00000000000..3f5ebb67785 --- /dev/null +++ b/src/modules/HomePage/components/Banner/Banner.tsx @@ -0,0 +1,68 @@ +import styles from './Banner.module.scss'; +import banner01 from './../../../../../public/img/banner01.webp'; +import banner02 from './../../../../../public/img/banner02.webp'; +import banner03 from './../../../../../public/img/banner-accessories.png'; +import banner04 from './../../../../../public/img/banner-phones.png'; +import banner05 from './../../../../../public/img/banner-tablets.png'; +import arrowRight from './../../../../../public/img/icons/arrowRightWhite.svg'; +import arrowLeft from './../../../../../public/img/icons/arrowLeftWhite.svg'; + +// eslint-disable-next-line import/no-extraneous-dependencies +import { Navigation, Autoplay, Pagination } from 'swiper/modules'; + +import { Swiper, SwiperSlide } from 'swiper/react'; + +import 'swiper/css'; +import 'swiper/css/navigation'; +import 'swiper/css/pagination'; + +export const Banner = () => { + return ( + <section className={styles.banner}> + <h1 className={styles.title}>Welcome to Nice Gadgets store!</h1> + <div className={styles.banner__swiper}> + <div className={styles.fullWidthSwiper}> + <Swiper + loop={true} + modules={[Navigation, Autoplay, Pagination]} + spaceBetween={50} + slidesPerView={1} + navigation={{ + nextEl: `.${styles.next}`, + prevEl: `.${styles.prev}`, + }} + pagination={{ + clickable: true, + }} + autoplay={{ + delay: 5000, + disableOnInteraction: false, + }} + > + <SwiperSlide> + <img loading="lazy" src={banner01} alt="banner01" /> + </SwiperSlide> + <SwiperSlide> + <img loading="lazy" src={banner02} alt="banner02" /> + </SwiperSlide> + <SwiperSlide> + <img loading="lazy" src={banner03} alt="banner03" /> + </SwiperSlide> + <SwiperSlide> + <img loading="lazy" src={banner04} alt="banner04" /> + </SwiperSlide> + <SwiperSlide> + <img loading="lazy" src={banner05} alt="banner05" /> + </SwiperSlide> + <button className={styles.prev}> + <img src={arrowLeft} alt="arrowLeft" /> + </button> + <button className={styles.next}> + <img src={arrowRight} alt="arrowRight" /> + </button> + </Swiper> + </div> + </div> + </section> + ); +}; diff --git a/src/modules/HomePage/components/Banner/index.ts b/src/modules/HomePage/components/Banner/index.ts new file mode 100644 index 00000000000..f4930c0719e --- /dev/null +++ b/src/modules/HomePage/components/Banner/index.ts @@ -0,0 +1 @@ +export { Banner } from './Banner'; diff --git a/src/modules/HomePage/components/Category/CategorySection.module.scss b/src/modules/HomePage/components/Category/CategorySection.module.scss new file mode 100644 index 00000000000..f93f6ff1635 --- /dev/null +++ b/src/modules/HomePage/components/Category/CategorySection.module.scss @@ -0,0 +1,96 @@ +@use './../../../../styles/global.scss' as *; +@use './../../../../styles/variables.scss' as *; + +.category { + padding: 40px 0; + + &__items { + display: flex; + flex-grow: 1; + flex-shrink: 1; + gap: 16px; + } + + &__title { + margin-bottom: 24px; + } + + &__img { + display: block; + + width: 368px; + height: 368px; + + position: relative; + overflow: hidden; + + margin-bottom: 24px; + } + + &__img img { + position: absolute; + + top: 40px; + left: 90px; + max-width: 100%; + max-height: 100%; + object-fit: cover; + + transition: all 0.3s ease; + } + + &__img:hover { + img { + transform: scale(1.1); + } + } + + &__label { + color: $primary__text-color; + font-size: 20px; + font-weight: 500; + line-height: 100%; + + margin-bottom: 4px; + } + + &__desc { + color: $secondary__text-color; + font-size: $body__text-size; + font-weight: 600; + line-height: 21px; + } +} + +@media (max-width: 1199px) { + .category { + &__img { + width: 187px; + height: 187px; + } + + &__img img { + top: 20px; + left: 45px; + } + } +} + +@media (max-width: 639px) { + .category__items { + flex-direction: column; + } + + .category { + padding: 20px 0; + &__img { + width: calc(100vw - 40px); + height: 250px; + } + + &__img img { + top: 20px; + left: 45px; + } + } +} diff --git a/src/modules/HomePage/components/Category/CategorySection.tsx b/src/modules/HomePage/components/Category/CategorySection.tsx new file mode 100644 index 00000000000..4c8a5d80e3f --- /dev/null +++ b/src/modules/HomePage/components/Category/CategorySection.tsx @@ -0,0 +1,31 @@ +import styles from './CategorySection.module.scss'; +import { Category } from '../../../../types/Category'; +import './../../../../styles/global.scss'; + +import { categories } from './constants'; +import { Link } from 'react-router-dom'; + +export const CategorySection = () => { + return ( + <section className={styles.category}> + <h2 className={`${styles.category__title} h2title`}>Shop by category</h2> + <div className={styles.category__items}> + {categories.map((category: Category) => { + return ( + <article key={category.id} className={styles.category__item}> + <Link + to={category.href} + style={{ backgroundColor: `${category.bgColor}` }} + className={styles.category__img} + > + <img src={category.img} alt={category.name} /> + </Link> + <h3 className={styles.category__label}>{category.name}</h3> + <p className={styles.category__desc}>{category.desc}</p> + </article> + ); + })} + </div> + </section> + ); +}; diff --git a/src/modules/HomePage/components/Category/constants.ts b/src/modules/HomePage/components/Category/constants.ts new file mode 100644 index 00000000000..ed4861674f3 --- /dev/null +++ b/src/modules/HomePage/components/Category/constants.ts @@ -0,0 +1,30 @@ +import img01 from './../../../../../public/img/category-phones.webp'; +import img02 from './../../../../../public/img/category-tablets.png'; +import img03 from './../../../../../public/img/category-accessories.webp'; + +export const categories = [ + { + id: 0, + name: 'Mobile phones', + href: '/phones', + img: img01, + desc: '95 models', + bgColor: '#6D6474', + }, + { + id: 1, + name: 'Tablets', + href: '/tablets', + img: img02, + desc: '24 models', + bgColor: '#8d8d92', + }, + { + id: 2, + name: 'Accessories', + href: '/accessories', + img: img03, + desc: '100 models', + bgColor: '#973d5f', + }, +]; diff --git a/src/modules/HomePage/components/Category/index.ts b/src/modules/HomePage/components/Category/index.ts new file mode 100644 index 00000000000..6b7ce4e8a69 --- /dev/null +++ b/src/modules/HomePage/components/Category/index.ts @@ -0,0 +1 @@ +export { CategorySection } from './CategorySection'; diff --git a/src/modules/HomePage/constants.ts b/src/modules/HomePage/constants.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/modules/HomePage/hooks.ts b/src/modules/HomePage/hooks.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/modules/HomePage/index.ts b/src/modules/HomePage/index.ts new file mode 100644 index 00000000000..0799f479a25 --- /dev/null +++ b/src/modules/HomePage/index.ts @@ -0,0 +1 @@ +export { HomePage } from './HomePage'; diff --git a/src/modules/ProductPage/ProductPage.tsx b/src/modules/ProductPage/ProductPage.tsx new file mode 100644 index 00000000000..0474d403bef --- /dev/null +++ b/src/modules/ProductPage/ProductPage.tsx @@ -0,0 +1,98 @@ +import { FC, useEffect, useState } from 'react'; +import { PageContainer } from '../shared/components/PageContainer'; +import { Path } from '../shared/components/Path'; +import { useLocation, useParams } from 'react-router-dom'; +import { Product } from '../../types/Product'; +import { Title } from '../shared/components/Title'; +import phones from '../../../public/api/phones.json'; +import tablets from '../../../public/api/tablets.json'; +import accessories from '../../../public/api/accessories.json'; +import { ProductFullInfo } from '../../types/ProductFullInfo'; +import { ProductMainDesc } from './components/ProductMainDesc'; +import { ProductInfo } from './components/ProductInfo'; +import { ProductSection } from '../shared/components/ProductSection'; +import { Loader } from '../shared/components/Loader'; + +export const ProductPage = () => { + const { productId } = useParams(); + const [loading, setLoading] = useState(true); + + const handleCurrentProduct = (): ProductFullInfo | null => { + const phone = phones.find( + product => product.id === productId, + ) as ProductFullInfo; + + if (phone) { + return phone; + } + + const tablet = tablets.find( + product => product.id === productId, + ) as ProductFullInfo; + + if (tablet) { + return tablet; + } + + const accessory = accessories.find( + product => product.id === productId, + ) as ProductFullInfo; + + if (accessory) { + return accessory; + } + + return null; + }; + + const [currentProduct, setCurrentProduct] = useState<ProductFullInfo | null>( + null, + ); + + useEffect(() => { + setCurrentProduct(() => handleCurrentProduct()); + setLoading(false); + }, [productId]); + + const location = useLocation(); + + const from = location.state?.from || 'Home'; + + return ( + <> + {loading ? ( + <Loader /> + ) : currentProduct ? ( + <PageContainer> + <Path pathName={from} nameOfProduct={currentProduct.name} /> + <Title title={currentProduct.name} /> + <ProductMainDesc + images={currentProduct.images} + colorsAvailable={currentProduct.colorsAvailable} + capacityAvailable={currentProduct.capacityAvailable} + currentPrice={currentProduct.priceDiscount} + fullPrice={currentProduct.priceRegular} + descScreen={currentProduct.screen} + descCapacity={currentProduct.capacity} + descRAM={currentProduct.ram} + currentProduct={currentProduct} + /> + <ProductInfo + description={currentProduct.description} + screen={currentProduct.screen} + resolution={currentProduct.resolution} + processor={currentProduct.processor} + ram={currentProduct.ram} + memory={currentProduct.capacity} + camera={currentProduct.camera} + zoom={currentProduct.zoom} + cell={currentProduct.cell} + /> + <ProductSection title={'You may also like'} type={'hot'} /> + </PageContainer> + ) : ( + <h1>Product was not found</h1> + )} + </> + ); +}; diff --git a/src/modules/ProductPage/components/ProductInfo/ProductInfo.module.scss b/src/modules/ProductPage/components/ProductInfo/ProductInfo.module.scss new file mode 100644 index 00000000000..e0637d1070e --- /dev/null +++ b/src/modules/ProductPage/components/ProductInfo/ProductInfo.module.scss @@ -0,0 +1,99 @@ +@use './../../../../styles/variables.scss' as *; + +.product__info { + padding: 40px 0; + + display: flex; + gap: 64px; +} +.about { + max-width: 560px; +} + +.label { + color: $primary__text-color; + font-size: 22px; + font-weight: 800; + line-height: 140%; +} + +.line { + width: 100%; + background-color: $secondary__element-color; + height: 1px; + margin: 16px 0 32px 0; +} + +.paragraphs { + display: flex; + flex-direction: column; + gap: 32px; +} + +.paragraph__title { + color: $primary__text-color; + font-size: 18px; + font-weight: 600; + margin-bottom: 16px; +} + +.paragraph_texts { + display: flex; + flex-direction: column; + gap: 20px; +} + +.paragraph__text { + color: $secondary__text-color; + font-size: 14px; + line-height: 21px; + font-weight: 500; +} + +.tech { + width: 100%; +} + +.desc { + display: flex; + justify-content: space-between; + gap: 10px; +} + +.text__content { + display: flex; + flex-direction: column; + gap: 8px; +} + +.desc__name { + font-size: 14px; + color: $secondary__text-color; + line-height: 21px; + font-weight: 500; +} + +.desc__text { + color: $primary__text-color; + font-size: 14px; + line-height: 21px; + font-weight: 500; +} + +@media (max-width: 899px) { + .product__info { + flex-direction: column; + } +} + +@media (max-width: 639px) { + .desc { + gap: 20px; + } + .desc__text { + font-size: 12px; + word-break: break-word; + overflow-wrap: break-word; + white-space: normal; + } +} diff --git a/src/modules/ProductPage/components/ProductInfo/ProductInfo.tsx b/src/modules/ProductPage/components/ProductInfo/ProductInfo.tsx new file mode 100644 index 00000000000..ca2e0a61dd3 --- /dev/null +++ b/src/modules/ProductPage/components/ProductInfo/ProductInfo.tsx @@ -0,0 +1,92 @@ +import { FC } from 'react'; +import styles from './ProductInfo.module.scss'; +import { ProductDescriptions } from '../../../../types/ProductDescriptions'; + +type Props = { + description: ProductDescriptions[]; + screen: string; + resolution: string; + processor: string; + ram: string; + memory: string; + camera: string; + zoom: string; + cell: string[]; +}; + +export const ProductInfo: FC<Props> = ({ + description, + screen, + resolution, + processor, + ram, + memory, + camera, + zoom, + cell, +}) => { + return ( + <div className={styles.product__info}> + <div className={styles.about}> + <h4 className={styles.label}>About</h4> + <div className={styles.line}></div> + <div className={styles.paragraphs}> + {description.map((desc: ProductDescriptions, index) => ( + <article key={index} className={styles.paragraph}> + <h4 className={styles.paragraph__title}>{desc.title}</h4> + <div className={styles.paragraph_texts}> + {desc.text.map((p, indexP) => ( + <span key={indexP} className={styles.paragraph__text}> + {p} + </span> + ))} + </div> + </article> + ))} + </div> + </div> + <div className={styles.tech}> + <h4 className={styles.label}>Tech specs</h4> + <div className={styles.line}></div> + <div className={styles.text__content}> + <div className={styles.desc}> + <h6 className={styles.desc__name}>Screen</h6> + <p className={styles.desc__text}>{screen}</p> + </div> + <div className={styles.desc}> + <h6 className={styles.desc__name}>Resolution</h6> + <p className={styles.desc__text}>{resolution}</p> + </div> + <div className={styles.desc}> + <h6 className={styles.desc__name}>Processor</h6> + <p className={styles.desc__text}>{processor}</p> + </div> + <div className={styles.desc}> + <h6 className={styles.desc__name}>RAM</h6> + <p className={styles.desc__text}>{ram}</p> + </div> + <div className={styles.desc}> + <h6 className={styles.desc__name}>Built in memory</h6> + <p className={styles.desc__text}>{memory}</p> + </div> + {camera && ( + <div className={styles.desc}> + <h6 className={styles.desc__name}>Camera</h6> + <p className={styles.desc__text}>{camera}</p> + </div> + )} + {zoom && ( + <div className={styles.desc}> + <h6 className={styles.desc__name}>Zoom</h6> + <p className={styles.desc__text}>{zoom}</p> + </div> + )} + <div className={styles.desc}> + <h6 className={styles.desc__name}>Cell</h6> + <div className={styles.desc__text}>{cell.join(',')}</div> + </div> + </div> + </div> + </div> + ); +}; diff --git a/src/modules/ProductPage/components/ProductInfo/index.ts b/src/modules/ProductPage/components/ProductInfo/index.ts new file mode 100644 index 00000000000..1137a885f17 --- /dev/null +++ b/src/modules/ProductPage/components/ProductInfo/index.ts @@ -0,0 +1 @@ +export { ProductInfo } from './ProductInfo'; diff --git a/src/modules/ProductPage/components/ProductMainDesc/ProductMainDesc.module.scss b/src/modules/ProductPage/components/ProductMainDesc/ProductMainDesc.module.scss new file mode 100644 index 00000000000..a47a0df539e --- /dev/null +++ b/src/modules/ProductPage/components/ProductMainDesc/ProductMainDesc.module.scss @@ -0,0 +1,312 @@ +@use './../../../../styles/variables.scss' as *; + +.content { + display: flex; + gap: 64px; + padding: 0 0 40px; +} + +.gallery { + display: flex; + gap: 16px; +} + +.thumbnails { + display: flex; + flex-direction: column; + + gap: 16px; +} + +.addToCart { + width: 100%; +} + +.img { + width: 80px; + height: 80px; + border: 1px solid $secondary__element-color; + + display: flex; + align-items: center; + justify-content: center; + + transition: all 0.3s ease; +} + +.img:hover { + border: 1px solid $primary__text-color; +} + +.active { + border: 1px solid $primary__text-color; +} + +.img img { + width: 66px; + height: 66px; + object-fit: contain; +} + +.main__image img { + width: 464px; + height: 464px; + object-fit: contain; +} + +.main__desc { + display: flex; + gap: 32px; + flex-direction: column; +} + +.options { + display: flex; + flex-direction: column; + gap: 24px; +} + +.line { + width: 100%; + background-color: $secondary__element-color; + height: 1px; +} + +.label { + color: $secondary__text-color; + margin-bottom: 8px; +} + +.colors { + display: flex; + gap: 10px; +} + +.color__frame { + border: 1px solid $secondary__element-color; + border-radius: 50%; + width: 35px; + height: 35px; + + display: flex; + justify-content: center; + align-items: center; + + transition: all 0.3s ease; + + &.active__frame { + border: 1px solid $primary__text-color; + } + + &:hover { + border: 1px solid $primary__text-color; + } +} + +.color { + border-radius: 50%; + width: 30px; + height: 30px; +} + +.capacities { + display: flex; + gap: 10px; +} + +.capacity { + padding: 0 8px; + color: $primary__text-color; +} + +.capacity__frame { + height: 32px; + + display: flex; + align-items: center; + justify-content: center; + + user-select: none; + + border: 1px solid $secondary__element-color; + + transition: all 0.3s ease; + + &.active__frame { + background-color: $primary__text-color; + border: 1px solid $primary__text-color; + + .capacity { + color: $secondary__background-color; + } + } + + &:hover { + background-color: $primary__text-color; + border: 1px solid $primary__text-color; + + .capacity { + color: $secondary__background-color; + } + } +} + +.prices { + display: flex; + gap: 8px; + align-items: center; + letter-spacing: -1%; +} + +.card__price { + color: $primary__text-color; + font-weight: 800; + font-size: 32px; + line-height: 41px; +} + +.card__oldPrice { + color: $secondary__text-color; + font-size: 22px; + font-weight: 500; + position: relative; + letter-spacing: normal; + line-height: 100%; +} + +.card__oldPrice::before { + position: absolute; + content: ''; + + width: 100%; + height: 2px; + background-color: $secondary__text-color; + top: 50%; + left: 0; +} + +.desc { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + max-width: 320px; + gap: 20px; + + &__name { + color: $secondary__text-color; + font-size: $primary__text-size; + line-height: 100%; + } + + &__text { + color: $primary__text-color; + font-size: $primary__text-size; + line-height: 100%; + } +} + +.card__actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + height: 40px; +} + +.cart { + height: 100%; + width: 263px; + background-color: $accent__color; + color: $primary__text-color; + font-size: 14px; + line-height: 21px; + + transition: background-color 0.3s ease; +} + +.cart:hover { + background-color: $accent__color-hover; +} + +.cart:active { + background-color: $secondary__element-color; +} + +.favourites__img { + position: absolute; + content: url('./../../../../../public//img/favourites.svg'); + + width: 16px; + height: 16px; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +.card__favourites { + background-color: $secondary__element-color; + padding: 12px; + position: relative; + width: 40px; + height: 40px; + + transition: background-color 0.3s ease; +} + +.card__favourites:hover { + background-color: $secondary__text-color; + padding: 12px; +} + +.card__favourites:active { + background-color: $secondary__element-color; + border: 1px solid $secondary__text-color; + + .favourites__img { + content: url('./../../../../../public//img/favouritesActive.svg'); + } +} + +@media (max-width: 899px) { + .content { + gap: 32px; + padding: 0 0 20px; + } + .main__image img { + width: 287px; + height: 287px; + object-fit: contain; + } + + .img { + width: 40px; + height: 40px; + } + + .img img { + width: 33px; + height: 33px; + } + + .main__desc { + gap: 16px; + } +} + +.main__image { + touch-action: pan-y; +} + +@media (max-width: 639px) { + .content { + flex-direction: column; + } + .gallery { + flex-direction: column-reverse; + } + .thumbnails { + flex-direction: row; + justify-content: center; + } +} diff --git a/src/modules/ProductPage/components/ProductMainDesc/ProductMainDesc.tsx b/src/modules/ProductPage/components/ProductMainDesc/ProductMainDesc.tsx new file mode 100644 index 00000000000..755b8e39502 --- /dev/null +++ b/src/modules/ProductPage/components/ProductMainDesc/ProductMainDesc.tsx @@ -0,0 +1,236 @@ +import { FC, useEffect, useState } from 'react'; +import styles from './ProductMainDesc.module.scss'; +import { Link } from 'react-router-dom'; +import { ProductFullInfo } from '../../../../types/ProductFullInfo'; +import phones from '../../../../../public/api/phones.json'; +import tablets from '../../../../../public/api/tablets.json'; +import accessories from '../../../../../public/api/accessories.json'; +import { Favorite } from '../../../shared/components/Favorite'; +import { AddToCart } from '../../../shared/components/AddToCart'; + +type Props = { + images: string[]; + colorsAvailable: string[]; + capacityAvailable: string[]; + currentPrice: number; + fullPrice: number; + descScreen: string; + descCapacity: string; + descRAM: string; + + currentProduct: ProductFullInfo; +}; + +export const ProductMainDesc: FC<Props> = ({ + images, + colorsAvailable, + capacityAvailable, + currentPrice, + fullPrice, + descScreen, + descCapacity, + descRAM, + + currentProduct, +}) => { + const [selectedColor, setSelectedColor] = useState<string>( + currentProduct.color, + ); + const [selectedCapacity, setSelectedCapacity] = useState<string>( + currentProduct.capacity, + ); + + const [selectedImage, setSelectedImage] = useState<string>( + currentProduct.images[0], + ); + + const handleChangedProduct = (color: string, capacity: string) => { + const phone = phones.find(product => { + return ( + product.namespaceId === currentProduct.namespaceId && + product.capacity === capacity && + product.color === color + ); + }); + + if (phone) { + return phone.id; + } + + const tablet = tablets.find(product => { + return ( + product.namespaceId === currentProduct.namespaceId && + product.capacity === capacity && + product.color === color + ); + }); + + if (tablet) { + return tablet.id; + } + + const accessory = accessories.find(product => { + return ( + product.namespaceId === currentProduct.namespaceId && + product.capacity === capacity && + product.color === color + ); + }); + + if (accessory) { + return accessory.id; + } + + return null; + }; + + useEffect(() => { + setSelectedImage(currentProduct.images[0]); + setSelectedColor(currentProduct.color); + setSelectedCapacity(currentProduct.capacity); + }, [currentProduct]); + + const [touchStartX, setTouchStartX] = useState<number | null>(null); + const [touchEndX, setTouchEndX] = useState<number | null>(null); + + const handleTouchStart = (e: React.TouchEvent) => { + setTouchStartX(e.touches[0].clientX); + }; + + const handleTouchMove = (e: React.TouchEvent) => { + setTouchEndX(e.touches[0].clientX); + }; + + const currentIndex = images.indexOf(selectedImage); + + const showNextImage = () => { + const nextIndex = (currentIndex + 1) % images.length; + + setSelectedImage(images[nextIndex]); + }; + + const showPrevImage = () => { + const prevIndex = (currentIndex - 1 + images.length) % images.length; + + setSelectedImage(images[prevIndex]); + }; + + const handleTouchEnd = () => { + if (touchStartX === null || touchEndX === null) { + return; + } + + const distance = touchStartX - touchEndX; + + if (distance > 50) { + showNextImage(); + } + + if (distance < -50) { + showPrevImage(); + } + + setTouchStartX(null); + setTouchEndX(null); + }; + + return ( + <div className={styles.content}> + <div className={styles.gallery}> + <div className={styles.thumbnails}> + {images.map(img => ( + <div + key={img} + className={`${styles.img} ${img === selectedImage ? styles.active : ''}`} + > + <img + src={img} + onClick={() => { + setSelectedImage(img); + }} + /> + </div> + ))} + </div> + <div + className={styles.main__image} + onTouchStart={handleTouchStart} + onTouchMove={handleTouchMove} + onTouchEnd={handleTouchEnd} + > + <img src={selectedImage} /> + </div> + </div> + + <div className={styles.main__desc}> + <div className={styles.options}> + <div className={styles.wrapper}> + <h4 className={styles.label}>Available colors</h4> + <div className={styles.colors}> + {colorsAvailable.map((color, index) => ( + <Link + to={`/product/${handleChangedProduct(color, selectedCapacity)}`} + key={index} + onClick={() => setSelectedColor(color)} + className={`${styles.color__frame} ${ + color === selectedColor ? styles.active__frame : '' + }`} + > + <div + className={styles.color} + style={{ backgroundColor: `${color}` }} + ></div> + </Link> + ))} + </div> + </div> + <div className={styles.line}></div> + <div className={styles.wrapper}> + <h4 className={styles.label}>Select capacity</h4> + <div className={styles.capacities}> + {capacityAvailable.map((capacity, index) => ( + <Link + to={`/product/${handleChangedProduct(selectedColor, capacity)}`} + onClick={() => setSelectedCapacity(capacity)} + key={index} + className={`${styles.capacity__frame} ${ + capacity === selectedCapacity ? styles.active__frame : '' + }`} + > + <div className={styles.capacity}>{capacity}</div> + </Link> + ))} + </div> + </div> + <div className={styles.line}></div> + </div> + <div className={styles.prices}> + <p className={styles.card__price}>${currentPrice}</p> + <p className={styles.card__oldPrice}>${fullPrice}</p> + </div> + + <div className={styles.card__actions}> + <div className={styles.addToCart}> + <AddToCart productId={currentProduct.id} /> + </div> + <Favorite productId={currentProduct.id} /> + </div> + + <div className={styles.card__desc}> + <div className={styles.desc}> + <h6 className={styles.desc__name}>Screen</h6> + <p className={styles.desc__text}>{descScreen}</p> + </div> + <div className={styles.desc}> + <h6 className={styles.desc__name}>Capacity</h6> + <p className={styles.desc__text}>{descCapacity}</p> + </div> + <div className={styles.desc}> + <h6 className={styles.desc__name}>RAM</h6> + <p className={styles.desc__text}>{descRAM}</p> + </div> + </div> + </div> + </div> + ); +}; diff --git a/src/modules/ProductPage/components/ProductMainDesc/index.ts b/src/modules/ProductPage/components/ProductMainDesc/index.ts new file mode 100644 index 00000000000..81999cf5862 --- /dev/null +++ b/src/modules/ProductPage/components/ProductMainDesc/index.ts @@ -0,0 +1 @@ +export { ProductMainDesc } from './ProductMainDesc'; diff --git a/src/modules/ProductPage/constants.ts b/src/modules/ProductPage/constants.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/modules/ProductPage/index.ts b/src/modules/ProductPage/index.ts new file mode 100644 index 00000000000..93f6975c68d --- /dev/null +++ b/src/modules/ProductPage/index.ts @@ -0,0 +1 @@ +export { ProductPage } from './ProductPage'; diff --git a/src/modules/shared/components/AddToCart/AddToCart.module.scss b/src/modules/shared/components/AddToCart/AddToCart.module.scss new file mode 100644 index 00000000000..43f10c3e1ce --- /dev/null +++ b/src/modules/shared/components/AddToCart/AddToCart.module.scss @@ -0,0 +1,30 @@ +@use './../../../../styles/variables.scss' as *; + +.cart { + height: 40px; + width: 100%; + background-color: $accent__color; + color: $primary__text-color; + font-size: 14px; + line-height: 21px; + + transition: background-color 0.3s ease; +} + +.cartAdded { + height: 40px; + width: 100%; + background-color: $secondary__element-color; + color: $primary__text-color; + font-size: 14px; + line-height: 21px; + + transition: background-color 0.3s ease; +} + +.cart:hover { + background-color: $accent__color-hover; +} +.cart:active { + background-color: $secondary__element-color; +} diff --git a/src/modules/shared/components/AddToCart/AddToCart.tsx b/src/modules/shared/components/AddToCart/AddToCart.tsx new file mode 100644 index 00000000000..21a83509eed --- /dev/null +++ b/src/modules/shared/components/AddToCart/AddToCart.tsx @@ -0,0 +1,44 @@ +import styles from './AddToCart.module.scss'; +import { useAppDispatch, useAppSelector } from '../../../../app/hooks'; +import { FC } from 'react'; +import { cartSlice } from '../../../../features/cart/cartSlice'; +import products from './../../../../../public/api/products.json'; +import { Product } from '../../../../types/Product'; + +type Props = { + productId?: string; +}; + +export const AddToCart: FC<Props> = ({ productId }) => { + const dispatch = useAppDispatch(); + const added = useAppSelector(state => state.cart); + + const currentProduct = products.find( + product => product.itemId === productId, + ) as Product; + + const isAdded = added.some(product => product.id === currentProduct.itemId); + + const productForCard = { + id: currentProduct.itemId, + title: currentProduct.name, + image: currentProduct.image, + price: currentProduct.price, + count: 1, + }; + + return ( + <button + onClick={() => { + if (!isAdded) { + dispatch(cartSlice.actions.addProductToCart(productForCard)); + } else { + dispatch(cartSlice.actions.removeProductFromCart(productForCard)); + } + }} + className={isAdded ? styles.cartAdded : styles.cart} + > + {isAdded ? 'Remove from cart' : 'Add to cart'} + </button> + ); +}; diff --git a/src/modules/shared/components/AddToCart/index.ts b/src/modules/shared/components/AddToCart/index.ts new file mode 100644 index 00000000000..cf03b3f582d --- /dev/null +++ b/src/modules/shared/components/AddToCart/index.ts @@ -0,0 +1 @@ +export { AddToCart } from './AddToCart'; diff --git a/src/modules/shared/components/CatalogProducts/CatalogProducts.module.scss b/src/modules/shared/components/CatalogProducts/CatalogProducts.module.scss new file mode 100644 index 00000000000..18d1184c52f --- /dev/null +++ b/src/modules/shared/components/CatalogProducts/CatalogProducts.module.scss @@ -0,0 +1,56 @@ +@use '../../../../styles/global.scss'; +@use '../../../../styles/variables.scss'; + +.cart__wrapper { + display: flex; + gap: 16px; + justify-content: space-between; + padding-top: 32px; +} + +.products { + padding-top: 24px; + display: grid; + grid-template-columns: repeat(4, 1fr); + column-gap: 16px; + row-gap: 40px; + margin-bottom: 40px; + + grid-template-rows: auto; +} + +.cart__products { + display: flex; + flex-direction: column; + gap: 16px; + width: 100%; +} + +@media (max-width: 1199px) { + .products { + grid-template-columns: repeat(3, 1fr); + } + + .cart__wrapper { + flex-direction: column; + } +} + +@media (max-width: 799px) { + .products { + grid-template-columns: repeat(2, 1fr); + } + .cart__wrapper { + padding-top: 0; + } +} + +@media (max-width: 499px) { + .products { + grid-template-columns: repeat(1, 1fr); + } + .cart__wrapper { + justify-content: center; + padding-top: 0; + } +} diff --git a/src/modules/shared/components/CatalogProducts/CatalogProducts.tsx b/src/modules/shared/components/CatalogProducts/CatalogProducts.tsx new file mode 100644 index 00000000000..ff1050fd0ea --- /dev/null +++ b/src/modules/shared/components/CatalogProducts/CatalogProducts.tsx @@ -0,0 +1,58 @@ +import { FC, useState } from 'react'; +import { Product } from '../../../../types/Product'; +import { ProductCard } from '../ProductCard'; +import styles from './CatalogProducts.module.scss'; +import { TotalAmount } from '../../../CartPage/components/TotalAmount'; +// eslint-disable-next-line max-len +import { ProductCardForCart } from '../../../CartPage/components/ProductCardForCart'; + +type Props = { + visibleProducts: Product[]; + cart?: boolean; + pathName: string; +}; + +export const CatalogProducts: FC<Props> = ({ + visibleProducts, + cart, + pathName, +}) => { + return ( + <> + <div className={styles.cart__wrapper}> + <div className={cart ? styles.cart__products : styles.products}> + <> + {visibleProducts.map(product => { + return cart ? ( + <ProductCardForCart + key={product.itemId} + title={product.name} + img={product.image} + currentPrice={product.price} + productId={product.itemId} + /> + ) : ( + <ProductCard + key={product.itemId} + title={product.name} + fullPrice={product.fullPrice} + descScreen={product.screen} + descCapacity={product.capacity} + descRAM={product.ram} + img={product.image} + currentPrice={product.price} + type={'hot'} + productId={product.itemId} + cart={cart} + pathName={pathName} + /> + ); + })} + </> + </div> + + {cart && <TotalAmount products={visibleProducts} />} + </div> + </> + ); +}; diff --git a/src/modules/shared/components/CatalogProducts/index.ts b/src/modules/shared/components/CatalogProducts/index.ts new file mode 100644 index 00000000000..cc7d6047624 --- /dev/null +++ b/src/modules/shared/components/CatalogProducts/index.ts @@ -0,0 +1 @@ +export { CatalogProducts } from './CatalogProducts'; diff --git a/src/modules/shared/components/Favorite/Favorite.module.scss b/src/modules/shared/components/Favorite/Favorite.module.scss new file mode 100644 index 00000000000..3ede2d8feeb --- /dev/null +++ b/src/modules/shared/components/Favorite/Favorite.module.scss @@ -0,0 +1,46 @@ +@use './../../../../styles/variables.scss' as *; + +.card__favorites { + background-color: $secondary__element-color; + padding: 12px; + + position: relative; + width: 40px; + height: 40px; + + transition: background-color 0.3s ease; +} + +.card__favorites:hover { + background-color: $secondary__text-color; + padding: 12px; +} + +.favorites__img { + position: absolute; + content: url('./../../../../../public/img/favourites.svg'); + + width: 16px; + height: 16px; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +.card__favorites:active { + background-color: $secondary__element-color; + border: 1px solid $secondary__text-color; + + .favorites__img { + content: url('./../../../../../public/img/favouritesActive.svg'); + } +} + +.isFavorite { + background-color: $secondary__element-color; + border: 1px solid $secondary__text-color; + + .favorites__img { + content: url('./../../../../../public/img/favouritesActive.svg'); + } +} diff --git a/src/modules/shared/components/Favorite/Favorite.tsx b/src/modules/shared/components/Favorite/Favorite.tsx new file mode 100644 index 00000000000..cef0f6dac76 --- /dev/null +++ b/src/modules/shared/components/Favorite/Favorite.tsx @@ -0,0 +1,29 @@ +import { favoritesSlice } from '../../../../features/favorite/favoritesSlice'; +import styles from './Favorite.module.scss'; +import { FC } from 'react'; +import { useAppDispatch, useAppSelector } from '../../../../app/hooks'; + +type Props = { + productId?: string; +}; + +export const Favorite: FC<Props> = ({ productId }) => { + const dispatch = useAppDispatch(); + const favoritesIds = useAppSelector(state => state.favorites); + const isFavorite = favoritesIds.some((id: string) => id === productId); + + return ( + <button + onClick={() => { + if (isFavorite) { + dispatch(favoritesSlice.actions.removeProduct(productId)); + } else { + dispatch(favoritesSlice.actions.addProduct(productId)); + } + }} + className={`${styles.card__favorites} ${isFavorite ? styles.isFavorite : ''}`} + > + <span className={styles.favorites__img}></span> + </button> + ); +}; diff --git a/src/modules/shared/components/Favorite/index.ts b/src/modules/shared/components/Favorite/index.ts new file mode 100644 index 00000000000..d8392335270 --- /dev/null +++ b/src/modules/shared/components/Favorite/index.ts @@ -0,0 +1 @@ +export { Favorite } from './Favorite'; diff --git a/src/modules/shared/components/Footer/Footer.module.scss b/src/modules/shared/components/Footer/Footer.module.scss new file mode 100644 index 00000000000..f875661dad7 --- /dev/null +++ b/src/modules/shared/components/Footer/Footer.module.scss @@ -0,0 +1,92 @@ +@use '../../../../styles/variables.scss' as *; +@use '../../../../styles/global.scss' as *; + +.footer { + padding: 32px 0; + border-top: 2px solid $secondary__text-color; + margin-top: 40px; +} + +.footer__content { + display: flex; + justify-content: space-between; + align-items: center; + + flex-wrap: wrap; +} + +.footer__items { + display: flex; + gap: 105px; +} + +.footer__item a { + text-transform: uppercase; + color: $primary__text-color; + letter-spacing: 4%; + line-height: 11px; + + transition: all 0.3s ease; + + &:hover { + color: $accent__color; + } +} + +.footer__btn { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + + color: $secondary__text-color; + font-weight: 700; + font-size: 12px; + cursor: pointer; +} + +.btn__icon { + width: 32px; + height: 32px; + padding: 8px; + background-color: $secondary__element-color; + + color: $primary__text-color; + font-size: 14px; + font-weight: 500; + + pointer-events: fill; + transition: all 0.3s ease; + + &:hover { + background-color: $accent__color; + } +} + +@media (max-width: 1199px) { + .footer__items { + gap: 54px; + } +} + +@media (max-width: 639px) { + .footer { + margin-top: 20px; + } + + .footer__content { + flex-direction: column; + gap: 32px; + align-items: flex-start; + } + + .footer__items { + display: flex; + flex-direction: column; + gap: 54px; + } + + .footer__btn { + align-self: center; + } +} diff --git a/src/modules/shared/components/Footer/Footer.tsx b/src/modules/shared/components/Footer/Footer.tsx new file mode 100644 index 00000000000..9eb1e9e1a20 --- /dev/null +++ b/src/modules/shared/components/Footer/Footer.tsx @@ -0,0 +1,50 @@ +import styles from './Footer.module.scss'; +import logo from './../../../../../public/img/logo.svg'; +import arrowTopWhite from './../../../../../public/img/icons/arrowTopWhite.svg'; + +export const Footer = () => { + return ( + <footer className={styles.footer}> + <div className="container"> + <div className={styles.footer__content}> + <div className={styles.footer__logo}> + <img src={logo} alt="logo" /> + </div> + <nav className={styles.footer__nav}> + <ul className={styles.footer__items}> + <li className={styles.footer__item}> + <a + rel="noreferrer" + target="_blank" + href="https://github.com/iirk1" + > + Github + </a> + </li> + <li className={styles.footer__item}> + <a + rel="noreferrer" + target="_blank" + href="https://www.linkedin.com/in/iryna-kohut-555b671bb" + > + LinkedIn + </a> + </li> + </ul> + </nav> + <div className={styles.footer__btn}> + <p className={styles.btn__text}>Back to top</p> + <button + onClick={() => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }} + className={styles.btn__icon} + > + <img src={arrowTopWhite} alt="arrowTopWhite" /> + </button> + </div> + </div> + </div> + </footer> + ); +}; diff --git a/src/modules/shared/components/Footer/constants.ts b/src/modules/shared/components/Footer/constants.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/modules/shared/components/Footer/index.ts b/src/modules/shared/components/Footer/index.ts new file mode 100644 index 00000000000..e667933974b --- /dev/null +++ b/src/modules/shared/components/Footer/index.ts @@ -0,0 +1 @@ +import { Footer } from './Footer'; diff --git a/src/modules/shared/components/Header/Header.module.scss b/src/modules/shared/components/Header/Header.module.scss new file mode 100644 index 00000000000..a688d7f2197 --- /dev/null +++ b/src/modules/shared/components/Header/Header.module.scss @@ -0,0 +1,265 @@ +@use '../../../../styles/variables.scss' as *; + +.header { + background-color: $primary__background-color; + width: 100%; + border-bottom: 0.5px solid $secondary__text-color; +} + +.header__container { + height: 64px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + + max-width: 1440px; + margin: 0 auto; +} + +.logo { + flex: 0 0 auto; + height: 100%; + padding: 0 24px; + display: flex; + align-items: center; +} + +.menu { + height: 100%; +} + +.menu__items { + display: flex; + justify-content: flex-start; + align-items: center; + gap: 64px; + height: 100%; +} + +.menu__link { + display: block; + height: 100%; + width: 100%; + font-family: $primary__font-family; + position: relative; + text-transform: uppercase; + letter-spacing: 0.5px; + color: $secondary__text-color; + font-weight: 700; + font-size: 12px; + transition: color 0.3s ease; + + &::before { + content: ''; + position: absolute; + left: 0; + bottom: -25px; + width: 100%; + height: 2px; + background-color: $primary__text-color; + + transform: scaleX(0); + transform-origin: center; + transition: all 0.3s ease; + } + + &:hover { + color: $primary__text-color; + } +} + +.active { + color: $primary__text-color; +} + +.active::before { + transform-origin: center; + + transform: scaleX(1); +} + +.header__actions { + display: flex; + justify-content: flex-end; + width: 100%; + height: 100%; +} + +.actions__wrapper { + border-left: 0.5px solid $secondary__text-color; +} + +.actions__wrapper img { + padding: 24px; +} + +@media (max-width: 1199px) { + .header__container { + gap: 16px; + } + + .menu__items { + gap: 32px; + } + + .logo { + padding: 0 16px; + } +} + +.favorites { + position: relative; + + &::after { + position: absolute; + content: attr(data-count); + color: white; + font-size: 10px; + width: 12px; + height: 12px; + background-color: #eb5757; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + + top: 20px; + right: 18px; + z-index: 1000; + } +} + +.cart { + position: relative; + + &::after { + position: absolute; + content: attr(data-count); + color: white; + font-size: 10px; + width: 12px; + height: 12px; + background-color: #eb5757; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + + top: 20px; + right: 18px; + z-index: 1000; + } +} + +.actions__link { + display: block; + position: relative; + + &::before { + content: ''; + position: absolute; + left: 0px; + bottom: 0px; + width: 64px; + height: 2px; + background-color: white; + transform: scaleX(0); + transform-origin: center; + transition: all 0.3s ease; + } +} + +.actions__link.active::before { + transform: scaleX(1); +} + +.menu__burger { + display: none; +} + +@media (max-width: 639px) { + .header__container { + height: 48px; + gap: 16px; + } + .menu { + display: none; + } + .header__actions { + display: none; + } + .menu__burger { + display: block; + padding: 16px; + border-left: 0.5px solid $secondary__text-color; + } + .menu__burger img { + width: 16px; + height: 16px; + } + + .burgerMenu { + position: fixed; + top: 0; + left: 0; + + background-color: $primary__background-color; + width: 100vw; + min-height: 100vh; + z-index: 10; + + display: flex; + flex-direction: column; + + // padding: 24px; + + .menu { + display: block; + } + + .menu__items { + display: flex; + flex-direction: column; + } + + .burger__header { + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 0.5px solid $secondary__text-color; + margin-bottom: 24px; + } + + .menu__link { + &::before { + content: ''; + position: absolute; + left: 0; + bottom: -10px; + width: 100%; + height: 1px; + } + } + + .header__actions { + display: flex; + justify-content: center; + + margin-top: auto; + } + + .actions__wrapper { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + border: none; + border-top: 0.5px solid $secondary__text-color; + } + + .withline { + border-right: 0.5px solid $secondary__text-color; + } + } +} diff --git a/src/modules/shared/components/Header/Header.tsx b/src/modules/shared/components/Header/Header.tsx new file mode 100644 index 00000000000..f529169af47 --- /dev/null +++ b/src/modules/shared/components/Header/Header.tsx @@ -0,0 +1,181 @@ +import styles from './Header.module.scss'; +import logo from './../../../../../public/img/logo.svg'; +import favorites from './../../../../../public/img/favourites.svg'; +import cart from './../../../../../public/img/cart.svg'; +import { navLinks } from './constants'; +import { + Link, + NavLink, + NavLinkRenderProps, + useLocation, +} from 'react-router-dom'; +import classNames from 'classnames'; +import { useAppSelector } from '../../../../app/hooks'; +import menuOpen from './../../../../../public/img/icons/menu.png'; +import menuClose from './../../../../../public/img/icons/delete.png'; + +import { useEffect, useState } from 'react'; + +export const Header = () => { + const location = useLocation(); + + const from = location.state?.from || 'Home'; + + const favoritesIds = useAppSelector(state => state.favorites); + const cartProducts = useAppSelector(state => state.cart); + const count = cartProducts.reduce((accum, curr) => accum + curr.count, 0); + + const [burgerMenu, setBurgerMenu] = useState<boolean>(false); + + useEffect(() => { + if (burgerMenu) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + + return () => { + document.body.style.overflow = ''; + }; + }, [burgerMenu]); + + return ( + <header className={styles.header}> + <div className={styles.header__container}> + <Link to="/" className={styles.logo}> + <img src={logo} alt="logo" /> + </Link> + <nav className={styles.menu}> + <ul className={styles.menu__items}> + {navLinks.map(link => ( + <li key={link.id} className={styles.menu__item}> + <NavLink + to={link.href} + className={({ isActive }) => + classNames(styles.menu__link, { + [styles.active]: + isActive || + (location.pathname.startsWith('/product') && + from === link.label), + }) + } + > + {link.label} + </NavLink> + </li> + ))} + </ul> + </nav> + <div className={styles.header__actions}> + <span className={styles.actions__wrapper}> + <NavLink + data-count={favoritesIds.length} + to="/favorites" + className={({ isActive }) => + classNames(styles.actions__link, { + [styles.active]: isActive, + [styles.favorites]: favoritesIds.length > 0, + }) + } + > + <img src={favorites} alt="favorites" /> + </NavLink> + </span> + <span className={styles.actions__wrapper}> + <NavLink + data-count={count} + to="/cart" + className={({ isActive }) => + classNames(styles.actions__link, { + [styles.active]: isActive, + [styles.cart]: cartProducts.length > 0, + }) + } + > + <img src={cart} alt="cart" /> + </NavLink> + </span> + </div> + + <div className={styles.menu__burger}> + <img + onClick={() => setBurgerMenu(true)} + src={menuOpen} + alt="Open menu" + /> + </div> + + {burgerMenu && ( + <div className={styles.burgerMenu}> + <div className={styles.burger__header}> + <Link to="/" className={styles.logo}> + <img src={logo} alt="logo" /> + </Link> + <div className={styles.menu__burger}> + <img + onClick={() => setBurgerMenu(false)} + src={menuClose} + alt="Close menu" + /> + </div> + </div> + <nav className={styles.menu}> + <ul className={styles.menu__items}> + {navLinks.map(link => ( + <li key={link.id} className={styles.menu__item}> + <NavLink + to={link.href} + onClick={() => setBurgerMenu(false)} + className={({ isActive }) => + classNames(styles.menu__link, { + [styles.active]: + isActive || + (location.pathname.startsWith('/product') && + from === link.label), + }) + } + > + {link.label} + </NavLink> + </li> + ))} + </ul> + </nav> + <div className={styles.header__actions}> + <span className={`${styles.actions__wrapper} ${styles.withline}`}> + <NavLink + data-count={favoritesIds.length} + to="/favorites" + onClick={() => setBurgerMenu(false)} + className={({ isActive }) => + classNames(styles.actions__link, { + [styles.active]: isActive, + [styles.favorites]: favoritesIds.length > 0, + }) + } + > + <img src={favorites} alt="favorites" /> + </NavLink> + </span> + <span className={styles.actions__wrapper}> + <NavLink + data-count={cartProducts.length} + to="/cart" + onClick={() => setBurgerMenu(false)} + className={({ isActive }) => + classNames(styles.actions__link, { + [styles.active]: isActive, + [styles.favorites]: favoritesIds.length > 0, + }) + } + > + <img src={cart} alt="cart" /> + </NavLink> + </span> + </div> + </div> + )} + </div> + </header> + ); +}; diff --git a/src/modules/shared/components/Header/constants.ts b/src/modules/shared/components/Header/constants.ts new file mode 100644 index 00000000000..3a16147b3da --- /dev/null +++ b/src/modules/shared/components/Header/constants.ts @@ -0,0 +1,6 @@ +export const navLinks = [ + { label: 'Home', href: '/', id: 0 }, + { label: 'Phones', href: '/phones', id: 1 }, + { label: 'Tablets', href: '/tablets', id: 2 }, + { label: 'Accessories', href: '/accessories', id: 3 }, +]; diff --git a/src/modules/shared/components/Header/index.ts b/src/modules/shared/components/Header/index.ts new file mode 100644 index 00000000000..29429dc97e8 --- /dev/null +++ b/src/modules/shared/components/Header/index.ts @@ -0,0 +1 @@ +export { Header } from './Header'; diff --git a/src/modules/shared/components/Loader/Loader.module.scss b/src/modules/shared/components/Loader/Loader.module.scss new file mode 100644 index 00000000000..08a3ad4157c --- /dev/null +++ b/src/modules/shared/components/Loader/Loader.module.scss @@ -0,0 +1,25 @@ +.loader { + width: 50px; + height: 50px; + border-radius: 50%; + + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto; + + margin-top: 100px; + + animation: spin 0.5s linear infinite; + border: 4px solid gray; + border-top: 4px solid #905bff; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/src/modules/shared/components/Loader/Loader.tsx b/src/modules/shared/components/Loader/Loader.tsx new file mode 100644 index 00000000000..bf564daad20 --- /dev/null +++ b/src/modules/shared/components/Loader/Loader.tsx @@ -0,0 +1,5 @@ +import styles from './Loader.module.scss'; + +export const Loader = () => { + return <div className={styles.loader}></div>; +}; diff --git a/src/modules/shared/components/Loader/index.ts b/src/modules/shared/components/Loader/index.ts new file mode 100644 index 00000000000..d7027885251 --- /dev/null +++ b/src/modules/shared/components/Loader/index.ts @@ -0,0 +1 @@ +export { Loader } from './Loader'; diff --git a/src/modules/shared/components/PageContainer/PageContainer.tsx b/src/modules/shared/components/PageContainer/PageContainer.tsx new file mode 100644 index 00000000000..bfa67efa957 --- /dev/null +++ b/src/modules/shared/components/PageContainer/PageContainer.tsx @@ -0,0 +1,14 @@ +import { FC } from 'react'; +import './../../../../styles/global.scss'; + +type Props = { + children: React.ReactNode; +}; + +export const PageContainer: FC<Props> = ({ children }) => { + return ( + <div className="page"> + <div className="container">{children}</div> + </div> + ); +}; diff --git a/src/modules/shared/components/PageContainer/index.ts b/src/modules/shared/components/PageContainer/index.ts new file mode 100644 index 00000000000..bfab6ed09a4 --- /dev/null +++ b/src/modules/shared/components/PageContainer/index.ts @@ -0,0 +1 @@ +export { PageContainer } from './PageContainer'; diff --git a/src/modules/shared/components/Path/Path.module.scss b/src/modules/shared/components/Path/Path.module.scss new file mode 100644 index 00000000000..0a860b66701 --- /dev/null +++ b/src/modules/shared/components/Path/Path.module.scss @@ -0,0 +1,75 @@ +@use './../../../../styles/global.scss' as *; +@use './../../../../styles/variables.scss' as *; + +.path { + margin: 24px 0 20px; + display: flex; + gap: 8px; +} + +.pathName { + display: flex; + align-items: center; + gap: 8px; + text-transform: capitalize; +} + +.home { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.pathArrow { + width: 16px; + height: 16px; + color: $secondary__text-color; +} + +.link { + color: $secondary__text-color; + pointer-events: none; +} + +.pathLink { + color: $primary__text-color; +} + +.pathArrowBack { + color: $primary__text-color; + margin-bottom: 15px; + margin-top: 30px; +} + +.linkBack { + color: $primary__text-color; + margin-bottom: 15px; + margin-top: 30px; + font-size: 14px; + transition: all 0.3s ease; +} + +.linkBack:hover { + color: $accent__color; +} + +.active { + color: $secondary__text-color; + pointer-events: none; +} + +@media (max-width: 639px) { + .path { + margin-bottom: 0; + margin-top: 20px; + overflow: hidden; + white-space: nowrap; + } + + .linkBack { + margin-top: 30px; + } + .pathArrowBack { + margin-top: 30px; + } +} diff --git a/src/modules/shared/components/Path/Path.tsx b/src/modules/shared/components/Path/Path.tsx new file mode 100644 index 00000000000..d1f4620dd1f --- /dev/null +++ b/src/modules/shared/components/Path/Path.tsx @@ -0,0 +1,68 @@ +/* eslint-disable max-len */ +import styles from './Path.module.scss'; +import { Link, NavLink, useNavigate } from 'react-router-dom'; +import home from './../../../../../public/img/icons/Home.svg'; +import { FC } from 'react'; +import classNames from 'classnames'; +// eslint-disable-next-line max-len +import arrowRight from './../../../../../public/img/icons/arrowRight.svg'; +import arrowLeftWhite from './../../../../../public/img/icons/arrowLeftWhite.svg'; + +type Props = { + pathName: string; + nameOfProduct?: string; + cart?: boolean; +}; + +const isLinkActive = ({ isActive }: { isActive: boolean }) => { + return classNames(styles.pathLink, { [styles.active]: isActive }); +}; + +export const Path: FC<Props> = ({ pathName, nameOfProduct, cart }) => { + const navigate = useNavigate(); + + return ( + <> + {!cart && ( + <div className={styles.path}> + <Link to="/" className={styles.home}> + <img src={home} alt={home} /> + </Link> + + <div className={styles.pathName}> + <p className={styles.pathArrow}> + <img src={arrowRight} alt="arrowRight" /> + </p> + <NavLink + className={isLinkActive} + to={pathName === 'Home' ? '/' : `/${pathName}`} + > + {pathName} + </NavLink> + </div> + + {nameOfProduct && ( + <div className={styles.pathName}> + <p className={styles.pathArrow}> + <img src={arrowRight} alt="arrowRight" /> + </p> + <NavLink className={styles.link} to={`/${nameOfProduct}`}> + {nameOfProduct} + </NavLink> + </div> + )} + </div> + )} + {nameOfProduct && ( + <div className={styles.pathName}> + <p className={styles.pathArrowBack}> + <img src={arrowLeftWhite} alt="arrowLeftWhite" /> + </p> + <button className={styles.linkBack} onClick={() => navigate(-1)}> + Back + </button> + </div> + )} + </> + ); +}; diff --git a/src/modules/shared/components/Path/constants.ts b/src/modules/shared/components/Path/constants.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/modules/shared/components/Path/index.ts b/src/modules/shared/components/Path/index.ts new file mode 100644 index 00000000000..e1fb08e075d --- /dev/null +++ b/src/modules/shared/components/Path/index.ts @@ -0,0 +1 @@ +export { Path } from './Path'; diff --git a/src/modules/shared/components/ProductCard/ProductCard.module.scss b/src/modules/shared/components/ProductCard/ProductCard.module.scss new file mode 100644 index 00000000000..53c8b36a59d --- /dev/null +++ b/src/modules/shared/components/ProductCard/ProductCard.module.scss @@ -0,0 +1,231 @@ +@use './../../../../styles/variables.scss' as *; + +.card { + background-color: $secondary__background-color; + height: 506px; + + &__content { + display: flex; + flex-direction: column; + justify-content: space-between; + height: 100%; + width: 100%; + + gap: 8px; + padding: 32px; + } + + &__img { + display: flex; + align-items: center; + justify-content: center; + + width: 100%; + height: 196px; + } + &__img img { + max-width: 100%; + max-height: 100%; + object-fit: contain !important; + + transition: all 0.3s ease; + } + + &__img img:hover { + transform: scale(1.1); + } + + &__title { + font-family: 'Mont', sans-serif; + color: $primary__text-color; + font-size: 14px; + line-height: 21px; + font-weight: 600; + + margin-top: 16px; + + transition: all 0.3s ease; + } + + &__title:hover { + color: $accent__color-hover; + } + + &__price { + color: $primary__text-color; + font-size: 22px; + line-height: 140%; + font-weight: 800; + } + + &__line { + width: 100%; + height: 1px; + background-color: $secondary__element-color; + margin-bottom: 8px; + } +} + +.prices { + display: flex; + gap: 8px; + align-items: center; + letter-spacing: normal; +} + +.card__oldPrice { + color: $secondary__text-color; + font-size: 22px; + font-weight: 500; + position: relative; + letter-spacing: normal; +} + +.card__oldPrice::before { + position: absolute; + content: ''; + + width: 100%; + height: 2px; + background-color: $secondary__text-color; + top: 50%; + left: 0; +} + +.desc { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + + &__name { + color: $secondary__text-color; + font-size: $primary__text-size; + line-height: 100%; + } + + &__text { + color: $primary__text-color; + font-size: $primary__text-size; + line-height: 100%; + } +} + +.card__actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + height: 40px; +} + +.card__cart { + height: 128px; + display: flex; + gap: 16px; +} + +.card__content__cart { + display: flex; + justify-content: space-between; + gap: 24px; + align-items: center; + padding: 24px; + + width: 100%; + + background-color: $secondary__background-color; +} + +.delete__cart { + font-size: 16px; + color: $secondary__element-color; + cursor: pointer; +} + +.card__img__cart img { + height: 80px; + width: 80px; + object-fit: contain; +} + +.quantity { + display: flex; +} + +.card__title__cart { + width: 300px; + + font-family: 'Mont', sans-serif; + color: $primary__text-color; + font-size: 14px; + line-height: 21px; + font-weight: 600; + + transition: all 0.3s ease; +} + +.card__title__cart:hover { + color: $accent__color-hover; +} + +.card__price__cart { + width: 100px; + display: flex; + justify-content: center; + align-items: center; + + color: $primary__text-color; + font-size: 22px; + line-height: 140%; + font-weight: 800; +} + +.minus, +.plus { + width: 32px; + height: 32px; + border: 1px solid $secondary__element-color; + color: $primary__text-color; + background-color: $secondary__element-color; + + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + user-select: none; +} +.minus__disabled { + width: 32px; + height: 32px; + border: 1px solid $secondary__element-color; + color: $primary__text-color; + + display: flex; + justify-content: center; + align-items: center; +} + +.count { + width: 50px; + height: 32px; + color: $primary__text-color; + + user-select: none; + + display: flex; + justify-content: center; + align-items: center; +} + +@media (max-width: 1199px) { + .card { + width: 100%; + } +} + +@media (max-width: 639px) { + .card { + width: 100%; + } +} diff --git a/src/modules/shared/components/ProductCard/ProductCard.tsx b/src/modules/shared/components/ProductCard/ProductCard.tsx new file mode 100644 index 00000000000..e874bb0b830 --- /dev/null +++ b/src/modules/shared/components/ProductCard/ProductCard.tsx @@ -0,0 +1,135 @@ +import { FC, SetStateAction } from 'react'; +import styles from './ProductCard.module.scss'; +import { Link } from 'react-router-dom'; +import { Favorite } from '../Favorite'; +import { AddToCart } from '../AddToCart'; +import { useAppDispatch, useAppSelector } from '../../../../app/hooks'; +import { cartSlice } from '../../../../features/cart/cartSlice'; + +type Props = { + title: string; + fullPrice: number; + descScreen: string; + descCapacity: string; + descRAM: string; + img: string; + currentPrice: number; + type?: 'hot' | 'new'; + productId?: string; + cart: boolean; + pathName: string; +}; + +export const ProductCard: FC<Props> = ({ + title, + fullPrice, + descScreen, + descCapacity, + descRAM, + img, + currentPrice, + type, + productId, + cart, + pathName, +}) => { + const dispatch = useAppDispatch(); + + const productInCart = useAppSelector(state => + state.cart.find(item => item.id === productId), + ); + + const count = productInCart ? productInCart.count : 1; + const price = productInCart ? productInCart.price : currentPrice; + + return ( + <article className={cart ? styles.card__cart : styles.card}> + <div className={cart ? styles.card__content__cart : styles.card__content}> + {cart && ( + <button + onClick={() => { + dispatch(cartSlice.actions.removeProduct(productId)); + }} + className={styles.delete__cart} + > + ✕ + </button> + )} + <Link + to={`/product/${productId}`} + state={{ from: pathName }} + className={cart ? styles.card__img__cart : styles.card__img} + > + <img loading="lazy" src={img} alt="product image" /> + </Link> + <Link to={`/product/${productId}`}> + <h4 className={cart ? styles.card__title__cart : styles.card__title}> + {title} + </h4> + </Link> + {cart && ( + <div className={styles.quantity}> + <div + onClick={() => { + if (count > 1) { + dispatch(cartSlice.actions.minusCount(productId)); + } + }} + className={count === 1 ? styles.minus__disabled : styles.minus} + > + - + </div> + <div className={styles.count}>{count}</div> + <div + onClick={() => { + dispatch(cartSlice.actions.plusCount(productId)); + }} + className={styles.plus} + > + + + </div> + </div> + )} + + {cart && ( + <p className={cart ? styles.card__price__cart : styles.card__price}> + ${Number(price) * count} + </p> + )} + {!cart && + (type === 'new' ? ( + <p className={styles.card__price}>${fullPrice}</p> + ) : ( + <div className={styles.prices}> + <p className={styles.card__price}>${currentPrice}</p> + <p className={styles.card__oldPrice}>${fullPrice}</p> + </div> + ))} + + {!cart && ( + <> + <span className={styles.card__line}></span> + <div className={styles.card__desc}> + <div className={styles.desc}> + <h6 className={styles.desc__name}>Screen</h6> + <p className={styles.desc__text}>{descScreen}</p> + </div> + <div className={styles.desc}> + <h6 className={styles.desc__name}>Capacity</h6> + <p className={styles.desc__text}>{descCapacity}</p> + </div> + <div className={styles.desc}> + <h6 className={styles.desc__name}>RAM</h6> + <p className={styles.desc__text}>{descRAM}</p> + </div> + </div> + <div className={styles.card__actions}> + <AddToCart productId={productId} /> + <Favorite productId={productId} /> + </div> + </> + )} + </div> + </article> + ); +}; diff --git a/src/modules/shared/components/ProductCard/constants.ts b/src/modules/shared/components/ProductCard/constants.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/modules/shared/components/ProductCard/index.ts b/src/modules/shared/components/ProductCard/index.ts new file mode 100644 index 00000000000..c4f2778191c --- /dev/null +++ b/src/modules/shared/components/ProductCard/index.ts @@ -0,0 +1 @@ +export { ProductCard } from './ProductCard'; diff --git a/src/modules/shared/components/ProductSection/ProductSection.module.scss b/src/modules/shared/components/ProductSection/ProductSection.module.scss new file mode 100644 index 00000000000..64c19290eb2 --- /dev/null +++ b/src/modules/shared/components/ProductSection/ProductSection.module.scss @@ -0,0 +1,59 @@ +@use './../../../../styles/global.scss' as *; +@use './../../../../styles/variables.scss' as *; + +.product { + padding: 40px 0; +} + +.product__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; +} + +.nav__buttons { + display: flex; + gap: 16px; +} + +.prev, +.next { + display: flex; + align-items: center; + justify-content: center; + text-align: center; + font-size: 24px; + + width: 32px; + height: 32px; + + color: $primary__text-color; + background-color: $secondary__element-color; + + transition: background-color 0.3s ease; +} + +.prev:hover, +.next:hover { + background-color: $accent__color; +} + +.disabled { + background-color: $primary__background-color; + border: 1px solid $secondary__element-color; + pointer-events: none; + &:hover { + background-color: $primary__background-color; + } +} + +@media (max-width: 639px) { + .product { + width: calc(100vw - 40px); + padding: 20px 0; + } + .product__header { + gap: 20px; + } +} diff --git a/src/modules/shared/components/ProductSection/ProductSection.tsx b/src/modules/shared/components/ProductSection/ProductSection.tsx new file mode 100644 index 00000000000..9963b4db9d8 --- /dev/null +++ b/src/modules/shared/components/ProductSection/ProductSection.tsx @@ -0,0 +1,71 @@ +import { FC, useRef, useState } from 'react'; +import styles from './ProductSection.module.scss'; +import products from './../../../../../public/api/products.json'; +import { ProductSlider } from '../ProductSlider'; +import classNames from 'classnames'; +import './../../../../styles/global.scss'; +import arrowRight from './../../../../../public/img/icons/arrowRightWhite.svg'; +import arrowLeft from './../../../../../public/img/icons/arrowLeftWhite.svg'; + +type Props = { + title: string; + type: 'hot' | 'new'; +}; + +export const ProductSection: FC<Props> = ({ title, type }) => { + const prevRef = useRef<HTMLButtonElement | null>(null); + const nextRef = useRef<HTMLButtonElement | null>(null); + + const [currentElIndex, seCurrentElIndex] = useState<number>(0); + + const sortedProducts = [...products].sort((a, b) => { + if (type === 'new') { + return b.year - a.year; + } + + if (type === 'hot') { + const discountA = a.fullPrice - a.price; + const discountB = b.fullPrice - b.price; + + return discountB - discountA; + } + + return 0; + }); + + return ( + <section className={styles.product}> + <div className={styles.product__header}> + <h2 className="h2title">{title}</h2> + + <div className={styles.nav__buttons}> + <button + ref={prevRef} + className={classNames(styles.prev, { + [styles.disabled]: currentElIndex === 0, + })} + aria-label="Prev" + > + <img src={arrowLeft} alt="arrowLeft" /> + </button> + <button + ref={nextRef} + className={classNames(styles.next, { + [styles.disabled]: currentElIndex === products.length - 4, + })} + aria-label="Next" + > + <img src={arrowRight} alt="arrowRight" /> + </button> + </div> + </div> + <ProductSlider + products={sortedProducts} + seCurrentElIndex={seCurrentElIndex} + prevRef={prevRef} + nextRef={nextRef} + type={type} + /> + </section> + ); +}; diff --git a/src/modules/shared/components/ProductSection/constants.ts b/src/modules/shared/components/ProductSection/constants.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/modules/shared/components/ProductSection/index.ts b/src/modules/shared/components/ProductSection/index.ts new file mode 100644 index 00000000000..94d7a8e8d40 --- /dev/null +++ b/src/modules/shared/components/ProductSection/index.ts @@ -0,0 +1 @@ +export { ProductSection } from './ProductSection'; diff --git a/src/modules/shared/components/ProductSlider/ProductSlider.module.scss b/src/modules/shared/components/ProductSlider/ProductSlider.module.scss new file mode 100644 index 00000000000..f791d62969c --- /dev/null +++ b/src/modules/shared/components/ProductSlider/ProductSlider.module.scss @@ -0,0 +1,45 @@ +/* stylelint-disable selector-type-no-unknown */ +.product__slider { + :global(.swiper) { + width: 100% !important; + height: 100%; + margin: auto; + + overflow: hidden; + } + + :global(.swiper-slide) { + width: 270px; + } + + :global(.swiper-slide img) { + display: block; + margin: 0 auto; + + width: 100%; + height: 100%; + + object-fit: cover; + } +} + +@media (max-width: 1199px) { + .product__slider { + .slider__wrapper { + max-width: calc(100vw - 40px) !important; + margin: 0 auto; + } + } +} + +@media (max-width: 639px) { + .product__slider { + .slider__wrapper { + max-width: calc(100vw) !important; + } + + :global(.swiper-slide) { + width: 212px !important; + } + } +} diff --git a/src/modules/shared/components/ProductSlider/ProductSlider.tsx b/src/modules/shared/components/ProductSlider/ProductSlider.tsx new file mode 100644 index 00000000000..d57fc784756 --- /dev/null +++ b/src/modules/shared/components/ProductSlider/ProductSlider.tsx @@ -0,0 +1,69 @@ +/* eslint-disable no-param-reassign */ +import styles from './ProductSlider.module.scss'; +import { Navigation, Autoplay, Pagination } from 'swiper/modules'; + +import { Swiper, SwiperSlide } from 'swiper/react'; + +import 'swiper/css'; +import 'swiper/css/navigation'; +import 'swiper/css/pagination'; + +import React, { FC, SetStateAction } from 'react'; +import { ProductCard } from '../ProductCard'; +import { Product } from '../../../../types/Product'; + +type Props = { + products: Product[]; + prevRef: React.RefObject<HTMLButtonElement>; + nextRef: React.RefObject<HTMLButtonElement>; + seCurrentElIndex: React.Dispatch<SetStateAction<number>>; + type: 'hot' | 'new'; +}; + +export const ProductSlider: FC<Props> = ({ + products, + nextRef, + prevRef, + seCurrentElIndex, + type, +}) => { + return ( + <div className={styles.product__slider}> + <div className={styles.slider__wrapper}> + <Swiper + loop={false} + modules={[Navigation, Autoplay, Pagination]} + spaceBetween={16} + slidesPerView="auto" + onSlideChange={swiper => { + seCurrentElIndex(swiper.realIndex); + }} + onBeforeInit={swiper => { + swiper.params.navigation!.prevEl = prevRef.current; + swiper.params.navigation!.nextEl = nextRef.current; + }} + navigation + > + {products.map(product => { + return ( + <SwiperSlide key={product.id}> + <ProductCard + key={product.id} + title={product.name} + fullPrice={product.fullPrice} + currentPrice={product.price} + descScreen={product.screen} + descCapacity={product.capacity} + descRAM={product.ram} + img={product.image} + type={type} + productId={product.itemId} + /> + </SwiperSlide> + ); + })} + </Swiper> + </div> + </div> + ); +}; diff --git a/src/modules/shared/components/ProductSlider/constants.ts b/src/modules/shared/components/ProductSlider/constants.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/modules/shared/components/ProductSlider/index.ts b/src/modules/shared/components/ProductSlider/index.ts new file mode 100644 index 00000000000..a1489c8d445 --- /dev/null +++ b/src/modules/shared/components/ProductSlider/index.ts @@ -0,0 +1 @@ +export { ProductSlider } from './ProductSlider'; diff --git a/src/modules/shared/components/Title/Title.module.scss b/src/modules/shared/components/Title/Title.module.scss new file mode 100644 index 00000000000..c089580c457 --- /dev/null +++ b/src/modules/shared/components/Title/Title.module.scss @@ -0,0 +1,43 @@ +@use './../../../../styles/global.scss' as *; +@use './../../../../styles/variables.scss' as *; + +.h1__title { + align-self: flex-start; + font-size: 48px; + font-weight: 800; + letter-spacing: -1%; + line-height: 56px; + margin-bottom: 8px; + color: $primary__text-color; + margin-top: 20px; +} + +.h1__title__small { + align-self: flex-start; + font-size: 32px; + font-weight: 800; + letter-spacing: -1%; + line-height: 41px; + margin-bottom: 40px; + margin-top: 16px; + color: $primary__text-color; +} + +.text { + color: $secondary__text-color; + font-size: 14px; + font-weight: 600; + line-height: 21px; + // margin-bottom: 40px; +} + +@media (max-width: 639px) { + .h1__title { + font-size: 32px; + } + .h1__title__small { + font-size: 22px; + line-height: 140%; + margin-bottom: 32px; + } +} diff --git a/src/modules/shared/components/Title/Title.tsx b/src/modules/shared/components/Title/Title.tsx new file mode 100644 index 00000000000..17abe123f2f --- /dev/null +++ b/src/modules/shared/components/Title/Title.tsx @@ -0,0 +1,21 @@ +import './../../../../styles/global.scss'; +import styles from './Title.module.scss'; +import { FC } from 'react'; + +type Props = { + title: string; + amountPage?: number; +}; + +export const Title: FC<Props> = ({ title, amountPage }) => { + return ( + <> + <h1 + className={amountPage >= 0 ? styles.h1__title : styles.h1__title__small} + > + {title} + </h1> + {amountPage > 0 && <p className={styles.text}>{amountPage} models</p>} + </> + ); +}; diff --git a/src/modules/shared/components/Title/index.ts b/src/modules/shared/components/Title/index.ts new file mode 100644 index 00000000000..177576b64e1 --- /dev/null +++ b/src/modules/shared/components/Title/index.ts @@ -0,0 +1 @@ +export { Title } from './Title'; diff --git a/src/styles/fonts.scss b/src/styles/fonts.scss new file mode 100644 index 00000000000..9033e77f49b --- /dev/null +++ b/src/styles/fonts.scss @@ -0,0 +1,20 @@ +@font-face { + font-family: 'Monti-Regular'; + src: url('./../../public/fonts/Mont-Regular.otf') format('opentype'); + font-weight: normal; + font-style: normal; +} + +@font-face { + font-family: 'Monti-SemiBold'; + src: url('./../../public/fonts/Mont-SemiBold.otf') format('opentype'); + font-weight: normal; + font-style: normal; +} + +@font-face { + font-family: 'Monti-Bold'; + src: url('./../../public/fonts/Mont-Bold.otf') format('opentype'); + font-weight: normal; + font-style: normal; +} diff --git a/src/styles/global.scss b/src/styles/global.scss new file mode 100644 index 00000000000..c1fc449cbb4 --- /dev/null +++ b/src/styles/global.scss @@ -0,0 +1,99 @@ +@use './variables.scss' as *; +@use './reset.scss' as *; +@use './fonts.scss' as *; + +body { + font-family: $primary__font-family; + background-color: $primary__background-color; + font-weight: 700; + letter-spacing: 1px; + font-size: $primary__text-size; +} + +html, +body { + height: 100%; + margin: 0; + scroll-behavior: smooth; +} + +.page { + min-height: 100vh; + display: flex; + flex-direction: column; + + text-align: left; + animation: fade-in 0.3s ease-in-out; +} + +@keyframes fade-in { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.app { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.main { + flex: 1; +} + +.container { + display: flex; + flex-direction: column; + width: 100%; + + height: 100%; + + max-width: 1176px; + padding: 0 20px; + margin: 0 auto; + text-align: left; +} + +@media (max-width: 559px) { + .container { + padding: 0 15px; + max-width: 100vw; + } +} + +.h2title { + font-size: $label__text-size; + color: $primary__text-color; + text-align: left; +} + +.message { + color: $secondary__text-color; + font-size: 14px; + margin: 10px 0; +} + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + clip-path: inset(50%); + border: 0; + white-space: nowrap; +} + +@media (max-width: 639px) { + .h2title { + font-size: 22px; + } +} diff --git a/src/styles/reset.scss b/src/styles/reset.scss new file mode 100644 index 00000000000..7b41386c55a --- /dev/null +++ b/src/styles/reset.scss @@ -0,0 +1,105 @@ +* { + padding: 0; + margin: 0; + border: none; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +/* Links */ +a, +a:link, +a:visited { + text-decoration: none; +} + +a:hover { + text-decoration: none; +} + +/* Common */ +aside, +nav, +footer, +header, +section, +main { + display: block; +} + +h1, +h2, +h3, +h4, +h5, +h6, +p { + font-size: inherit; + font-weight: inherit; +} + +ul, +ul li { + list-style: none; +} + +img { + vertical-align: top; +} + +img, +svg { + max-width: 100%; + height: auto; +} + +address { + font-style: normal; +} + +/* Form */ +input, +textarea, +button, +select { + font-family: inherit; + font-size: inherit; + color: inherit; + background-color: transparent; +} + +input::-ms-clear { + display: none; +} + +button, +input[type='submit'] { + display: inline-block; + box-shadow: none; + background-color: transparent; + cursor: pointer; +} + +input:focus, +input:active, +button:focus, +button:active { + outline: none; +} + +button::-moz-focus-inner { + padding: 0; + border: 0; +} + +label { + cursor: pointer; +} + +legend { + display: block; +} diff --git a/src/styles/variables.scss b/src/styles/variables.scss new file mode 100644 index 00000000000..72547bbc793 --- /dev/null +++ b/src/styles/variables.scss @@ -0,0 +1,15 @@ +$primary__font-family: 'Mont', sans-serif; +$primary__background-color: #0f1121; +$primary__text-color: #f1f2f9; +$primary__text-size: 12px; + +$secondary__element-color: #323542; +$secondary__text-color: #75767f; +$secondary__background-color: #161827; + +$accent__color: #905bff; +$accent__color-hover: #a378ff; + +$body__text-size: 14px; + +$label__text-size: 32px; diff --git a/src/types/Category.ts b/src/types/Category.ts new file mode 100644 index 00000000000..973cc3e7be8 --- /dev/null +++ b/src/types/Category.ts @@ -0,0 +1,8 @@ +export type Category = { + id: number; + name: string; + href: string; + img: string; + desc: string; + bgColor: string; +}; diff --git a/src/types/ItemsPerPage.ts b/src/types/ItemsPerPage.ts new file mode 100644 index 00000000000..93c8baf8356 --- /dev/null +++ b/src/types/ItemsPerPage.ts @@ -0,0 +1 @@ +export type ItemsPerPage = '4' | '8' | '16' | 'all'; diff --git a/src/types/Product.ts b/src/types/Product.ts new file mode 100644 index 00000000000..8111167715a --- /dev/null +++ b/src/types/Product.ts @@ -0,0 +1,14 @@ +export type 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; +}; diff --git a/src/types/ProductDescriptions.ts b/src/types/ProductDescriptions.ts new file mode 100644 index 00000000000..99d0d54e62a --- /dev/null +++ b/src/types/ProductDescriptions.ts @@ -0,0 +1,4 @@ +export type ProductDescriptions = { + title: string; + text: string[]; +}; diff --git a/src/types/ProductFullInfo.ts b/src/types/ProductFullInfo.ts new file mode 100644 index 00000000000..316cc7d1082 --- /dev/null +++ b/src/types/ProductFullInfo.ts @@ -0,0 +1,34 @@ +export type ProductFullInfo = { + id: string; + category: string; + namespaceId: string; + name: string; + capacityAvailable: string[]; + capacity: string; + priceRegular: number; + priceDiscount: number; + colorsAvailable: string[]; + color: string; + images: string[]; + description: [ + { + title: string; + text: string[]; + }, + { + title: string; + text: string[]; + }, + { + title: string; + text: string[]; + }, + ]; + screen: string; + resolution: string; + processor: string; + ram: string; + camera: string; + zoom: string; + cell: []; +}; diff --git a/src/types/SortName.ts b/src/types/SortName.ts new file mode 100644 index 00000000000..8a4176bb872 --- /dev/null +++ b/src/types/SortName.ts @@ -0,0 +1 @@ +export type SortName = 'age' | 'title' | 'price';