diff --git a/.eslintrc.cjs b/.eslintrc.cjs index b51149cf57d..dea7c09213b 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -67,6 +67,7 @@ module.exports = { 'react/prop-types': 0, 'react/require-default-props': 0, 'import/prefer-default-export': 0, + 'import/extensions': 'off', 'standard/no-callback-literal': 0, 'react/jsx-filename-extension': [1, { extensions: ['.tsx'] }], 'react/destructuring-assignment': 0, diff --git a/.stylelintrc.js b/.stylelintrc.js index f3a4e74272a..15c2da755ce 100644 --- a/.stylelintrc.js +++ b/.stylelintrc.js @@ -1,4 +1,56 @@ module.exports = { extends: "@mate-academy/stylelint-config", - rules: {} + plugins: ["stylelint-order"], + rules: { + 'selector-pseudo-class-no-unknown': [true, { ignorePseudoClasses: ['global', 'local'] }], + 'order/properties-order': [ + { + groupName: 'positioning', + properties: ['position', 'top', 'right', 'bottom', 'left', 'z-index'], + }, + { + groupName: 'box-model', + properties: [ + 'display', + 'flex', 'flex-direction', 'flex-wrap', 'flex-flow', + 'flex-grow', 'flex-shrink', 'flex-basis', + 'justify-content', 'justify-items', 'justify-self', + 'align-content', 'align-items', 'align-self', + 'grid', 'grid-template', 'grid-template-columns', 'grid-template-rows', + 'grid-template-areas', 'grid-area', 'grid-column', 'grid-row', + 'gap', 'column-gap', 'row-gap', + 'width', 'min-width', 'max-width', + 'height', 'min-height', 'max-height', + 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', + 'padding-inline', 'padding-block', + 'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', + 'margin-inline', 'margin-block', + 'overflow', 'overflow-x', 'overflow-y', + ], + }, + { + groupName: 'visual', + properties: [ + 'background', 'background-color', 'background-image', + 'background-position', 'background-size', 'background-repeat', + 'border', 'border-top', 'border-right', 'border-bottom', 'border-left', + 'border-width', 'border-style', 'border-color', 'border-radius', + 'box-shadow', 'outline', 'opacity', 'visibility', 'cursor', + ], + }, + { + groupName: 'typography', + properties: [ + 'font', 'font-family', 'font-size', 'font-weight', 'font-style', + 'line-height', 'letter-spacing', 'text-align', 'text-decoration', + 'text-transform', 'text-overflow', 'white-space', 'word-break', + 'color', + ], + }, + { + groupName: 'transitions', + properties: ['transition', 'animation', 'transform'], + }, + ], + }, }; diff --git a/package-lock.json b/package-lock.json index 836b9e63b46..34c97235269 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,11 +16,12 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.25.1", - "react-transition-group": "^4.4.5" + "react-transition-group": "^4.4.5", + "swiper": "^12.1.3" }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^2.1.3", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", @@ -46,6 +47,7 @@ "prettier": "^3.3.2", "sass": "^1.77.8", "stylelint": "^16.7.0", + "stylelint-order": "^8.1.1", "typescript": "^5.2.2", "vite": "^5.3.1" } @@ -424,6 +426,67 @@ "node": ">=6.9.0" } }, + "node_modules/@cacheable/memory": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.8.tgz", + "integrity": "sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.4.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/memory/node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/memory/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.4.1.tgz", + "integrity": "sha512-eiFgzCbIneyMlLOmNG4g9xzF7Hv3Mga4LjxjcSC/ues6VYq2+gUbQI8JqNuw/ZM8tJIeIaBGpswAsqV2V7ApgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -449,6 +512,7 @@ "url": "https://opencollective.com/csstools" } ], + "peer": true, "engines": { "node": "^14 || ^16 || >=18" }, @@ -471,6 +535,7 @@ "url": "https://opencollective.com/csstools" } ], + "peer": true, "engines": { "node": "^14 || ^16 || >=18" } @@ -490,6 +555,7 @@ "url": "https://opencollective.com/csstools" } ], + "peer": true, "engines": { "node": "^14 || ^16 || >=18" }, @@ -513,6 +579,7 @@ "url": "https://opencollective.com/csstools" } ], + "peer": true, "engines": { "node": "^14 || ^16 || >=18" }, @@ -587,13 +654,14 @@ } }, "node_modules/@dual-bundle/import-meta-resolve": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz", - "integrity": "sha512-+nxncfwHM5SgAtrVzgpzJOI1ol0PkumhVo469KCf9lUi21IGcY90G98VuHm9VRrUypmAzawAHO9bs6hqeADaVg==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.2.1.tgz", + "integrity": "sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==", "dev": true, + "license": "MIT", "funding": { "type": "github", - "url": "https://github.com/sponsors/wooorm" + "url": "https://github.com/sponsors/JounQin" } }, "node_modules/@esbuild/aix-ppc64": { @@ -1183,11 +1251,19 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, "node_modules/@mate-academy/scripts": { - "version": "1.8.5", - "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.8.5.tgz", - "integrity": "sha512-mHRY2FkuoYCf5U0ahIukkaRo5LSZsxrTSgMJheFoyf3VXsTvfM9OfWcZIDIDB521kdPrScHHnRp+JRNjCfUO5A==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-2.1.3.tgz", + "integrity": "sha512-a07wHTj/1QUK2Aac5zHad+sGw4rIvcNl5lJmJpAD7OxeSbnCdyI6RXUHwXhjF5MaVo9YHrJ0xVahyERS2IIyBQ==", "dev": true, + "license": "MIT", "dependencies": { "@octokit/rest": "^17.11.2", "@types/get-port": "^4.2.0", @@ -3080,6 +3156,30 @@ "resolved": "https://registry.npmjs.org/bulma/-/bulma-1.0.1.tgz", "integrity": "sha512-+xv/BIAEQakHkR0QVz+s+RjNqfC53Mx9ZYexyaFNFo9wx5i76HXArNdwW7bccyJxa5mgV/T5DcVGqsAB19nBJQ==" }, + "node_modules/cacheable": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.4.tgz", + "integrity": "sha512-djgxybDbw9fL/ZWMI3+CE8ZilNxcwFkVtDc1gJ+IlOSSWkSMPQabhV/XCHTQ6pwwN6aivXPZ43omTooZiX06Ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.0.8", + "@cacheable/utils": "^2.4.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.9.0" + } + }, + "node_modules/cacheable/node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, "node_modules/cachedir": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.4.0.tgz", @@ -3477,12 +3577,13 @@ } }, "node_modules/css-functions-list": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.2.2.tgz", - "integrity": "sha512-c+N0v6wbKVxTu5gOBBFkr9BEdBWaqqjQeiJ8QvSRIJOf+UxlJh930m8e6/WNeODIK0mYLFkoONrnj16i2EcvfQ==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.3.3.tgz", + "integrity": "sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==", "dev": true, + "license": "MIT", "engines": { - "node": ">=12 || >=16" + "node": ">=12" } }, "node_modules/css-tree": { @@ -3490,6 +3591,7 @@ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", "dev": true, + "peer": true, "dependencies": { "mdn-data": "2.0.30", "source-map-js": "^1.0.1" @@ -3751,12 +3853,13 @@ "dev": true }, "node_modules/debug": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", - "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -5034,16 +5137,17 @@ "dev": true }, "node_modules/fast-glob": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", - "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", "dev": true, + "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", - "micromatch": "^4.0.4" + "micromatch": "^4.0.8" }, "engines": { "node": ">=8.6.0" @@ -5229,10 +5333,11 @@ } }, "node_modules/flatted": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", - "dev": true + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" }, "node_modules/for-each": { "version": "0.3.3", @@ -5817,6 +5922,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -5839,6 +5957,13 @@ "he": "bin/he" } }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -6703,10 +6828,11 @@ } }, "node_modules/known-css-properties": { - "version": "0.34.0", - "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.34.0.tgz", - "integrity": "sha512-tBECoUqNFbyAY4RrbqsBQqDFpGXAEbdD5QKr8kACx3+rnArmuuR22nKQWKazvp07N9yjTyDZaw/20UIH8tL9DQ==", - "dev": true + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.37.0.tgz", + "integrity": "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==", + "dev": true, + "license": "MIT" }, "node_modules/language-subtag-registry": { "version": "0.3.23", @@ -7101,7 +7227,8 @@ "version": "2.0.30", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", - "dev": true + "dev": true, + "peer": true }, "node_modules/meow": { "version": "13.2.0", @@ -7131,10 +7258,11 @@ } }, "node_modules/micromatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", - "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, + "license": "MIT", "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" @@ -7304,13 +7432,6 @@ "node": ">=10" } }, - "node_modules/mocha/node_modules/ms": { - "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 - }, "node_modules/mocha/node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -7828,15 +7949,16 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true, "funding": [ { @@ -7844,6 +7966,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -8313,10 +8436,11 @@ "dev": true }, "node_modules/picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", - "dev": true + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.1", @@ -8434,9 +8558,9 @@ } }, "node_modules/postcss": { - "version": "8.4.39", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz", - "integrity": "sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "dev": true, "funding": [ { @@ -8452,10 +8576,11 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.0.1", - "source-map-js": "^1.2.0" + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -8468,15 +8593,16 @@ "dev": true }, "node_modules/postcss-resolve-nested-selector": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.1.tgz", - "integrity": "sha512-HvExULSwLqHLgUy1rl3ANIqCsvMS0WHss2UOsXhXnQaZ9VCc2oBvIpXrl00IUFT5ZDITME0o6oiXeiHr2SAIfw==", - "dev": true + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.6.tgz", + "integrity": "sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==", + "dev": true, + "license": "MIT" }, "node_modules/postcss-safe-parser": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.0.tgz", - "integrity": "sha512-ovehqRNVCpuFzbXoTb4qLtyzK3xn3t/CUBxOs8LsnQjQrShaB4lKiHoVqY8ANaC0hBMHq5QVWk77rwGklFUDrg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", + "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", "dev": true, "funding": [ { @@ -8492,6 +8618,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "engines": { "node": ">=18.0" }, @@ -8538,6 +8665,16 @@ "node": ">=4" } }, + "node_modules/postcss-sorting": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/postcss-sorting/-/postcss-sorting-10.0.0.tgz", + "integrity": "sha512-TXbU+h6vVRW+86c/+ewhWq9k7pr7ijASTnepVhCQiC87zAOTkvB1v2dHyWP+ggstSTX/PNvjzS+IOqzejndz9w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "postcss": "^8.4.20" + } + }, "node_modules/postcss-value-parser": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", @@ -8642,6 +8779,26 @@ "node": ">=6" } }, + "node_modules/qified": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.9.1.tgz", + "integrity": "sha512-n7mar4T0xQ+39dE2vGTAlbxUEpndwPANH0kDef1/MYsB8Bba9wshkybIRx74qgcvKQPEWErf9AqAdYjhzY2Ilg==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.1.1.tgz", + "integrity": "sha512-AHb76R16GB5EsPBE2J7Ko5kiEyXwviB9P5SMrAKcuAu4vJPZttViAbj9+tZeaQE5zjDme+1vcHP78Yj/WoAveA==", + "dev": true, + "license": "MIT" + }, "node_modules/qs": { "version": "6.10.4", "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.4.tgz", @@ -9454,10 +9611,11 @@ "dev": true }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -9737,9 +9895,9 @@ "peer": true }, "node_modules/stylelint": { - "version": "16.7.0", - "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.7.0.tgz", - "integrity": "sha512-Q1ATiXlz+wYr37a7TGsfvqYn2nSR3T/isw3IWlZQzFzCNoACHuGBb6xBplZXz56/uDRJHIygxjh7jbV/8isewA==", + "version": "16.26.1", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.26.1.tgz", + "integrity": "sha512-v20V59/crfc8sVTAtge0mdafI3AdnzQ2KsWe6v523L4OA1bJO02S7MO2oyXDCS6iWb9ckIPnqAFVItqSBQr7jw==", "dev": true, "funding": [ { @@ -9751,45 +9909,46 @@ "url": "https://github.com/sponsors/stylelint" } ], - "dependencies": { - "@csstools/css-parser-algorithms": "^2.7.1", - "@csstools/css-tokenizer": "^2.4.1", - "@csstools/media-query-list-parser": "^2.1.13", - "@csstools/selector-specificity": "^3.1.1", - "@dual-bundle/import-meta-resolve": "^4.1.0", + "license": "MIT", + "dependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-syntax-patches-for-csstree": "^1.0.19", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/media-query-list-parser": "^4.0.3", + "@csstools/selector-specificity": "^5.0.0", + "@dual-bundle/import-meta-resolve": "^4.2.1", "balanced-match": "^2.0.0", "colord": "^2.9.3", "cosmiconfig": "^9.0.0", - "css-functions-list": "^3.2.2", - "css-tree": "^2.3.1", - "debug": "^4.3.5", - "fast-glob": "^3.3.2", + "css-functions-list": "^3.2.3", + "css-tree": "^3.1.0", + "debug": "^4.4.3", + "fast-glob": "^3.3.3", "fastest-levenshtein": "^1.0.16", - "file-entry-cache": "^9.0.0", + "file-entry-cache": "^11.1.1", "global-modules": "^2.0.0", "globby": "^11.1.0", "globjoin": "^0.1.4", "html-tags": "^3.3.1", - "ignore": "^5.3.1", + "ignore": "^7.0.5", "imurmurhash": "^0.1.4", "is-plain-object": "^5.0.0", - "known-css-properties": "^0.34.0", + "known-css-properties": "^0.37.0", "mathml-tag-names": "^2.1.3", "meow": "^13.2.0", - "micromatch": "^4.0.7", + "micromatch": "^4.0.8", "normalize-path": "^3.0.0", - "picocolors": "^1.0.1", - "postcss": "^8.4.39", - "postcss-resolve-nested-selector": "^0.1.1", - "postcss-safe-parser": "^7.0.0", - "postcss-selector-parser": "^6.1.0", + "picocolors": "^1.1.1", + "postcss": "^8.5.6", + "postcss-resolve-nested-selector": "^0.1.6", + "postcss-safe-parser": "^7.0.1", + "postcss-selector-parser": "^7.1.0", "postcss-value-parser": "^4.2.0", "resolve-from": "^5.0.0", "string-width": "^4.2.3", - "strip-ansi": "^7.1.0", - "supports-hyperlinks": "^3.0.0", + "supports-hyperlinks": "^3.2.0", "svg-tags": "^1.0.0", - "table": "^6.8.2", + "table": "^6.9.0", "write-file-atomic": "^5.0.1" }, "bin": { @@ -9799,16 +9958,136 @@ "node": ">=18.12.0" } }, - "node_modules/stylelint/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "node_modules/stylelint-order": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/stylelint-order/-/stylelint-order-8.1.1.tgz", + "integrity": "sha512-LqsEB6VggJuu5v10RtkrQsBObcdwBE7GuAOlwfc/LR3VL/w8UqKX2BOLIjhyGt0Gne/njo7gRNGiJAKhfmPMNw==", "dev": true, + "license": "MIT", + "dependencies": { + "postcss": "^8.5.8", + "postcss-sorting": "^10.0.0" + }, "engines": { - "node": ">=12" + "node": ">=20.19.0" }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "peerDependencies": { + "stylelint": "^16.18.0 || ^17.0.0" + } + }, + "node_modules/stylelint/node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/stylelint/node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz", + "integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/stylelint/node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/stylelint/node_modules/@csstools/media-query-list-parser": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-4.0.3.tgz", + "integrity": "sha512-HAYH7d3TLRHDOUQK4mZKf9k9Ph/m8Akstg66ywKR4SFAigjs3yBiUeZtFxywiTm5moZMAp/5W/ZuFnNXXYLuuQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/stylelint/node_modules/@csstools/selector-specificity": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz", + "integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss-selector-parser": "^7.0.0" } }, "node_modules/stylelint/node_modules/balanced-match": { @@ -9817,29 +10096,71 @@ "integrity": "sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==", "dev": true }, - "node_modules/stylelint/node_modules/file-entry-cache": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-9.0.0.tgz", - "integrity": "sha512-6MgEugi8p2tiUhqO7GnPsmbCCzj0YRCwwaTbpGRyKZesjRSzkqkAE9fPp7V2yMs5hwfgbQLgdvSSkGNg1s5Uvw==", + "node_modules/stylelint/node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "dev": true, + "license": "MIT", "dependencies": { - "flat-cache": "^5.0.0" + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" }, "engines": { - "node": ">=18" + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/stylelint/node_modules/file-entry-cache": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.2.tgz", + "integrity": "sha512-N2WFfK12gmrK1c1GXOqiAJ1tc5YE+R53zvQ+t5P8S5XhnmKYVB5eZEiLNZKDSmoG8wqqbF9EXYBBW/nef19log==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.20" } }, "node_modules/stylelint/node_modules/flat-cache": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-5.0.0.tgz", - "integrity": "sha512-JrqFmyUl2PnPi1OvLyTVHnQvwQ0S+e6lGSwu8OkAZlSaNIZciTY2H/cOOROxsBA1m/LZNHDsqAgDZt6akWcjsQ==", + "version": "6.1.22", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.22.tgz", + "integrity": "sha512-N2dnzVJIphnNsjHcrxGW7DePckJ6haPrSFqpsBUhHYgwtKGVq4JrBGielEGD2fCVnsGm1zlBVZ8wGhkyuetgug==", "dev": true, + "license": "MIT", "dependencies": { - "flatted": "^3.3.1", - "keyv": "^4.5.4" + "cacheable": "^2.3.4", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/stylelint/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/stylelint/node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/stylelint/node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" }, "engines": { - "node": ">=18" + "node": ">=4" } }, "node_modules/stylelint/node_modules/resolve-from": { @@ -9851,21 +10172,6 @@ "node": ">=8" } }, - "node_modules/stylelint/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -9879,16 +10185,20 @@ } }, "node_modules/supports-hyperlinks": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.0.0.tgz", - "integrity": "sha512-QBDPHyPQDRTy9ku4URNGY5Lah8PAaXs6tAAwp55sL5WCsSW7GIfdf6W5ixfziW+t7wh3GVvHyHHyQ1ESsoRvaA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", + "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" }, "engines": { "node": ">=14.18" + }, + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" } }, "node_modules/supports-hyperlinks/node_modules/has-flag": { @@ -9930,6 +10240,25 @@ "integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==", "dev": true }, + "node_modules/swiper": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/swiper/-/swiper-12.1.3.tgz", + "integrity": "sha512-XcWlVmkHFICI4fuoJKgbp8PscDcS4i7pBH8nwJRBi3dpQvhCySwsWRYm4bOf/BzKVWkHOYaFw7qz9uBSrY3oug==", + "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", @@ -9947,10 +10276,11 @@ } }, "node_modules/table": { - "version": "6.8.2", - "resolved": "https://registry.npmjs.org/table/-/table-6.8.2.tgz", - "integrity": "sha512-w2sfv80nrAh2VCbqR5AK27wswXhqcck2AhfnNW76beQXskGZ1V12GwS//yYVa3d3fcvAip2OUnbDAjW2k3v9fA==", + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "ajv": "^8.0.1", "lodash.truncate": "^4.4.2", diff --git a/package.json b/package.json index ae251685c8b..0920a5d07ca 100644 --- a/package.json +++ b/package.json @@ -12,11 +12,12 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.25.1", - "react-transition-group": "^4.4.5" + "react-transition-group": "^4.4.5", + "swiper": "^12.1.3" }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^2.1.3", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", @@ -42,6 +43,7 @@ "prettier": "^3.3.2", "sass": "^1.77.8", "stylelint": "^16.7.0", + "stylelint-order": "^8.1.1", "typescript": "^5.2.2", "vite": "^5.3.1" }, diff --git a/src/App.scss b/src/App.scss index 71bc413aade..b657f136475 100644 --- a/src/App.scss +++ b/src/App.scss @@ -1 +1,8 @@ // not empty + +// Prevent scrolling when menu is open +body.no-scroll { + position: relative; + height: 100vh; + overflow: hidden; +} diff --git a/src/App.tsx b/src/App.tsx index 372e4b42066..364874853ce 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,12 @@ import './App.scss'; +import { AppRouter } from './AppRouter'; +import { FavoritesProvider } from './contexts/FavoritesContext'; +import { CartProvider } from './contexts/CartContext'; export const App = () => ( -
-

Product Catalog

-
+ + + + + ); diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx new file mode 100644 index 00000000000..15d9979cf08 --- /dev/null +++ b/src/AppRouter.tsx @@ -0,0 +1,31 @@ +import { Route, Routes } from 'react-router-dom'; +import { MainLayout } from './modules/shared/MainLayout'; +import { HomePage } from './modules/HomePage'; +import { PhonesPage } from './modules/PhonesPage'; +import { TabletsPage } from './modules/TabletsPage'; +import { AccessoriesPage } from './modules/AccessoriesPage'; +import { ProductDetailsPage } from './modules/ProductDetailsPage'; +import { FavoritesPage } from './modules/FavoritesPage'; +import { CartPage } from './modules/CartPage'; +import { NotFoundPage } from './modules/NotFoundPage'; +import { ContactsPage } from './modules/ContactsPage'; +import { RightsPage } from './modules/RightsPage'; + +export const AppRouter = () => { + return ( + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +}; diff --git a/src/assets/fonts/Mont-Black.woff2 b/src/assets/fonts/Mont-Black.woff2 new file mode 100644 index 00000000000..0aefce335a5 Binary files /dev/null and b/src/assets/fonts/Mont-Black.woff2 differ diff --git a/src/assets/fonts/Mont-BlackItalic.woff2 b/src/assets/fonts/Mont-BlackItalic.woff2 new file mode 100644 index 00000000000..cf1bbbef8cd Binary files /dev/null and b/src/assets/fonts/Mont-BlackItalic.woff2 differ diff --git a/src/assets/fonts/Mont-Bold.woff2 b/src/assets/fonts/Mont-Bold.woff2 new file mode 100644 index 00000000000..1093fe0dabc Binary files /dev/null and b/src/assets/fonts/Mont-Bold.woff2 differ diff --git a/src/assets/fonts/Mont-BoldItalic.woff2 b/src/assets/fonts/Mont-BoldItalic.woff2 new file mode 100644 index 00000000000..c2f08b2435f Binary files /dev/null and b/src/assets/fonts/Mont-BoldItalic.woff2 differ diff --git a/src/assets/fonts/Mont-ExtraLight.woff2 b/src/assets/fonts/Mont-ExtraLight.woff2 new file mode 100644 index 00000000000..9c1b82b164d Binary files /dev/null and b/src/assets/fonts/Mont-ExtraLight.woff2 differ diff --git a/src/assets/fonts/Mont-ExtraLightItalic.woff2 b/src/assets/fonts/Mont-ExtraLightItalic.woff2 new file mode 100644 index 00000000000..9c7e86432fc Binary files /dev/null and b/src/assets/fonts/Mont-ExtraLightItalic.woff2 differ diff --git a/src/assets/fonts/Mont-Heavy.woff2 b/src/assets/fonts/Mont-Heavy.woff2 new file mode 100644 index 00000000000..e3defdb4eab Binary files /dev/null and b/src/assets/fonts/Mont-Heavy.woff2 differ diff --git a/src/assets/fonts/Mont-HeavyItalic.woff2 b/src/assets/fonts/Mont-HeavyItalic.woff2 new file mode 100644 index 00000000000..b5c524bfea8 Binary files /dev/null and b/src/assets/fonts/Mont-HeavyItalic.woff2 differ diff --git a/src/assets/fonts/Mont-Light.woff2 b/src/assets/fonts/Mont-Light.woff2 new file mode 100644 index 00000000000..a73f9459293 Binary files /dev/null and b/src/assets/fonts/Mont-Light.woff2 differ diff --git a/src/assets/fonts/Mont-LightItalic.woff2 b/src/assets/fonts/Mont-LightItalic.woff2 new file mode 100644 index 00000000000..5b4103107fa Binary files /dev/null and b/src/assets/fonts/Mont-LightItalic.woff2 differ diff --git a/src/assets/fonts/Mont-Regular.woff2 b/src/assets/fonts/Mont-Regular.woff2 new file mode 100644 index 00000000000..0b682c4d225 Binary files /dev/null and b/src/assets/fonts/Mont-Regular.woff2 differ diff --git a/src/assets/fonts/Mont-RegularItalic.woff2 b/src/assets/fonts/Mont-RegularItalic.woff2 new file mode 100644 index 00000000000..da3f1053dd3 Binary files /dev/null and b/src/assets/fonts/Mont-RegularItalic.woff2 differ diff --git a/src/assets/fonts/Mont-SemiBold.woff2 b/src/assets/fonts/Mont-SemiBold.woff2 new file mode 100644 index 00000000000..7c2fb176b30 Binary files /dev/null and b/src/assets/fonts/Mont-SemiBold.woff2 differ diff --git a/src/assets/fonts/Mont-SemiBoldItalic.woff2 b/src/assets/fonts/Mont-SemiBoldItalic.woff2 new file mode 100644 index 00000000000..a65c6296ae5 Binary files /dev/null and b/src/assets/fonts/Mont-SemiBoldItalic.woff2 differ diff --git a/src/assets/fonts/Mont-Thin.woff2 b/src/assets/fonts/Mont-Thin.woff2 new file mode 100644 index 00000000000..5634eb7b32e Binary files /dev/null and b/src/assets/fonts/Mont-Thin.woff2 differ diff --git a/src/assets/fonts/Mont-ThinItalic.woff2 b/src/assets/fonts/Mont-ThinItalic.woff2 new file mode 100644 index 00000000000..c17514854e4 Binary files /dev/null and b/src/assets/fonts/Mont-ThinItalic.woff2 differ diff --git a/src/assets/icons/icon-cart.svg b/src/assets/icons/icon-cart.svg new file mode 100644 index 00000000000..f38097b1311 --- /dev/null +++ b/src/assets/icons/icon-cart.svg @@ -0,0 +1 @@ + diff --git a/src/assets/icons/icon-chevron.svg b/src/assets/icons/icon-chevron.svg new file mode 100644 index 00000000000..0161c90b6e4 --- /dev/null +++ b/src/assets/icons/icon-chevron.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/icon-close.svg b/src/assets/icons/icon-close.svg new file mode 100644 index 00000000000..e728d7d39d3 --- /dev/null +++ b/src/assets/icons/icon-close.svg @@ -0,0 +1 @@ + diff --git a/src/assets/icons/icon-favorites-selected.svg b/src/assets/icons/icon-favorites-selected.svg new file mode 100644 index 00000000000..22bc8f5a692 --- /dev/null +++ b/src/assets/icons/icon-favorites-selected.svg @@ -0,0 +1 @@ + diff --git a/src/assets/icons/icon-favorites.svg b/src/assets/icons/icon-favorites.svg new file mode 100644 index 00000000000..e3b1a77eb44 --- /dev/null +++ b/src/assets/icons/icon-favorites.svg @@ -0,0 +1 @@ + diff --git a/src/assets/icons/icon-menu.svg b/src/assets/icons/icon-menu.svg new file mode 100644 index 00000000000..ee72343df87 --- /dev/null +++ b/src/assets/icons/icon-menu.svg @@ -0,0 +1 @@ + diff --git a/src/assets/icons/icon-minus.svg b/src/assets/icons/icon-minus.svg new file mode 100644 index 00000000000..eb4b2b24cf0 --- /dev/null +++ b/src/assets/icons/icon-minus.svg @@ -0,0 +1 @@ + diff --git a/src/assets/icons/icon-plus.svg b/src/assets/icons/icon-plus.svg new file mode 100644 index 00000000000..06575e7cfa3 --- /dev/null +++ b/src/assets/icons/icon-plus.svg @@ -0,0 +1 @@ + diff --git a/src/assets/images/nice-gadgets-logo-desktop.png b/src/assets/images/nice-gadgets-logo-desktop.png new file mode 100755 index 00000000000..a7a4d5a2639 Binary files /dev/null and b/src/assets/images/nice-gadgets-logo-desktop.png differ diff --git a/src/assets/images/nice-gadgets-logo-desktop@2x.png b/src/assets/images/nice-gadgets-logo-desktop@2x.png new file mode 100755 index 00000000000..9ae379a11cc Binary files /dev/null and b/src/assets/images/nice-gadgets-logo-desktop@2x.png differ diff --git a/src/assets/images/nice-gadgets-logo-mobile.png b/src/assets/images/nice-gadgets-logo-mobile.png new file mode 100755 index 00000000000..a15cc146073 Binary files /dev/null and b/src/assets/images/nice-gadgets-logo-mobile.png differ diff --git a/src/assets/images/nice-gadgets-logo-mobile@2x.png b/src/assets/images/nice-gadgets-logo-mobile@2x.png new file mode 100755 index 00000000000..4d6aa47c5cd Binary files /dev/null and b/src/assets/images/nice-gadgets-logo-mobile@2x.png differ diff --git a/src/contexts/CartContext.tsx b/src/contexts/CartContext.tsx new file mode 100644 index 00000000000..b14dffac1fe --- /dev/null +++ b/src/contexts/CartContext.tsx @@ -0,0 +1,120 @@ +import { + createContext, + useContext, + useEffect, + useState, + type ReactNode, +} from 'react'; +import type { Product } from '@/types/Product'; +import type { CartItem } from '@/types/CartItem'; +import { loadFromStorage } from '@/utils/storage'; + +const STORAGE_KEY = 'cart'; + +type CartContextType = { + cartItems: CartItem[]; + addToCart: (product: Product) => void; + removeFromCart: (productId: number) => void; + toggleCart: (product: Product) => void; + updateQuantity: (productId: number, quantity: number) => void; + clearCart: () => void; + isInCart: (productId: number) => boolean; + cartCount: number; + cartTotal: number; +}; + +const CartContext = createContext(null); + +type Props = { + children: ReactNode; +}; + +export const CartProvider = ({ children }: Props) => { + const [cartItems, setCartItems] = useState(() => + loadFromStorage(STORAGE_KEY, []), + ); + + useEffect(() => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(cartItems)); + }, [cartItems]); + + const addToCart = (product: Product) => { + setCartItems(prev => { + let found = false; + const updatedList = prev.map(item => { + if (item.product.id === product.id) { + found = true; + + return { ...item, quantity: item.quantity + 1 }; + } + + return item; + }); + + return found ? updatedList : [...prev, { product, quantity: 1 }]; + }); + }; + + const removeFromCart = (productId: number) => { + setCartItems(prev => prev.filter(item => item.product.id !== productId)); + }; + + const updateQuantity = (productId: number, quantity: number) => { + setCartItems(prev => { + if (quantity <= 0) { + return prev.filter(item => item.product.id !== productId); + } + + return prev.map(item => { + if (item.product.id === productId) { + return { ...item, quantity }; + } + + return item; + }); + }); + }; + + const clearCart = () => setCartItems([]); + + const isInCart = (productId: number) => + cartItems.some(item => item.product.id === productId); + + const toggleCart = (product: Product) => + isInCart(product.id) ? removeFromCart(product.id) : addToCart(product); + + const cartCount = cartItems.reduce((sum, item) => sum + item.quantity, 0); + + const cartTotal = cartItems.reduce( + (sum, item) => sum + item.product.price * item.quantity, + 0, + ); + + return ( + + {children} + + ); +}; + +export const useCart = () => { + const context = useContext(CartContext); + + if (!context) { + throw new Error('useCart must be used within CartProvider'); + } + + return context; +}; diff --git a/src/contexts/FavoritesContext.tsx b/src/contexts/FavoritesContext.tsx new file mode 100644 index 00000000000..340188a771b --- /dev/null +++ b/src/contexts/FavoritesContext.tsx @@ -0,0 +1,62 @@ +import { + createContext, + useContext, + useEffect, + useState, + type ReactNode, +} from 'react'; +import type { Product } from '@/types/Product'; +import { loadFromStorage } from '@/utils/storage'; + +const STORAGE_KEY = 'favorites'; + +type FavoritesContextType = { + favorites: Product[]; + toggleFavorite: (product: Product) => void; + isFavorite: (productId: number) => boolean; +}; + +const FavoritesContext = createContext(null); + +type Props = { + children: ReactNode; +}; + +export const FavoritesProvider = ({ children }: Props) => { + const [favorites, setFavorites] = useState(() => + loadFromStorage(STORAGE_KEY, []), + ); + + useEffect(() => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites)); + }, [favorites]); + + const toggleFavorite = (product: Product) => { + setFavorites(prev => + prev.some(p => p.id === product.id) + ? prev.filter(p => p.id !== product.id) + : [...prev, product], + ); + }; + + const isFavorite = (productId: number) => + favorites.some(p => p.id === productId); + + return ( + + {children} + + ); +}; + +export const useFavorites = () => { + const context = useContext(FavoritesContext); + + if (!context) { + throw new Error('useFavorites must be used within FavoritesProvider'); + } + + return context; +}; diff --git a/src/index.tsx b/src/index.tsx index 50470f1508d..7cd8db15cb9 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,11 @@ import { createRoot } from 'react-dom/client'; +import { HashRouter } from 'react-router-dom'; + +import './styles/index.scss'; import { App } from './App'; -createRoot(document.getElementById('root') as HTMLElement).render(); +createRoot(document.getElementById('root') as HTMLElement).render( + + + , +); diff --git a/src/modules/AccessoriesPage/AccessoriesPage.tsx b/src/modules/AccessoriesPage/AccessoriesPage.tsx new file mode 100644 index 00000000000..1785090b14f --- /dev/null +++ b/src/modules/AccessoriesPage/AccessoriesPage.tsx @@ -0,0 +1,5 @@ +import { CatalogPage } from '../shared/components/CatalogPage'; + +export const AccessoriesPage = () => ( + +); diff --git a/src/modules/AccessoriesPage/index.ts b/src/modules/AccessoriesPage/index.ts new file mode 100644 index 00000000000..83dcf696d14 --- /dev/null +++ b/src/modules/AccessoriesPage/index.ts @@ -0,0 +1 @@ +export { AccessoriesPage } from './AccessoriesPage'; diff --git a/src/modules/CartPage/CartPage.module.scss b/src/modules/CartPage/CartPage.module.scss new file mode 100644 index 00000000000..5709fc04c25 --- /dev/null +++ b/src/modules/CartPage/CartPage.module.scss @@ -0,0 +1,106 @@ +@use '@/styles/variables' as *; +@use '@/styles/mixins' as *; + +.page { + @include content-width; +} + +.empty { + margin-top: $spacing-lg; + font-size: 14px; + color: $color-secondary; +} + +.layout { + display: flex; + flex-direction: column; + gap: $spacing-lg; + + @include media-up($breakpoint-desktop) { + flex-direction: row; + align-items: flex-start; + gap: $spacing-xl; + } +} + +.list { + display: flex; + flex-direction: column; + gap: $spacing-md; + padding: 0; + margin: 0; + list-style: none; + + @include media-up($breakpoint-desktop) { + flex: 1; + max-width: 752px; + } +} + +.total { + display: flex; + flex-direction: column; + align-items: center; + gap: $spacing-md; + padding: $spacing-lg; + border: 1px solid $color-elements; + + @include media-up($breakpoint-desktop) { + position: sticky; + top: $spacing-lg; + flex-shrink: 0; + width: 368px; + } +} + +.totalPriceBlock { + display: flex; + flex-direction: column; + align-items: center; + gap: $spacing-xs; + width: 100%; +} + +.totalPrice { + margin: 0; + font-size: 32px; + font-weight: 800; + line-height: 1.28; + letter-spacing: -0.01em; + text-align: center; + color: $color-primary; +} + +.totalLabel { + margin: 0; + font-size: 14px; + font-weight: 600; + text-align: center; + color: $color-secondary; +} + +.divider { + width: 100%; + margin: 0; + border: none; + border-top: 1px solid $color-elements; +} + +.checkout { + @include flex-center; + + width: 100%; + height: 48px; + background: $color-primary; + border: none; + cursor: pointer; + font-size: 14px; + font-weight: 700; + line-height: 1.5; + color: $color-white; + transition: background $transition-base; + + &:hover { + background: $color-secondary; + } +} \ No newline at end of file diff --git a/src/modules/CartPage/CartPage.tsx b/src/modules/CartPage/CartPage.tsx new file mode 100644 index 00000000000..8ffe1490fa5 --- /dev/null +++ b/src/modules/CartPage/CartPage.tsx @@ -0,0 +1,52 @@ +import { useCart } from '@/contexts/CartContext'; +import { Breadcrumbs } from '../shared/components/Breadcrumbs'; +import { CartItem } from '../shared/components/CartItem'; +import styles from './CartPage.module.scss'; + +export const CartPage = () => { + const { cartItems, cartCount, cartTotal, clearCart } = useCart(); + + const handleCheckout = () => { + const confirmed = window.confirm( + 'Checkout is not implemented yet. Do you want to clear the Cart?', + ); + + if (confirmed) { + clearCart(); + } + }; + + return ( +
+ +

Cart

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

Your cart is empty

+ ) : ( +
+
    + {cartItems.map(item => ( +
  • + +
  • + ))} +
+ +
+
+

${cartTotal}

+

Total for {cartCount} items

+
+ +
+ + +
+
+ )} +
+ ); +}; 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/ContactsPage/ContactsPage.module.scss b/src/modules/ContactsPage/ContactsPage.module.scss new file mode 100644 index 00000000000..5dc4a78c793 --- /dev/null +++ b/src/modules/ContactsPage/ContactsPage.module.scss @@ -0,0 +1,45 @@ +@use '@/styles/variables' as *; +@use '@/styles/mixins' as *; + +.container { + padding-bottom: $spacing-3xl; + + @include content-width; +} + +.title { + margin-bottom: $spacing-xl; +} + +.grid { + display: grid; + gap: $spacing-lg; + + @include media-up($breakpoint-tablet) { + grid-template-columns: 1fr 1fr; + } +} + +.card { + display: flex; + flex-direction: column; + gap: $spacing-sm; + padding: $spacing-lg; + border: 1px solid $color-elements; + border-radius: 8px; +} + +.cardTitle { + margin-bottom: $spacing-xs; + color: $color-primary; +} + +.link { + text-decoration: underline; + color: $color-primary; + transition: opacity $transition-base; + + &:hover { + opacity: 0.7; + } +} diff --git a/src/modules/ContactsPage/ContactsPage.tsx b/src/modules/ContactsPage/ContactsPage.tsx new file mode 100644 index 00000000000..222cd9db0f8 --- /dev/null +++ b/src/modules/ContactsPage/ContactsPage.tsx @@ -0,0 +1,48 @@ +import { Breadcrumbs } from '../shared/components/Breadcrumbs'; +import styles from './ContactsPage.module.scss'; + +export const ContactsPage = () => { + return ( +
+ +

Contacts

+ +
+
+

Our Store

+

123 Gadget Street

+

Kyiv, 01001, Ukraine

+

Mon – Fri: 9:00 – 20:00

+

Sat – Sun: 10:00 – 18:00

+
+ +
+

Get in Touch

+

+ Email:{' '} + + support@nicegadgets.com + +

+

+ Phone:{' '} + + +38 (044) 123-45-67 + +

+

+ GitHub:{' '} + + react_phone-catalog + +

+
+
+
+ ); +}; diff --git a/src/modules/ContactsPage/index.ts b/src/modules/ContactsPage/index.ts new file mode 100644 index 00000000000..02d17d0341a --- /dev/null +++ b/src/modules/ContactsPage/index.ts @@ -0,0 +1 @@ +export { ContactsPage } from './ContactsPage'; diff --git a/src/modules/FavoritesPage/FavoritesPage.module.scss b/src/modules/FavoritesPage/FavoritesPage.module.scss new file mode 100644 index 00000000000..63ea5dd6300 --- /dev/null +++ b/src/modules/FavoritesPage/FavoritesPage.module.scss @@ -0,0 +1,14 @@ +@use '@/styles/variables' as *; +@use '@/styles/mixins' as *; + +.page { + @include content-width; +} + +.empty { + margin-top: $spacing-2xl; + font-size: 20px; + font-weight: 600; + text-align: center; + color: $color-secondary; +} \ No newline at end of file diff --git a/src/modules/FavoritesPage/FavoritesPage.tsx b/src/modules/FavoritesPage/FavoritesPage.tsx new file mode 100644 index 00000000000..52e8bb9ae91 --- /dev/null +++ b/src/modules/FavoritesPage/FavoritesPage.tsx @@ -0,0 +1,20 @@ +import { useFavorites } from '@/contexts/FavoritesContext'; +import { Breadcrumbs } from '../shared/components/Breadcrumbs'; +import { ProductsList } from '../shared/components/ProductsList'; +import styles from './FavoritesPage.module.scss'; + +export const FavoritesPage = () => { + const { favorites } = useFavorites(); + + return ( +
+ +

Favorites page

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

You have no favorites yet

+ ) : ( + + )} +
+ ); +}; 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.module.scss b/src/modules/HomePage/HomePage.module.scss new file mode 100644 index 00000000000..7a8dda92210 --- /dev/null +++ b/src/modules/HomePage/HomePage.module.scss @@ -0,0 +1,32 @@ +@use '@/styles/variables' as *; +@use '@/styles/mixins' as *; + +.page { + @include content-width; +} + +.hiddenTitle { + @include visually-hidden; +} + +.pageTitle { + margin-top: $spacing-lg; + margin-bottom: $spacing-lg; + font-size: 32px; + font-weight: 800; + line-height: 41px; + letter-spacing: -0.01em; + + + @include media-up($breakpoint-tablet) { + margin-top: $spacing-xl; + margin-bottom: $spacing-xl; + font-size: 48px; + line-height: 56px; + } + + @include media-up($breakpoint-desktop) { + margin-top: 56px; + margin-bottom: 56px; + } +} diff --git a/src/modules/HomePage/HomePage.tsx b/src/modules/HomePage/HomePage.tsx new file mode 100644 index 00000000000..f9e65db2cd7 --- /dev/null +++ b/src/modules/HomePage/HomePage.tsx @@ -0,0 +1,18 @@ +import { WelcomeSlider } from './components/WelcomeSlider'; +import { ProductsSlider } from './components/ProductsSlider'; +import { ShopByCategory } from './components/ShopByCategory'; +import styles from './HomePage.module.scss'; + +export const HomePage = () => { + return ( +
+

Product Catalog

+

Welcome to Nice Gadgets store!

+ + + + + +
+ ); +}; diff --git a/src/modules/HomePage/components/ProductsSlider/ProductsSlider.module.scss b/src/modules/HomePage/components/ProductsSlider/ProductsSlider.module.scss new file mode 100644 index 00000000000..b3af827f34b --- /dev/null +++ b/src/modules/HomePage/components/ProductsSlider/ProductsSlider.module.scss @@ -0,0 +1,98 @@ +@use '@/styles/variables' as *; +@use '@/styles/mixins' as *; + +.section { + width: 100%; + margin-top: $spacing-2xl; + margin-bottom: $spacing-2xl; + + :global(.swiper) { + padding: $spacing-sm; + margin: -$spacing-sm; + } + + :global(.swiper-wrapper) { + align-items: stretch; + } + + :global(.swiper-slide) { + width: 212px; + height: auto; + + @include media-up($breakpoint-tablet) { + width: 237px; + } + + @include media-up($breakpoint-desktop) { + width: auto; + } + } +} + +.header { + @include flex-between; + + gap: $spacing-md; + margin-bottom: $spacing-md; + + @include media-up($breakpoint-tablet) { + margin-bottom: $spacing-lg; + } +} + +.title { + margin: 0; + font-family: $font-family-base; + font-size: 22px; + font-weight: 800; + line-height: 140%; + color: $color-primary; + + @include media-up($breakpoint-tablet) { + font-size: 32px; + line-height: 41px; + letter-spacing: -0.01em; + } +} + +.controls { + display: flex; + flex-direction: row; + align-items: center; + gap: $spacing-md; +} + +.navBtn { + @include flex-center; + + flex-shrink: 0; + + width: 32px; + height: 32px; + background: $color-white; + border: 1px solid $color-icons; + cursor: pointer; + color: $color-primary; + transition: border-color $transition-base, + color $transition-base; + + &:hover:not(:disabled) { + border-color: $color-primary; + } +} + +.navBtnLeft { + img { + transform: rotate(180deg); + } +} + +.navBtnDisabled { + border-color: $color-elements; + cursor: default; + color: $color-icons; + + img { + opacity: 0.4; + } +} diff --git a/src/modules/HomePage/components/ProductsSlider/ProductsSlider.tsx b/src/modules/HomePage/components/ProductsSlider/ProductsSlider.tsx new file mode 100644 index 00000000000..f83e0029036 --- /dev/null +++ b/src/modules/HomePage/components/ProductsSlider/ProductsSlider.tsx @@ -0,0 +1,104 @@ +import { useEffect, useRef, useState } from 'react'; +import { Swiper, SwiperSlide } from 'swiper/react'; +import { Navigation } from 'swiper/modules'; +import type { Swiper as SwiperType } from 'swiper'; +import 'swiper/css'; + +import { ProductCard } from '../../../shared/components/ProductCard'; +import type { Product } from '@/types/Product'; +import styles from './ProductsSlider.module.scss'; +import iconChevron from '@/assets/icons/icon-chevron.svg'; + +type SliderType = 'brand-new' | 'hot-prices' | 'you-may-also-like'; + +type Props = { + title: string; + type: SliderType; +}; + +export const ProductsSlider = ({ title, type }: Props) => { + const [products, setProducts] = useState([]); + const [isBeginning, setIsBeginning] = useState(true); + const [isEnd, setIsEnd] = useState(false); + const swiperRef = useRef(null); + + useEffect(() => { + fetch('./api/products.json') + .then(res => res.json()) + .then((data: Product[]) => { + if (type === 'brand-new') { + data.sort((a, b) => b.year - a.year); + setProducts(data.slice(0, 10)); + } + + if (type === 'hot-prices') { + data.sort((a, b) => b.fullPrice - b.price - (a.fullPrice - a.price)); + setProducts(data.slice(0, 10)); + } + + if (type === 'you-may-also-like') { + setProducts([...data].sort(() => Math.random() - 0.5).slice(0, 10)); + } + }); + }, [type]); + + const handlePrev = () => swiperRef.current?.slidePrev(); + const handleNext = () => swiperRef.current?.slideNext(); + + return ( +
+
+

{title}

+ +
+ + + +
+
+ + {products.length > 0 && ( + { + swiperRef.current = swiper; + setIsBeginning(swiper.isBeginning); + setIsEnd(swiper.isEnd); + }} + onSlideChange={swiper => { + setIsBeginning(swiper.isBeginning); + setIsEnd(swiper.isEnd); + }} + > + {products.map(product => ( + + + + ))} + + )} +
+ ); +}; diff --git a/src/modules/HomePage/components/ProductsSlider/index.ts b/src/modules/HomePage/components/ProductsSlider/index.ts new file mode 100644 index 00000000000..0a5bb986628 --- /dev/null +++ b/src/modules/HomePage/components/ProductsSlider/index.ts @@ -0,0 +1 @@ +export { ProductsSlider } from './ProductsSlider'; diff --git a/src/modules/HomePage/components/ShopByCategory/ShopByCategory.module.scss b/src/modules/HomePage/components/ShopByCategory/ShopByCategory.module.scss new file mode 100644 index 00000000000..461cc243644 --- /dev/null +++ b/src/modules/HomePage/components/ShopByCategory/ShopByCategory.module.scss @@ -0,0 +1,86 @@ +@use '@/styles/variables' as *; +@use '@/styles/mixins' as *; + +.section { + width: 100%; + margin-top: $spacing-2xl; + margin-bottom: $spacing-2xl; + + @include media-up($breakpoint-tablet) { + margin-top: $spacing-3xl; + margin-bottom: $spacing-3xl; + } +} + +.title { + margin: 0 0 $spacing-md; + font-size: 22px; + font-weight: 800; + line-height: 140%; + color: $color-primary; + + @include media-up($breakpoint-tablet) { + margin-bottom: $spacing-lg; + font-size: 32px; + line-height: 41px; + letter-spacing: -0.01em; + } +} + +.grid { + display: grid; + grid-template-columns: 1fr; + gap: $spacing-md; + + @include media-up($breakpoint-tablet) { + grid-template-columns: repeat(3, 1fr); + } +} + +.card { + display: block; + text-decoration: none; + color: inherit; +} + +.imageWrapper { + position: relative; + aspect-ratio: 1; + overflow: hidden; +} + +.image { + position: absolute; + top: 15%; + left: 15%; + display: block; + width: 100%; + height: 100%; + transition: transform $transition-base; + object-fit: contain; + object-position: center bottom; + + .card:hover & { + transform: scale(1.1); + } +} + +.info { + margin-top: $spacing-sm; +} + +.cardTitle { + margin: 0 0 $spacing-xs; + font-size: 20px; + font-weight: 700; + line-height: 26px; + color: $color-primary; +} + +.cardCount { + margin: 0; + font-size: 14px; + font-weight: 500; + line-height: 21px; + color: $color-secondary; +} diff --git a/src/modules/HomePage/components/ShopByCategory/ShopByCategory.tsx b/src/modules/HomePage/components/ShopByCategory/ShopByCategory.tsx new file mode 100644 index 00000000000..6b518a6ea4e --- /dev/null +++ b/src/modules/HomePage/components/ShopByCategory/ShopByCategory.tsx @@ -0,0 +1,79 @@ +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import styles from './ShopByCategory.module.scss'; + +type Category = { + name: string; + path: string; + bg: string; + image: string; +}; + +const categories: Category[] = [ + { + name: 'Mobile phones', + path: '/phones', + bg: '#6D6474', + image: './img/category-phones.webp', + }, + { + name: 'Tablets', + path: '/tablets', + bg: '#8D8D92', + image: './img/category-tablets.webp', + }, + { + name: 'Accessories', + path: '/accessories', + bg: '#973D5F', + image: './img/category-accessories.webp', + }, +]; + +export const ShopByCategory = () => { + const [counts, setCounts] = useState>({}); + + useEffect(() => { + fetch('./api/products.json') + .then(res => res.json()) + .then((data: { category: string }[]) => { + const result = data.reduce>( + (acc, p) => ({ + ...acc, + [p.category]: (acc[p.category] ?? 0) + 1, + }), + {}, + ); + + setCounts(result); + }); + }, []); + + return ( +
+

Shop by category

+ +
+ {categories.map(({ name, path, bg, image }) => { + const key = path.slice(1); + + return ( + +
+ {name} +
+ +
+

{name}

+

{counts[key] ?? 0} models

+
+ + ); + })} +
+
+ ); +}; diff --git a/src/modules/HomePage/components/ShopByCategory/index.ts b/src/modules/HomePage/components/ShopByCategory/index.ts new file mode 100644 index 00000000000..767e814b1f2 --- /dev/null +++ b/src/modules/HomePage/components/ShopByCategory/index.ts @@ -0,0 +1 @@ +export { ShopByCategory } from './ShopByCategory'; diff --git a/src/modules/HomePage/components/WelcomeSlider/WelcomeSlider.module.scss b/src/modules/HomePage/components/WelcomeSlider/WelcomeSlider.module.scss new file mode 100644 index 00000000000..3f686a8486d --- /dev/null +++ b/src/modules/HomePage/components/WelcomeSlider/WelcomeSlider.module.scss @@ -0,0 +1,100 @@ +@use '@/styles/variables' as *; +@use '@/styles/mixins' as *; + +.wrapper { + display: flow-root; + margin-inline: -$content-padding-mobile; + + @include media-up($breakpoint-tablet) { + margin-inline: 0; + } +} + +.track { + position: relative; + + @include media-up($breakpoint-tablet) { + padding-inline: #{$spacing-xl + 19px}; + } +} + +.swiper { + width: 100%; +} + +.image { + display: block; + width: 100%; + aspect-ratio: 1 / 1; + object-fit: cover; + + @include media-up($breakpoint-tablet) { + aspect-ratio: 490 / 189; + } + + @include media-up($breakpoint-desktop) { + aspect-ratio: 1040 / 400; + } +} + +.prev, +.next { + @include flex-center; + + position: absolute; + top: 0; + bottom: 0; + z-index: 10; + display: none; + width: $spacing-xl; + background: $color-white; + border: 1px solid $color-icons; + cursor: pointer; + transition: border-color $transition-base; + + @include media-up($breakpoint-tablet) { + display: flex; + } +} + +.prev { + left: 0; + + img { + transform: rotate(180deg); + } + + &:hover { + border-color: $color-primary; + } +} + +.next { + right: 0; + + &:hover { + border-color: $color-primary; + } +} + +.pagination { + @include flex-center; + + gap: $spacing-md; + margin-top: $spacing-md; + margin-bottom: $spacing-md; + cursor: pointer; + + :global(.swiper-pagination-bullet) { + width: 14px; + height: 4px; + background: $color-elements; + border-radius: 0; + opacity: 1; + transition: background $transition-base; + } + + :global(.swiper-pagination-bullet-active) { + background: $color-primary; + } +} diff --git a/src/modules/HomePage/components/WelcomeSlider/WelcomeSlider.tsx b/src/modules/HomePage/components/WelcomeSlider/WelcomeSlider.tsx new file mode 100644 index 00000000000..65c696a1d2c --- /dev/null +++ b/src/modules/HomePage/components/WelcomeSlider/WelcomeSlider.tsx @@ -0,0 +1,50 @@ +import { Swiper, SwiperSlide } from 'swiper/react'; +import { Navigation, Pagination, Autoplay } from 'swiper/modules'; +import 'swiper/css'; +import styles from './WelcomeSlider.module.scss'; +import iconChevron from '@/assets/icons/icon-chevron.svg'; + +const SLIDES = [ + { src: './img/banner-phones.png', alt: 'New models iPhone' }, + { src: './img/banner-tablets.png', alt: 'New models iPad' }, + { src: './img/banner-accessories.png', alt: 'New accessories' }, +]; + +export const WelcomeSlider = () => { + return ( +
+
+ + {SLIDES.map((slide, i) => ( + + {slide.alt} + + ))} + + + + + +
+ +
+
+ ); +}; diff --git a/src/modules/HomePage/components/WelcomeSlider/index.ts b/src/modules/HomePage/components/WelcomeSlider/index.ts new file mode 100644 index 00000000000..e8a86640cfa --- /dev/null +++ b/src/modules/HomePage/components/WelcomeSlider/index.ts @@ -0,0 +1 @@ +export { WelcomeSlider } from './WelcomeSlider'; 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/NotFoundPage/NotFoundPage.module.scss b/src/modules/NotFoundPage/NotFoundPage.module.scss new file mode 100644 index 00000000000..6b0e3cf95b1 --- /dev/null +++ b/src/modules/NotFoundPage/NotFoundPage.module.scss @@ -0,0 +1,27 @@ +@use '@/styles/variables' as *; +@use '@/styles/mixins' as *; + +.container { + @include content-width; + + h1 { + display: flex; + justify-content: center; + margin-top: 200px; + } +} + +.homeLink { + display: block; + margin-top: $spacing-lg; + font-size: 16px; + font-weight: 600; + text-align: center; + text-decoration: none; + color: $color-primary; + transition: opacity $transition-base; + + &:hover { + opacity: 0.7; + } +} diff --git a/src/modules/NotFoundPage/NotFoundPage.tsx b/src/modules/NotFoundPage/NotFoundPage.tsx new file mode 100644 index 00000000000..4781479dcf8 --- /dev/null +++ b/src/modules/NotFoundPage/NotFoundPage.tsx @@ -0,0 +1,13 @@ +import { Link } from 'react-router-dom'; +import styles from './NotFoundPage.module.scss'; + +export const NotFoundPage = () => { + return ( +
+

Page not found

+ + Go to Home page + +
+ ); +}; diff --git a/src/modules/NotFoundPage/index.ts b/src/modules/NotFoundPage/index.ts new file mode 100644 index 00000000000..642c600088e --- /dev/null +++ b/src/modules/NotFoundPage/index.ts @@ -0,0 +1 @@ +export { NotFoundPage } from './NotFoundPage'; diff --git a/src/modules/PhonesPage/PhonesPage.tsx b/src/modules/PhonesPage/PhonesPage.tsx new file mode 100644 index 00000000000..e005b6b3a38 --- /dev/null +++ b/src/modules/PhonesPage/PhonesPage.tsx @@ -0,0 +1,5 @@ +import { CatalogPage } from '../shared/components/CatalogPage'; + +export const PhonesPage = () => ( + +); diff --git a/src/modules/PhonesPage/index.ts b/src/modules/PhonesPage/index.ts new file mode 100644 index 00000000000..6054067fc66 --- /dev/null +++ b/src/modules/PhonesPage/index.ts @@ -0,0 +1 @@ +export { PhonesPage } from './PhonesPage'; diff --git a/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss b/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss new file mode 100644 index 00000000000..6292a8dfcfd --- /dev/null +++ b/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss @@ -0,0 +1,419 @@ +@use '@/styles/variables' as *; +@use '@/styles/mixins' as *; + +.page { + display: flex; + flex-direction: column; + + @include content-width; +} + +.back { + display: flex; + align-items: center; + gap: $spacing-xs; + width: fit-content; + min-width: 66px; + min-height: 16px; + padding: 0; + background: none; + border: none; + cursor: pointer; + font-size: 14px; + font-weight: 600; + line-height: 21px; + color: $color-secondary; + transition: color $transition-base; + + &:hover { + color: $color-primary; + } +} + +.backText { + position: relative; + top: 1px; +} + +.backChevron { + width: 16px; + height: 16px; + transform: rotate(180deg); +} + +.title { + margin-top: 16px; + margin-bottom: 32px; + font-size: 22px; + font-weight: 800; + line-height: 1.4; +} + +.contentGrid { + display: flex; + flex-direction: column; + gap: $spacing-2xl; + + @include media-up($breakpoint-tablet) { + flex-direction: row; + align-items: flex-start; + gap: $spacing-md; + } + + @include media-up($breakpoint-desktop) { + gap: $spacing-3xl; + } +} + +.imageSection { + max-width: 560px; + + @include media-up($breakpoint-tablet) { + flex: 1; + min-width: 338px; + } +} + +.details { + flex: 1; +} + +.detailsWrapper { + display: flex; + flex-direction: column; + gap: $spacing-md; + max-width: 320px; + + @include media-up($breakpoint-tablet) { + gap: $spacing-lg; + min-width: 0; + } +} + +.colorsSection { + display: flex; + flex-direction: column; + gap: $spacing-sm; +} + +.sectionHeader { + display: flex; + flex-direction: column; + justify-content: space-between; + gap: $spacing-sm; + margin-bottom: $spacing-sm; + + @include media-up($breakpoint-desktop) { + flex-direction: row-reverse; + } +} + +.sectionLabel { + font-size: 12px; + font-weight: 600; + line-height: 15px; + color: $color-secondary; +} + +.productId { + font-size: 12px; + font-weight: 700; + line-height: 15px; + color: $color-icons; +} + +.colorSwatches { + display: flex; + flex-wrap: wrap; + gap: $spacing-sm; +} + +.radioInput { + position: absolute; + width: 0; + height: 0; + opacity: 0; + pointer-events: none; +} + +.colorSwatchLabel { + display: inline-flex; + cursor: pointer; +} + +.capacityLabel { + display: inline-flex; + cursor: pointer; +} + +.colorSwatch { + position: relative; + display: block; + flex-shrink: 0; + width: 32px; + height: 32px; + padding: 0; + background: transparent; + border: 1px solid $color-elements; + border-radius: 50%; + cursor: pointer; + transition: border-color $transition-base; + + &::after { + position: absolute; + background: var(--swatch-color, #888); + border: 2px solid $color-white; + border-radius: 50%; + content: ''; + inset: 2px; + } + + &:hover { + border-color: $color-icons; + } + + &.colorSwatchSelected { + border-color: $color-primary; + } +} + +.divider { + margin: 0; + border: none; + border-top: 1px solid $color-elements; +} + +.capacitySection { + display: flex; + flex-direction: column; + gap: $spacing-sm; +} + +.capacityButtons { + display: flex; + flex-wrap: wrap; + gap: $spacing-sm; +} + +.capacityBtn { + display: inline-flex; + justify-content: center; + align-items: center; + height: 32px; + padding: 0 $spacing-sm; + background: transparent; + border: 1px solid $color-icons; + cursor: pointer; + font-size: 14px; + font-weight: 500; + line-height: 21px; + color: $color-primary; + transition: background $transition-base, + color $transition-base, + border-color $transition-base; + + &:hover { + border-color: $color-primary; + } + + &.capacityBtnSelected { + background: $color-primary; + border-color: $color-primary; + color: $color-white; + } +} + +.priceRow { + display: flex; + align-items: center; + gap: $spacing-sm; + margin-top: $spacing-md; +} + +.price { + font-size: 32px; + font-weight: 800; + line-height: 41px; + letter-spacing: -0.01em; + color: $color-primary; +} + +.priceOld { + font-size: 22px; + font-weight: 500; + line-height: 28px; + text-decoration: line-through; + color: $color-secondary; +} + +.actions { + display: flex; + gap: $spacing-sm; +} + +.addToCart { + flex: 1; + height: 48px; + background: $color-primary; + border: none; + cursor: pointer; + font-size: 14px; + font-weight: 700; + line-height: 21px; + color: $color-white; + transition: background $transition-base; + + &:hover { + box-shadow: 0 3px 13px rgba(23, 32, 49, 0.4); + } + + &.addToCartAdded { + background: $color-white; + border: 1px solid $color-elements; + color: $color-primary; + } +} + +.addToFavorites { + display: flex; + flex-shrink: 0; + justify-content: center; + align-items: center; + width: 48px; + height: 48px; + background: transparent; + border: 1px solid $color-icons; + cursor: pointer; + transition: border-color $transition-base; + + &:hover { + border-color: $color-primary; + } + + &.addToFavoritesActive { + border-color: $color-elements; + } +} + +.specs { + display: flex; + flex-direction: column; + gap: $spacing-sm; + margin: 0; +} + +.specRow { + display: flex; + justify-content: space-between; + align-items: center; +} + +.specLabel { + font-size: 12px; + font-weight: 600; + line-height: 15px; + color: $color-secondary; +} + +.specValue { + margin: 0; + font-size: 12px; + font-weight: 600; + line-height: 15px; + text-align: right; + color: $color-primary; +} + +.bottomSections { + display: flex; + flex-direction: column; + gap: $spacing-3xl; + margin-top: $spacing-3xl; + + @include media-up($breakpoint-desktop) { + flex-direction: row; + align-items: flex-start; + gap: $spacing-2xl; + } +} + +.aboutSection, +.techSpecsSection { + display: flex; + flex-direction: column; + gap: $spacing-xl; + + @include media-up($breakpoint-desktop) { + flex: 1; + } +} + +.sectionTitle { + padding-bottom: $spacing-md; + border-bottom: 1px solid $color-elements; + font-size: 20px; + font-weight: 700; + line-height: 26px; + color: $color-primary; + + @include media-up($breakpoint-tablet) { + font-size: 22px; + font-weight: 800; + line-height: 1.4; + } +} + +.descriptionItem { + display: flex; + flex-direction: column; + gap: $spacing-md; +} + +.descriptionTitle { + font-size: 16px; + font-weight: 700; + line-height: 20px; + color: $color-primary; + + @include media-up($breakpoint-tablet) { + font-size: 20px; + line-height: 26px; + } +} + +.descriptionText { + margin: 0; + font-size: 14px; + font-weight: 600; + line-height: 21px; + color: $color-secondary; +} + +.techSpecs { + display: flex; + flex-direction: column; + gap: $spacing-sm; + margin: 0; +} + +.techSpecRow { + display: flex; + justify-content: space-between; + align-items: center; +} + +.techSpecLabel { + font-size: 14px; + font-weight: 500; + line-height: 21px; + color: $color-secondary; +} + +.techSpecValue { + margin: 0; + font-size: 14px; + font-weight: 500; + line-height: 21px; + text-align: right; + color: $color-primary; +} diff --git a/src/modules/ProductDetailsPage/ProductDetailsPage.tsx b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx new file mode 100644 index 00000000000..0b4f1c3c985 --- /dev/null +++ b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx @@ -0,0 +1,369 @@ +import React, { useEffect, useState } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import classNames from 'classnames'; +import type { Product } from '@/types/Product'; +import type { ProductDetail } from '@/types/ProductDetail'; +import { Breadcrumbs } from '../shared/components/Breadcrumbs'; +import { Loader } from '../shared/components/Loader'; +import { useFavorites } from '@/contexts/FavoritesContext'; +import { useCart } from '@/contexts/CartContext'; +import { ImageSlider } from './components/ImageSlider'; +import chevronIcon from '@/assets/icons/icon-chevron.svg'; +import favoritesIcon from '@/assets/icons/icon-favorites.svg'; +import favoritesSelectedIcon from '@/assets/icons/icon-favorites-selected.svg'; +import { ProductsSlider } from '../HomePage/components/ProductsSlider'; +import styles from './ProductDetailsPage.module.scss'; + +const TECH_SPEC_FIELDS: [string, keyof ProductDetail][] = [ + ['Screen', 'screen'], + ['Resolution', 'resolution'], + ['Processor', 'processor'], + ['RAM', 'ram'], + ['Built in memory', 'capacity'], + ['Camera', 'camera'], + ['Zoom', 'zoom'], + ['Cell', 'cell'], +]; + +const COLOR_MAP: Record = { + black: '#1C1C1E', + blue: '#276787', + coral: '#FF6B6B', + gold: '#C8A97E', + graphite: '#54524F', + green: '#3E6B4D', + midnight: '#191C22', + midnightgreen: '#2D4C3F', + pink: '#F4C3C2', + purple: '#9B8EC4', + red: '#BF0000', + rosegold: '#B76E79', + 'rose gold': '#B76E79', + sierrablue: '#6E93A8', + silver: '#E3E4E5', + 'sky blue': '#A8C5DA', + spaceblack: '#2A2A2A', + spacegray: '#57585A', + 'space gray': '#57585A', + starlight: '#FAF6EF', + white: '#FAFAFA', + yellow: '#FFE680', +}; + +const colorToHex = (name: string): string => COLOR_MAP[name] ?? '#888888'; + +const findVariant = ( + allDetails: ProductDetail[], + namespaceId: string, + color: string, + capacity: string, +): ProductDetail | undefined => + allDetails.find( + d => + d.namespaceId === namespaceId && + d.color === color && + d.capacity === capacity, + ); + +const CATEGORY_LABELS: Record = { + phones: 'Phones', + tablets: 'Tablets', + accessories: 'Accessories', +}; + +export const ProductDetailsPage = () => { + const { productId } = useParams<{ productId: string }>(); + const navigate = useNavigate(); + const [product, setProduct] = useState(null); + const [detail, setDetail] = useState(null); + const [allDetails, setAllDetails] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedColor, setSelectedColor] = useState(''); + const [selectedCapacity, setSelectedCapacity] = useState(''); + const { toggleCart, isInCart } = useCart(); + const { toggleFavorite, isFavorite } = useFavorites(); + const isProductFavorited = product ? isFavorite(product.id) : false; + const isProductInCart = product ? isInCart(product.id) : false; + + useEffect(() => { + window.scrollTo(0, 0); + }, [productId]); + + useEffect(() => { + setLoading(true); + setError(null); + setProduct(null); + setDetail(null); + setAllDetails([]); + + const controller = new AbortController(); + + fetch('./api/products.json', { signal: controller.signal }) + .then(res => res.json()) + .then((data: Product[]) => { + const foundProduct = data.find(p => p.itemId === productId); + + if (!foundProduct) { + setError('Product was not found'); + + return; + } + + setProduct(foundProduct); + + return fetch(`./api/${foundProduct.category}.json`, { + signal: controller.signal, + }); + }) + .then(res => res?.json()) + .then((data: ProductDetail[] | undefined) => { + if (!data) { + return; + } + + setAllDetails(data); + + const foundDetail = data.find(d => d.id === productId); + + if (foundDetail) { + setDetail(foundDetail); + setSelectedColor(foundDetail.color); + setSelectedCapacity(foundDetail.capacity); + } + }) + .catch(err => { + if (err.name !== 'AbortError') { + setError('Something went wrong'); + } + }) + .finally(() => setLoading(false)); + + return () => controller.abort(); + }, [productId]); + + if (loading) { + return ; + } + + if (error) { + return

{error}

; + } + + if (!product) { + return

Product was not found

; + } + + const categoryLabel = CATEGORY_LABELS[product.category] ?? product.category; + + return ( +
+ + + + +

{product.name}

+ +
+
+ {detail && } +
+
+ {detail && ( + <> +
+ ID: {detail.id} + Available colors +
+
+
+
+ {detail.colorsAvailable.map(color => ( + + ))} +
+
+ +
+ +
+ Select capacity +
+ {detail.capacityAvailable.map(cap => ( + + ))} +
+
+ +
+ +
+ ${detail.priceDiscount} + + ${detail.priceRegular} + +
+ +
+ + +
+ +
+ {( + [ + ['Screen', detail.screen], + ['Resolution', detail.resolution], + ['Processor', detail.processor], + ['RAM', detail.ram], + ] as [string, string][] + ).map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+
+ + )} +
+
+ + {detail && ( +
+
+

About

+ {detail.description.map((item, idx) => ( +
+

{item.title}

+ {item.text.map((para, pIdx) => ( +

+ {para} +

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

Tech specs

+
+ {TECH_SPEC_FIELDS.map(([label, key]): [string, string] => { + const raw = detail[key]; + const value = Array.isArray(raw) ? raw.join(', ') : String(raw); + + return [label, value]; + }) + .filter(([, value]) => Boolean(value)) + .map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+
+
+ )} + + +
+ ); +}; diff --git a/src/modules/ProductDetailsPage/components/ImageSlider/ImageSlider.module.scss b/src/modules/ProductDetailsPage/components/ImageSlider/ImageSlider.module.scss new file mode 100644 index 00000000000..50411433e5c --- /dev/null +++ b/src/modules/ProductDetailsPage/components/ImageSlider/ImageSlider.module.scss @@ -0,0 +1,87 @@ +@use '@/styles/variables' as *; +@use '@/styles/mixins' as *; + +.wrapper { + display: flex; + flex-direction: column; + gap: $spacing-md; + + @include media-up($breakpoint-tablet) { + flex-direction: row; + align-items: flex-start; + } +} + +.thumbnails { + display: flex; + flex-direction: row; + gap: $spacing-sm; + order: 2; + overflow-x: auto; + + @include media-up($breakpoint-tablet) { + flex-direction: column; + order: 1; + } + + @include media-up($breakpoint-desktop) { + gap: $spacing-md; + } +} + +.thumb { + flex-shrink: 0; + width: 51px; + aspect-ratio: 51 / 49; + padding: 0; + background: none; + border: 1px solid $color-elements; + cursor: pointer; + transition: border-color $transition-base; + + @include media-up($breakpoint-tablet) { + width: 35px; + height: 35px; + } + + @include media-up($breakpoint-desktop) { + width: 80px; + height: 80px; + } + + img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + } +} + +.thumb:hover { + border-color: $color-icons; +} + +.thumbActive { + border-color: $color-primary; +} + +.mainImage { + order: 1; + min-width: 0; + + @include media-up($breakpoint-tablet) { + order: 2; + flex: 1; + } +} + +.swiper { + width: 100%; +} + +.slide { + display: block; + width: 100%; + aspect-ratio: 1; + object-fit: contain; +} diff --git a/src/modules/ProductDetailsPage/components/ImageSlider/ImageSlider.tsx b/src/modules/ProductDetailsPage/components/ImageSlider/ImageSlider.tsx new file mode 100644 index 00000000000..d71ff617700 --- /dev/null +++ b/src/modules/ProductDetailsPage/components/ImageSlider/ImageSlider.tsx @@ -0,0 +1,61 @@ +import { useRef, useState } from 'react'; +import { Swiper, SwiperSlide } from 'swiper/react'; +import type { Swiper as SwiperType } from 'swiper'; +import 'swiper/css'; +import cn from 'classnames'; +import styles from './ImageSlider.module.scss'; + +type Props = { + images: string[]; + alt: string; +}; + +export const ImageSlider = ({ images, alt }: Props) => { + const [activeIndex, setActiveIndex] = useState(0); + const swiperRef = useRef(null); + + const handleThumbnailClick = (index: number) => { + setActiveIndex(index); + swiperRef.current?.slideTo(index); + }; + + return ( +
+
+ {images.map((src, i) => ( + + ))} +
+ +
+ { + swiperRef.current = s; + }} + onSlideChange={s => setActiveIndex(s.activeIndex)} + slidesPerView={1} + className={styles.swiper} + > + {images.map((src, i) => ( + + {`${alt} + + ))} + +
+
+ ); +}; diff --git a/src/modules/ProductDetailsPage/components/ImageSlider/index.ts b/src/modules/ProductDetailsPage/components/ImageSlider/index.ts new file mode 100644 index 00000000000..c2e4006d494 --- /dev/null +++ b/src/modules/ProductDetailsPage/components/ImageSlider/index.ts @@ -0,0 +1 @@ +export { ImageSlider } from './ImageSlider'; diff --git a/src/modules/ProductDetailsPage/index.ts b/src/modules/ProductDetailsPage/index.ts new file mode 100644 index 00000000000..ec50c119343 --- /dev/null +++ b/src/modules/ProductDetailsPage/index.ts @@ -0,0 +1 @@ +export { ProductDetailsPage } from './ProductDetailsPage'; diff --git a/src/modules/RightsPage/RightsPage.module.scss b/src/modules/RightsPage/RightsPage.module.scss new file mode 100644 index 00000000000..50a65301a05 --- /dev/null +++ b/src/modules/RightsPage/RightsPage.module.scss @@ -0,0 +1,31 @@ +@use '@/styles/variables' as *; +@use '@/styles/mixins' as *; + +.container { + padding-bottom: $spacing-3xl; + + @include content-width; +} + +.title { + margin-bottom: $spacing-xl; +} + +.section { + margin-bottom: $spacing-xl; +} + +.sectionTitle { + margin-bottom: $spacing-sm; + color: $color-primary; +} + +.link { + text-decoration: underline; + color: $color-primary; + transition: opacity $transition-base; + + &:hover { + opacity: 0.7; + } +} diff --git a/src/modules/RightsPage/RightsPage.tsx b/src/modules/RightsPage/RightsPage.tsx new file mode 100644 index 00000000000..b76bcfe6465 --- /dev/null +++ b/src/modules/RightsPage/RightsPage.tsx @@ -0,0 +1,57 @@ +import { Breadcrumbs } from '../shared/components/Breadcrumbs'; +import styles from './RightsPage.module.scss'; + +export const RightsPage = () => { + const year = new Date().getFullYear(); + + return ( +
+ +

Rights & Legal

+ +
+

Copyright

+

+ © {year} Nice Gadgets. All rights reserved. All content, trademarks, + and brand identifiers on this site are the property of Nice Gadgets or + their respective owners. +

+
+ +
+

License

+

+ This project is open-source and available under the{' '} + + MIT License + + . You are free to use, copy, modify, and distribute this software with + attribution. +

+
+ +
+

Disclaimer

+

+ Product images and descriptions are provided for informational + purposes only. Nice Gadgets is not responsible for typographical + errors or inaccuracies in product specifications. +

+
+ +
+

Privacy

+

+ We do not collect personal data beyond what is necessary to process + your order. Cart and favorites data is stored locally in your browser + and never shared with third parties. +

+
+
+ ); +}; diff --git a/src/modules/RightsPage/index.ts b/src/modules/RightsPage/index.ts new file mode 100644 index 00000000000..aef12bd2231 --- /dev/null +++ b/src/modules/RightsPage/index.ts @@ -0,0 +1 @@ +export { RightsPage } from './RightsPage'; diff --git a/src/modules/TabletsPage/TabletsPage.tsx b/src/modules/TabletsPage/TabletsPage.tsx new file mode 100644 index 00000000000..a78f09a7087 --- /dev/null +++ b/src/modules/TabletsPage/TabletsPage.tsx @@ -0,0 +1,5 @@ +import { CatalogPage } from '../shared/components/CatalogPage'; + +export const TabletsPage = () => ( + +); diff --git a/src/modules/TabletsPage/index.ts b/src/modules/TabletsPage/index.ts new file mode 100644 index 00000000000..5f5d7eb9d62 --- /dev/null +++ b/src/modules/TabletsPage/index.ts @@ -0,0 +1 @@ +export { TabletsPage } from './TabletsPage'; diff --git a/src/modules/shared/Footer/Footer.module.scss b/src/modules/shared/Footer/Footer.module.scss new file mode 100644 index 00000000000..51f2deaec99 --- /dev/null +++ b/src/modules/shared/Footer/Footer.module.scss @@ -0,0 +1,109 @@ +@use '@/styles/variables' as *; +@use '@/styles/mixins' as *; + +.footer { + margin-top: $spacing-3xl; + background: $color-white; + box-shadow: 0 -1px 0 $color-elements; +} + +.footerInner { + display: flex; + flex-direction: column; + gap: $spacing-xl; + padding: $spacing-xl $spacing-md; + + @include media-up($breakpoint-tablet) { + flex-direction: row; + gap: 0; + padding: $spacing-xl $spacing-lg; + } + + @include media-up($breakpoint-desktop) { + max-width: $content-max-width; + padding: $spacing-xl $content-padding-desktop; + margin-inline: auto; + } +} + +.logo { + display: flex; + align-items: flex-start; + + @include media-up($breakpoint-tablet) { + flex: 1; + } +} + +.logoImage { + display: block; + width: auto; + height: 32px; +} + +.nav { + display: flex; + flex-direction: column; + gap: $spacing-md; + + @include media-up($breakpoint-tablet) { + flex: 1; + flex-direction: row; + justify-content: space-between; + align-items: center; + gap: $spacing-3xl; + } +} + +.navLink { + font-size: 12px; + font-weight: 700; + line-height: 11px; + letter-spacing: 0.04em; + text-decoration: none; + text-transform: uppercase; + color: $color-secondary; + + &:hover { + color: $color-primary; + } +} + +.backToTop { + display: flex; + justify-content: center; + align-items: center; + gap: $spacing-md; + + @include media-up($breakpoint-tablet) { + flex: 1; + justify-content: flex-end; + } +} + +.backToTopText { + font-size: 12px; + font-weight: 600; + line-height: 15px; + text-align: right; + color: $color-secondary; +} + +.backToTopButton { + @include flex-center; + + flex-shrink: 0; + width: 32px; + height: 32px; + background: transparent; + border: 1px solid $color-icons; + cursor: pointer; + + img { + transform: rotate(-90deg); + } + + &:hover { + border-color: $color-primary; + } +} diff --git a/src/modules/shared/Footer/Footer.tsx b/src/modules/shared/Footer/Footer.tsx new file mode 100644 index 00000000000..7d5fd5b5f9d --- /dev/null +++ b/src/modules/shared/Footer/Footer.tsx @@ -0,0 +1,85 @@ +import { Link } from 'react-router-dom'; +import styles from './Footer.module.scss'; + +import logoMobile1x from '@/assets/images/nice-gadgets-logo-mobile.png'; +import logoMobile2x from '@/assets/images/nice-gadgets-logo-mobile@2x.png'; +import logoDesktop1x from '@/assets/images/nice-gadgets-logo-desktop.png'; +import logoDesktop2x from '@/assets/images/nice-gadgets-logo-desktop@2x.png'; +import iconChevron from '@/assets/icons/icon-chevron.svg'; + +type NavLink = { + href: string; + label: string; + external: boolean; +}; + +const navLinks: NavLink[] = [ + { + href: 'https://github.com/redfield-mp/react_phone-catalog', + label: 'Github', + external: true, + }, + { href: '/contacts', label: 'Contacts', external: false }, + { href: '/rights', label: 'Rights', external: false }, +]; + +const scrollToTop = () => { + window.scrollTo(0, 0); +}; + +export const Footer = () => { + return ( +
+
+
+ + + + Nice Gadgets logo + + +
+ + + +
+ Back to top + +
+
+
+ ); +}; diff --git a/src/modules/shared/Footer/index.ts b/src/modules/shared/Footer/index.ts new file mode 100644 index 00000000000..65e2506faf5 --- /dev/null +++ b/src/modules/shared/Footer/index.ts @@ -0,0 +1 @@ +export { Footer } from './Footer'; diff --git a/src/modules/shared/Header/Header.module.scss b/src/modules/shared/Header/Header.module.scss new file mode 100644 index 00000000000..0d781090c94 --- /dev/null +++ b/src/modules/shared/Header/Header.module.scss @@ -0,0 +1,243 @@ +@use '@/styles/variables' as *; +@use '@/styles/mixins' as *; + +.header { + position: sticky; + top: 0; + z-index: 999; + display: flex; + align-items: center; + min-height: $header-height-mobile; + background-color: $color-white; + + border-bottom: 1px solid $color-elements; + + @include media-down($breakpoint-tablet) { + justify-content: space-between; + } + + @include media-up($breakpoint-desktop) { + min-height: $header-height-desktop; + } +} + +.logoLink { + display: inline-flex; + align-items: center; + padding-inline: $spacing-md; + margin-right: $spacing-md; + + @include media-up($breakpoint-desktop) { + padding-inline: $spacing-lg; + margin-right: $spacing-lg; + } +} + +.logo { + display: block; + width: $logo-width-mobile; + height: auto; + + @include media-up($breakpoint-desktop) { + width: $logo-width-desktop; + } +} + +.menuButton { + width: $spacing-2xl; + height: $spacing-2xl; + background: none; + + border: none; + border-left: 1px solid $color-elements; + cursor: pointer; + + @include flex-center; + + @include media-up($breakpoint-tablet) { + display: none; + } +} + +.nav { + position: absolute; + top: $header-height-mobile + 1px; // 1px for border + right: 0; + left: 0; + z-index: 9999; + display: flex; + flex-direction: column; + height: calc(100vh - #{$header-height-mobile} - 1px); + + background-color: $color-white; + transition: transform $transition-base; + transform: translateX(-100%); + + @include media-up($breakpoint-tablet) { + position: static; + flex: 1; + flex-direction: row; + align-self: stretch; + height: auto; + + transform: translateX(0); + } +} + +.navMobileOpen { + transform: translateX(0); +} + +.navList { + display: flex; + flex-direction: column; + gap: $spacing-md; + padding: $spacing-md 0; + margin: 0; + list-style: none; + + @include media-up($breakpoint-tablet) { + flex-direction: row; + gap: $spacing-xl; + padding: 0; + } + + @include media-up($breakpoint-desktop) { + gap: $spacing-3xl; + } +} + +.navItem { + @include flex-center; +} + +.navLink { + display: block; + padding-top: $spacing-sm; + padding-bottom: $spacing-sm; + font-size: 12px; + font-weight: 800; + line-height: 11px; + letter-spacing: 4%; + text-decoration: none; + text-transform: uppercase; + color: $color-secondary; + + @include media-up($breakpoint-tablet) { + height: 100%; + + @include flex-center; + } +} + +.navLinkActive { + position: relative; + color: $color-primary; + + @include media-up($breakpoint-tablet) { + &::after { + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 3px; + background-color: $color-primary; + content: ''; + } + } +} + +.headerActionsList { + display: flex; + width: 100%; + padding: 0; + margin: auto 0 0; + border-top: 1px solid $color-elements; + list-style: none; + + @include media-up($breakpoint-tablet) { + width: auto; + margin-top: 0; + margin-left: auto; + border: none; + } +} + +.headerActionsItem { + flex: 1 1 0; + + @include flex-center; + + @include media-down($breakpoint-tablet) { + min-height: $spacing-3xl; + + &:not(:first-child) { + border-left: 1px solid $color-elements; + } + } + + @include media-up($breakpoint-tablet) { + width: $spacing-2xl; + border-left: 1px solid $color-elements; + } + + @include media-up($breakpoint-desktop) { + width: $spacing-3xl; + } +} + +.headerActionsLink { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + text-decoration: none; + color: $color-primary; + + > span:first-child { + @include visually-hidden; + } +} + +.iconWrapper { + position: relative; + display: flex; +} + +.badge { + position: absolute; + top: -6px; + right: -6px; + min-width: 14px; + height: 14px; + padding: 0 $spacing-xs; + background: $color-error; + border: 1px solid $color-white; + border-radius: 8px; + font-size: 9px; + font-weight: 700; + line-height: 14px; + text-align: center; + color: $color-white; + box-sizing: border-box; +} + +.headerActionsLinkActive { + position: relative; + color: $color-primary; + + &::after { + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 2px; + background-color: $color-primary; + content: ''; + + @include media-up($breakpoint-tablet) { + height: 3px; + } + } +} diff --git a/src/modules/shared/Header/Header.tsx b/src/modules/shared/Header/Header.tsx new file mode 100644 index 00000000000..49a46264751 --- /dev/null +++ b/src/modules/shared/Header/Header.tsx @@ -0,0 +1,149 @@ +import { useEffect, useState } from 'react'; +import styles from './Header.module.scss'; +import { Link, NavLink, useLocation } from 'react-router-dom'; +import { useFavorites } from '@/contexts/FavoritesContext'; +import { useCart } from '@/contexts/CartContext'; + +import logoMobile1x from '@/assets/images/nice-gadgets-logo-mobile.png'; +import logoMobile2x from '@/assets/images/nice-gadgets-logo-mobile@2x.png'; +import logoDesktop1x from '@/assets/images/nice-gadgets-logo-desktop.png'; +import logoDesktop2x from '@/assets/images/nice-gadgets-logo-desktop@2x.png'; +import iconMenu from '@/assets/icons/icon-menu.svg'; +import iconClose from '@/assets/icons/icon-close.svg'; +import iconFavorites from '@/assets/icons/icon-favorites.svg'; +import iconCart from '@/assets/icons/icon-cart.svg'; +import classNames from 'classnames'; + +const navItems = [ + { to: '/', label: 'Home', end: true }, + { to: '/phones', label: 'Phones', end: false }, + { to: '/tablets', label: 'Tablets', end: false }, + { to: '/accessories', label: 'Accessories', end: false }, +]; + +export const Header = () => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const location = useLocation(); + + const { favorites } = useFavorites(); + const favCount = favorites.length; + const { cartCount } = useCart(); + + useEffect(() => { + if (isMenuOpen) { + document.body.classList.add('no-scroll'); + } else { + document.body.classList.remove('no-scroll'); + } + + return () => { + document.body.classList.remove('no-scroll'); + }; + }, [isMenuOpen]); + + useEffect(() => { + setIsMenuOpen(false); + }, [location.pathname]); + + const handleMenuToggle = () => { + setIsMenuOpen(prev => !prev); + }; + + return ( +
+ + + + Nice Gadgets logo + + + + + + +
+ ); +}; diff --git a/src/modules/shared/Header/index.ts b/src/modules/shared/Header/index.ts new file mode 100644 index 00000000000..29429dc97e8 --- /dev/null +++ b/src/modules/shared/Header/index.ts @@ -0,0 +1 @@ +export { Header } from './Header'; diff --git a/src/modules/shared/MainLayout/MainLayout.module.scss b/src/modules/shared/MainLayout/MainLayout.module.scss new file mode 100644 index 00000000000..37448cac8d8 --- /dev/null +++ b/src/modules/shared/MainLayout/MainLayout.module.scss @@ -0,0 +1,9 @@ +.page { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.main { + flex: 1; +} diff --git a/src/modules/shared/MainLayout/MainLayout.tsx b/src/modules/shared/MainLayout/MainLayout.tsx new file mode 100644 index 00000000000..378a2d41054 --- /dev/null +++ b/src/modules/shared/MainLayout/MainLayout.tsx @@ -0,0 +1,16 @@ +import { Outlet } from 'react-router-dom'; +import { Header } from '../Header'; +import { Footer } from '../Footer'; +import styles from './MainLayout.module.scss'; + +export const MainLayout = () => { + return ( +
+
+
+ +
+
+
+ ); +}; diff --git a/src/modules/shared/MainLayout/index.ts b/src/modules/shared/MainLayout/index.ts new file mode 100644 index 00000000000..bfa7d492fe3 --- /dev/null +++ b/src/modules/shared/MainLayout/index.ts @@ -0,0 +1 @@ +export { MainLayout } from './MainLayout'; diff --git a/src/modules/shared/components/Breadcrumbs/Breadcrumbs.module.scss b/src/modules/shared/components/Breadcrumbs/Breadcrumbs.module.scss new file mode 100644 index 00000000000..91cc5347035 --- /dev/null +++ b/src/modules/shared/components/Breadcrumbs/Breadcrumbs.module.scss @@ -0,0 +1,57 @@ +@use '@/styles/variables' as *; + +.root { + display: flex; + align-items: center; + gap: $spacing-sm; + height: 16px; + margin-top: $spacing-lg; + margin-bottom: $spacing-lg; +} + +.home { + display: flex; + align-items: center; + color: $color-primary; + transition: color $transition-base; + + &:hover { + color: $color-secondary; + } +} + +.item { + display: flex; + align-items: center; + gap: $spacing-sm; +} + +.chevron { + display: flex; + align-items: center; + color: $color-icons; +} + +.link { + position: relative; + top: 1px; + font-size: 12px; + font-weight: 600; + line-height: 1; + text-decoration: none; + color: $color-primary; + transition: color $transition-base; + + &:hover { + color: $color-secondary; + } +} + +.current { + position: relative; + top: 1px; + font-size: 12px; + font-weight: 600; + line-height: 1; + color: $color-secondary; +} diff --git a/src/modules/shared/components/Breadcrumbs/Breadcrumbs.tsx b/src/modules/shared/components/Breadcrumbs/Breadcrumbs.tsx new file mode 100644 index 00000000000..4bcb4fa9a12 --- /dev/null +++ b/src/modules/shared/components/Breadcrumbs/Breadcrumbs.tsx @@ -0,0 +1,85 @@ +import { Link } from 'react-router-dom'; +import styles from './Breadcrumbs.module.scss'; + +type BreadcrumbItem = { + label: string; + path?: string; +}; + +type Props = { + items: BreadcrumbItem[]; +}; + +/* eslint-disable max-len */ +const HOME_OUTER_PATH = + 'M7.59.807a.67.67 0 0 1 .82 0l6 4.667c.162.126.257.32.257.526v7.333a2 2 0 0 1-2 2H3.334a2 2 0 0 1-2-2V6c0-.206.094-.4.257-.526zm-4.923 5.52v7.006a.667.667 0 0 0 .667.667h9.333a.666.666 0 0 0 .667-.667V6.326L8 2.178z'; +const HOME_INNER_PATH = + 'M5.334 8c0-.368.298-.667.666-.667h4c.368 0 .667.299.667.667v6.667a.667.667 0 1 1-1.333 0v-6H6.667v6a.667.667 0 0 1-1.333 0z'; +/* eslint-enable max-len */ + +const HomeIcon = () => ( + + + + +); + +const ChevronIcon = () => ( + + + +); + +export const Breadcrumbs = ({ items }: Props) => { + return ( + + ); +}; diff --git a/src/modules/shared/components/Breadcrumbs/index.ts b/src/modules/shared/components/Breadcrumbs/index.ts new file mode 100644 index 00000000000..28140a257ff --- /dev/null +++ b/src/modules/shared/components/Breadcrumbs/index.ts @@ -0,0 +1 @@ +export { Breadcrumbs } from './Breadcrumbs'; diff --git a/src/modules/shared/components/CartItem/CartItem.module.scss b/src/modules/shared/components/CartItem/CartItem.module.scss new file mode 100644 index 00000000000..3c6fed33243 --- /dev/null +++ b/src/modules/shared/components/CartItem/CartItem.module.scss @@ -0,0 +1,148 @@ +@use '@/styles/variables' as *; +@use '@/styles/mixins' as *; + +.root { + display: flex; + flex-direction: column; + gap: $spacing-md; + padding: $spacing-md; + background: $color-white; + border: 1px solid $color-elements; + + @include media-up($breakpoint-tablet) { + flex-direction: row; + align-items: center; + gap: $spacing-lg; + padding: $spacing-lg; + } +} + +.topRow { + display: flex; + flex-direction: row; + align-items: center; + gap: $spacing-md; + + @include media-up($breakpoint-tablet) { + flex: 1; + min-width: 0; + } +} + +.closeBtn { + display: flex; + flex-shrink: 0; + justify-content: center; + align-items: center; + width: 16px; + height: 16px; + padding: 0; + background: none; + border: none; + opacity: 0.5; + cursor: pointer; + transition: opacity $transition-base; + + &:hover { + opacity: 1; + } + + img { + display: block; + width: 16px; + height: 16px; + } +} + +.image { + flex-shrink: 0; + width: 80px; + height: 80px; + object-fit: contain; +} + +.name { + flex-grow: 1; + min-width: 0; + font-size: 14px; + font-weight: 600; + line-height: 1.5; + color: $color-primary; +} + +.bottomRow { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + height: 32px; + + @include media-up($breakpoint-tablet) { + flex-shrink: 0; + gap: $spacing-lg; + height: auto; + } +} + +.counter { + display: flex; + flex-shrink: 0; + align-items: center; + width: 96px; + height: 32px; +} + +.counterBtn { + @include flex-center; + + flex-shrink: 0; + width: 32px; + height: 32px; + background: none; + cursor: pointer; + transition: border-color $transition-base; + + img { + display: block; + width: 16px; + height: 16px; + } + + &:disabled { + opacity: 0.4; + cursor: default; + } +} + +.counterMinus { + border: 1px solid $color-elements; + + &:not(:disabled):hover { + border-color: $color-primary; + } +} + +.counterPlus { + border: 1px solid $color-icons; + + &:not(:disabled):hover { + border-color: $color-primary; + } +} + +.counterValue { + flex: 1; + font-size: 14px; + font-weight: 600; + text-align: center; + color: $color-primary; +} + +.price { + margin: 0; + font-size: 22px; + font-weight: 800; + line-height: 1.4; + white-space: nowrap; + color: $color-primary; +} \ No newline at end of file diff --git a/src/modules/shared/components/CartItem/CartItem.tsx b/src/modules/shared/components/CartItem/CartItem.tsx new file mode 100644 index 00000000000..eb5451a45bd --- /dev/null +++ b/src/modules/shared/components/CartItem/CartItem.tsx @@ -0,0 +1,63 @@ +import classNames from 'classnames'; +import type { CartItem as CartItemType } from '@/types/CartItem'; +import { useCart } from '@/contexts/CartContext'; +import closeIcon from '@/assets/icons/icon-close.svg'; +import minusIcon from '@/assets/icons/icon-minus.svg'; +import plusIcon from '@/assets/icons/icon-plus.svg'; +import styles from './CartItem.module.scss'; + +type Props = { + item: CartItemType; +}; + +export const CartItem = ({ item }: Props) => { + const { removeFromCart, updateQuantity } = useCart(); + const { product, quantity } = item; + + const handleDecrement = () => updateQuantity(product.id, quantity - 1); + const handleIncrement = () => updateQuantity(product.id, quantity + 1); + + return ( +
+
+ + + {product.name} + + {product.name} +
+ +
+
+ + {quantity} + +
+

${product.price * quantity}

+
+
+ ); +}; diff --git a/src/modules/shared/components/CartItem/index.ts b/src/modules/shared/components/CartItem/index.ts new file mode 100644 index 00000000000..186b364ebdf --- /dev/null +++ b/src/modules/shared/components/CartItem/index.ts @@ -0,0 +1 @@ +export { CartItem } from './CartItem'; diff --git a/src/modules/shared/components/CatalogPage/CatalogPage.module.scss b/src/modules/shared/components/CatalogPage/CatalogPage.module.scss new file mode 100644 index 00000000000..640d5c1545f --- /dev/null +++ b/src/modules/shared/components/CatalogPage/CatalogPage.module.scss @@ -0,0 +1,80 @@ +@use '@/styles/variables' as *; +@use '@/styles/mixins' as *; + +.page { + @include content-width; +} + +.title { + margin: $spacing-lg 0 $spacing-sm; + font-size: 32px; + font-weight: 800; + line-height: 41px; + letter-spacing: -0.01em; + color: $color-primary; + + @include media-up($breakpoint-tablet) { + margin-top: $spacing-2xl; + } +} + +.count { + margin: 0; + font-size: 14px; + font-weight: 500; + line-height: 21px; + color: $color-secondary; +} + +.error { + display: flex; + flex-direction: column; + align-items: center; + gap: $spacing-md; + padding: $spacing-3xl 0; + font-size: 14px; + font-weight: 400; + line-height: 21px; + color: $color-primary; +} + +.controls { + display: flex; + gap: $spacing-md; + margin-top: $spacing-lg; + margin-bottom: $spacing-lg; +} + +.sortControl { + flex: 1; + + @include media-up($breakpoint-tablet) { + flex: none; + width: 187px; + } +} + +.perPageControl { + flex: 1; + + @include media-up($breakpoint-tablet) { + flex: none; + width: 136px; + } +} + +.reloadButton { + padding: 9px $spacing-xl 7px; + background-color: $color-primary; + border: none; + cursor: pointer; + font-size: 14px; + font-weight: 600; + line-height: 21px; + color: $color-white; + transition: background-color $transition-base; + + &:hover { + background-color: $color-hover; + } +} diff --git a/src/modules/shared/components/CatalogPage/CatalogPage.tsx b/src/modules/shared/components/CatalogPage/CatalogPage.tsx new file mode 100644 index 00000000000..c02c92b5dfb --- /dev/null +++ b/src/modules/shared/components/CatalogPage/CatalogPage.tsx @@ -0,0 +1,227 @@ +import { useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import type { Product } from '@/types/Product'; +import { Breadcrumbs } from '../Breadcrumbs'; +import { ProductsList } from '../ProductsList'; +import { Loader } from '../Loader'; +import { Dropdown } from '../Dropdown'; +import { Pagination } from '../Pagination'; +import styles from './CatalogPage.module.scss'; + +type Category = 'phones' | 'tablets' | 'accessories'; +type SortKey = 'age' | 'title' | 'price'; + +type Props = { + category: Category; + title: string; +}; + +const EMPTY_MESSAGES: Record = { + phones: 'There are no phones yet', + tablets: 'There are no tablets yet', + accessories: 'There are no accessories yet', +}; + +const SORT_OPTIONS = [ + { value: 'age', label: 'Newest' }, + { value: 'title', label: 'Alphabetically' }, + { value: 'price', label: 'Cheapest' }, +]; + +const PER_PAGE_OPTIONS = [ + { value: '4', label: '4' }, + { value: '8', label: '8' }, + { value: '16', label: '16' }, + { value: 'all', label: 'All' }, +]; + +const DEFAULT_SORT: SortKey = 'age'; +const DEFAULT_PER_PAGE = '16'; +const VALID_SORT_VALUES: SortKey[] = ['age', 'title', 'price']; +const VALID_PER_PAGE_VALUES = ['4', '8', '16', 'all'] as const; + +const isSortKey = (value: string | null): value is SortKey => { + return VALID_SORT_VALUES.includes(value as SortKey); +}; + +const isPerPageValue = ( + value: string | null, +): value is (typeof VALID_PER_PAGE_VALUES)[number] => { + return ( + value !== null && + VALID_PER_PAGE_VALUES.includes( + value as (typeof VALID_PER_PAGE_VALUES)[number], + ) + ); +}; + +export const CatalogPage = ({ category, title }: Props) => { + const [products, setProducts] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(false); + const [retryCount, setRetryCount] = useState(0); + + const [searchParams, setSearchParams] = useSearchParams(); + const sortFromUrl = searchParams.get('sort'); + const perPageFromUrl = searchParams.get('perPage'); + const pageFromUrl = searchParams.get('page'); + + const sortParam: SortKey = isSortKey(sortFromUrl) + ? sortFromUrl + : DEFAULT_SORT; + const perPageParam = isPerPageValue(perPageFromUrl) + ? perPageFromUrl + : DEFAULT_PER_PAGE; + const pageParam = Math.max(1, Math.floor(Number(pageFromUrl) || 0)); + + const handleSortChange = (value: string) => { + setSearchParams(prev => { + const params = new URLSearchParams(prev); + + if (value === DEFAULT_SORT) { + params.delete('sort'); + } else { + params.set('sort', value); + } + + return params; + }); + }; + + const handlePerPageChange = (value: string) => { + setSearchParams(prev => { + const params = new URLSearchParams(prev); + + params.delete('page'); + + if (value === DEFAULT_PER_PAGE) { + params.delete('perPage'); + } else { + params.set('perPage', value); + } + + return params; + }); + }; + + const handlePageChange = (page: number) => { + setSearchParams(prev => { + const params = new URLSearchParams(prev); + + if (page === 1) { + params.delete('page'); + } else { + params.set('page', String(page)); + } + + return params; + }); + + window.scrollTo(0, 0); + }; + + useEffect(() => { + const controller = new AbortController(); + + setError(false); + setIsLoading(true); + + fetch('./api/products.json', { signal: controller.signal }) + .then(res => res.json()) + .then(data => { + setProducts(data.filter(p => p.category === category)); + setIsLoading(false); + }) + .catch(err => { + if (!(err instanceof DOMException && err.name === 'AbortError')) { + setError(true); + setIsLoading(false); + } + }); + + return () => controller.abort(); + }, [category, retryCount]); + + const sortedProducts: Product[] = [...products].sort((a, b) => { + if (sortParam === 'age') { + return b.year - a.year; + } + + if (sortParam === 'title') { + return a.name.localeCompare(b.name); + } + + if (sortParam === 'price') { + return a.price - b.price; + } + + return 0; + }); + const totalPages = + perPageParam === 'all' + ? 1 + : Math.ceil(sortedProducts.length / Number(perPageParam)); + + const currentPage = Math.min(pageParam, Math.max(totalPages, 1)); + + const perPageNum = Number(perPageParam); + const sliceStart = (currentPage - 1) * perPageNum; + const visibleProducts: Product[] = + perPageParam === 'all' + ? sortedProducts + : sortedProducts.slice(sliceStart, sliceStart + perPageNum); + + return ( +
+ +

{title}

+ + {isLoading ? ( + + ) : error ? ( +
+

Something went wrong

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

{EMPTY_MESSAGES[category]}

+ ) : ( + <> +

{products.length} models

+
+
+ +
+
+ +
+
+ + + {perPageParam !== 'all' && totalPages > 1 && ( + + )} + + )} +
+ ); +}; diff --git a/src/modules/shared/components/CatalogPage/index.ts b/src/modules/shared/components/CatalogPage/index.ts new file mode 100644 index 00000000000..8decfd6bf14 --- /dev/null +++ b/src/modules/shared/components/CatalogPage/index.ts @@ -0,0 +1 @@ +export { CatalogPage } from './CatalogPage'; diff --git a/src/modules/shared/components/Dropdown/Dropdown.module.scss b/src/modules/shared/components/Dropdown/Dropdown.module.scss new file mode 100644 index 00000000000..86c2438191e --- /dev/null +++ b/src/modules/shared/components/Dropdown/Dropdown.module.scss @@ -0,0 +1,59 @@ +@use '@/styles/variables' as *; +@use '@/styles/mixins' as *; + +.wrapper { + display: flex; + flex-direction: column; + gap: $spacing-xs; +} + +.label { + font-size: 12px; + font-weight: 700; + line-height: 15px; + color: $color-secondary; +} + +.selectWrapper { + position: relative; + display: flex; + align-items: center; +} + +.select { + width: 100%; + height: 40px; + padding-right: $spacing-2xl; + padding-left: 12px; + background: $color-white; + border: 1px solid $color-icons; + cursor: pointer; + font-size: 14px; + font-weight: 700; + line-height: 21px; + color: $color-primary; + transition: border-color $transition-base; + -webkit-appearance: none; + + appearance: none; + + &:hover, + &:focus { + border-color: $color-primary; + outline: none; + } +} + +.chevron { + position: absolute; + right: 12px; + display: flex; + align-items: center; + pointer-events: none; + color: $color-secondary; + transition: color $transition-base; +} + +.selectWrapper:hover .chevron { + color: $color-primary; +} diff --git a/src/modules/shared/components/Dropdown/Dropdown.tsx b/src/modules/shared/components/Dropdown/Dropdown.tsx new file mode 100644 index 00000000000..d78e0be87c6 --- /dev/null +++ b/src/modules/shared/components/Dropdown/Dropdown.tsx @@ -0,0 +1,52 @@ +import { useId } from 'react'; +import styles from './Dropdown.module.scss'; + +type Option = { value: string; label: string }; + +type Props = { + label: string; + value: string; + options: Option[]; + onChange: (value: string) => void; +}; + +const ChevronIcon = () => ( + + + +); + +export const Dropdown = ({ label, value, options, onChange }: Props) => { + const selectId = useId(); + + return ( +
+ +
+ + +
+
+ ); +}; diff --git a/src/modules/shared/components/Dropdown/index.ts b/src/modules/shared/components/Dropdown/index.ts new file mode 100644 index 00000000000..c0ad316fc26 --- /dev/null +++ b/src/modules/shared/components/Dropdown/index.ts @@ -0,0 +1 @@ +export { Dropdown } from './Dropdown'; 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..01c682c46ed --- /dev/null +++ b/src/modules/shared/components/Loader/Loader.module.scss @@ -0,0 +1,27 @@ +@use '@/styles/variables' as *; +@use '@/styles/mixins' as *; + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.root { + @include flex-center; + + padding: $spacing-2xl 0; + + @include media-up($breakpoint-tablet) { + padding: $spacing-3xl 0; + } +} + +.spinner { + width: $spacing-2xl; + height: $spacing-2xl; + border: 4px solid $color-elements; + border-top-color: $color-primary; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} diff --git a/src/modules/shared/components/Loader/Loader.tsx b/src/modules/shared/components/Loader/Loader.tsx new file mode 100644 index 00000000000..990fdfbbfbb --- /dev/null +++ b/src/modules/shared/components/Loader/Loader.tsx @@ -0,0 +1,7 @@ +import styles from './Loader.module.scss'; + +export const Loader = () => ( +
+
+
+); 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/Pagination/Pagination.module.scss b/src/modules/shared/components/Pagination/Pagination.module.scss new file mode 100644 index 00000000000..58d2c8d003c --- /dev/null +++ b/src/modules/shared/components/Pagination/Pagination.module.scss @@ -0,0 +1,81 @@ +@use '@/styles/variables' as *; +@use '@/styles/mixins' as *; + +.root { + display: flex; + flex-direction: row; + align-items: center; + gap: $spacing-sm; + width: fit-content; + padding: 0; + margin: $spacing-2xl auto; + list-style: none; +} + +.button { + @include flex-center; + + width: 32px; + height: 32px; + padding: 0; + background: $color-white; + border: none; + cursor: pointer; + transition: border-color $transition-base; +} + +.arrow { + border: 1px solid $color-icons; + color: $color-primary; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &:hover:not(:disabled) { + border-color: $color-primary; + } +} + +.icon { + display: flex; +} + +.arrowLeft .icon { + transform: scaleX(-1); +} + +.page { + border: 1px solid $color-elements; + font-size: 14px; + font-weight: 600; + line-height: 21px; + color: $color-primary; + + &:hover { + border-color: $color-primary; + } +} + +.ellipsis { + display: flex; + justify-content: center; + align-items: flex-end; + width: 32px; + height: 32px; + padding-bottom: $spacing-xs; + font-size: 14px; + font-weight: 600; + color: $color-secondary; +} + +.active { + background: $color-primary; + border-color: transparent; + color: $color-white; + + &:hover { + border-color: transparent; + } +} diff --git a/src/modules/shared/components/Pagination/Pagination.tsx b/src/modules/shared/components/Pagination/Pagination.tsx new file mode 100644 index 00000000000..5c371a9cfaf --- /dev/null +++ b/src/modules/shared/components/Pagination/Pagination.tsx @@ -0,0 +1,155 @@ +import { useMemo } from 'react'; +import cn from 'classnames'; +import styles from './Pagination.module.scss'; + +const BUTTON_SIZE = 32; +const BUTTON_GAP = 8; +const BREAKPOINT_TABLET = 640; +const BREAKPOINT_DESKTOP = 1200; +const PADDING_MOBILE = 32; // 16px × 2 sides +const PADDING_TABLET = 48; // 24px × 2 sides +const PADDING_DESKTOP = 64; // 32px × 2 sides +// Minimum width: 7 buttons (< 1 ... X ... N >) with delta=0 +const MIN_PAGINATION_WIDTH = 7 * BUTTON_SIZE + 6 * BUTTON_GAP; +// Each delta level adds 2 buttons (left + right of current) +const WIDTH_PER_DELTA = 2 * (BUTTON_SIZE + BUTTON_GAP); + +type PageItem = number | 'ellipsis-left' | 'ellipsis-right'; + +type Props = { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; +}; + +function getVisiblePages( + current: number, + total: number, + delta: number, +): PageItem[] { + const rangeStart = Math.max(2, current - delta); + const rangeEnd = Math.min(total - 1, current + delta); + + const result: PageItem[] = [1]; + + if (rangeStart > 2) { + result.push('ellipsis-left'); + } + + for (let i = rangeStart; i <= rangeEnd; i++) { + result.push(i); + } + + if (rangeEnd < total - 1) { + result.push('ellipsis-right'); + } + + if (total > 1) { + result.push(total); + } + + return result; +} + +const ChevronRight = () => ( + +); + +export const Pagination = ({ + currentPage, + totalPages, + onPageChange, +}: Props) => { + const delta = useMemo(() => { + const horizontalPadding = + window.innerWidth >= BREAKPOINT_DESKTOP + ? PADDING_DESKTOP + : window.innerWidth >= BREAKPOINT_TABLET + ? PADDING_TABLET + : PADDING_MOBILE; + const available = window.innerWidth - horizontalPadding; + + return Math.min( + totalPages, + Math.max( + 0, + Math.floor((available - MIN_PAGINATION_WIDTH) / WIDTH_PER_DELTA), + ), + ); + }, [totalPages]); + + const handlePageClick = (page: number) => { + if (page === currentPage) { + return; + } + + onPageChange(page); + }; + + return ( + + ); +}; diff --git a/src/modules/shared/components/Pagination/index.ts b/src/modules/shared/components/Pagination/index.ts new file mode 100644 index 00000000000..0a1fd4dad6c --- /dev/null +++ b/src/modules/shared/components/Pagination/index.ts @@ -0,0 +1 @@ +export { Pagination } from './Pagination'; 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..b3525c79209 --- /dev/null +++ b/src/modules/shared/components/ProductCard/ProductCard.module.scss @@ -0,0 +1,164 @@ +@use 'sass:color'; +@use '@/styles/variables' as *; +@use '@/styles/mixins' as *; + +.card { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: $spacing-sm; + width: 100%; + height: 100%; + padding: $spacing-xl; + background: $color-white; + border: 1px solid $color-elements; + box-sizing: border-box; + transition: box-shadow $transition-base; + + &:hover { + z-index: 1; + box-shadow: 0 2px 15px 0 rgba(0, 0, 0, 0.1); + } +} + +.imageWrapper { + display: flex; + flex-grow: 1; + justify-content: center; + align-items: center; + width: 100%; +} + +.image { + max-width: 100%; + max-height: 100%; + object-fit: contain; + transition: transform $transition-base; + + .card:hover & { + transform: scale(1.1); + } +} + +.titleWrapper { + width: 100%; + padding-top: $spacing-md; +} + +.title { + margin: 0; + font-size: 14px; + font-weight: 500; + line-height: 21px; + text-decoration: none; + color: $color-primary; +} + +.price { + display: flex; + flex-direction: row; + align-items: center; + gap: $spacing-sm; +} + +.currentPrice { + font-size: 22px; + font-weight: 800; + line-height: 140%; + color: $color-primary; +} + +.oldPrice { + font-size: 22px; + font-weight: 600; + line-height: 28px; + text-decoration: line-through; + color: $color-secondary; +} + +.divider { + align-self: stretch; + width: 100%; + height: 1px; + background: $color-elements; +} + +.specs { + display: flex; + flex-direction: column; + gap: $spacing-sm; + width: 100%; + padding: $spacing-sm 0; +} + +.spec { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + width: 100%; +} + +.specLabel { + font-size: 12px; + font-weight: 600; + line-height: 15px; + color: $color-secondary; +} + +.specValue { + font-size: 12px; + font-weight: 600; + line-height: 15px; + text-align: right; + color: $color-primary; +} + +.buttons { + display: flex; + flex-direction: row; + align-items: center; + gap: $spacing-sm; + width: 100%; +} + +.addToCart { + @include flex-center; + + flex: 1; + height: 40px; + background: $color-primary; + border: none; + cursor: pointer; + font-size: 14px; + font-weight: 600; + line-height: 21px; + color: $color-white; + transition: background $transition-base; + + &:hover { + background: color.adjust($color-primary, $lightness: 10%); + } +} + +.addToFavorites { + @include flex-center; + + flex-shrink: 0; + + width: 40px; + height: 40px; + background: $color-white; + border: 1px solid $color-icons; + cursor: pointer; + transition: border-color $transition-base; + + &:hover { + border-color: $color-primary; + } + + img { + width: 16px; + height: 16px; + } +} diff --git a/src/modules/shared/components/ProductCard/ProductCard.tsx b/src/modules/shared/components/ProductCard/ProductCard.tsx new file mode 100644 index 00000000000..5ef4b937714 --- /dev/null +++ b/src/modules/shared/components/ProductCard/ProductCard.tsx @@ -0,0 +1,84 @@ +import { Link } from 'react-router-dom'; +import heartIcon from '@/assets/icons/icon-favorites.svg'; +import heartIconSelected from '@/assets/icons/icon-favorites-selected.svg'; +import type { Product } from '@/types/Product'; +import { useFavorites } from '@/contexts/FavoritesContext'; +import { useCart } from '@/contexts/CartContext'; +import styles from './ProductCard.module.scss'; + +type Props = { + product: Product; + hideOldPrice?: boolean; +}; + +export const ProductCard = ({ product, hideOldPrice = false }: Props) => { + const hasDiscount = !hideOldPrice && product.fullPrice > product.price; + const { toggleFavorite, isFavorite } = useFavorites(); + const isProductFavorited = isFavorite(product.id); + const { toggleCart, isInCart } = useCart(); + const isProductInCart = isInCart(product.id); + + return ( +
+
+ + {product.name} + +
+ +
+ + {product.name} + +
+ +
+ ${product.price} + {hasDiscount && ( + ${product.fullPrice} + )} +
+ +
+ +
+
+ Screen + {product.screen} +
+
+ Capacity + {product.capacity} +
+
+ RAM + {product.ram} +
+
+ +
+ + +
+
+ ); +}; 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/ProductsList/ProductsList.module.scss b/src/modules/shared/components/ProductsList/ProductsList.module.scss new file mode 100644 index 00000000000..ca5bac86635 --- /dev/null +++ b/src/modules/shared/components/ProductsList/ProductsList.module.scss @@ -0,0 +1,9 @@ +@use '@/styles/variables' as *; +@use '@/styles/mixins' as *; + +.productsList { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(229px, 1fr)); + gap: $spacing-md; + margin-top: $spacing-lg; +} diff --git a/src/modules/shared/components/ProductsList/ProductsList.tsx b/src/modules/shared/components/ProductsList/ProductsList.tsx new file mode 100644 index 00000000000..2049f7fb887 --- /dev/null +++ b/src/modules/shared/components/ProductsList/ProductsList.tsx @@ -0,0 +1,17 @@ +import type { Product } from '@/types/Product'; +import { ProductCard } from '../ProductCard'; +import styles from './ProductsList.module.scss'; + +type Props = { + products: Product[]; +}; + +export const ProductsList = ({ products }: Props) => { + return ( +
+ {products.map(product => ( + + ))} +
+ ); +}; diff --git a/src/modules/shared/components/ProductsList/index.ts b/src/modules/shared/components/ProductsList/index.ts new file mode 100644 index 00000000000..ae9d590cdbd --- /dev/null +++ b/src/modules/shared/components/ProductsList/index.ts @@ -0,0 +1 @@ +export { ProductsList } from './ProductsList'; diff --git a/src/styles/_fonts.scss b/src/styles/_fonts.scss new file mode 100644 index 00000000000..f005fe87476 --- /dev/null +++ b/src/styles/_fonts.scss @@ -0,0 +1,127 @@ +@font-face { + font-family: Mont; + src: url('@/assets/fonts/Mont-Thin.woff2') format('woff2'); + font-weight: 100; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: Mont; + src: url('@/assets/fonts/Mont-ThinItalic.woff2') format('woff2'); + font-weight: 100; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: Mont; + src: url('@/assets/fonts/Mont-ExtraLight.woff2') format('woff2'); + font-weight: 200; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: Mont; + src: url('@/assets/fonts/Mont-ExtraLightItalic.woff2') format('woff2'); + font-weight: 200; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: Mont; + src: url('@/assets/fonts/Mont-Light.woff2') format('woff2'); + font-weight: 300; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: Mont; + src: url('@/assets/fonts/Mont-LightItalic.woff2') format('woff2'); + font-weight: 300; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: Mont; + src: url('@/assets/fonts/Mont-Regular.woff2') format('woff2'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: Mont; + src: url('@/assets/fonts/Mont-RegularItalic.woff2') format('woff2'); + font-weight: 400; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: Mont; + src: url('@/assets/fonts/Mont-SemiBold.woff2') format('woff2'); + font-weight: 600; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: Mont; + src: url('@/assets/fonts/Mont-SemiBoldItalic.woff2') format('woff2'); + font-weight: 600; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: Mont; + src: url('@/assets/fonts/Mont-Bold.woff2') format('woff2'); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: Mont; + src: url('@/assets/fonts/Mont-BoldItalic.woff2') format('woff2'); + font-weight: 700; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: Mont; + src: url('@/assets/fonts/Mont-Heavy.woff2') format('woff2'); + font-weight: 800; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: Mont; + src: url('@/assets/fonts/Mont-HeavyItalic.woff2') format('woff2'); + font-weight: 800; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: Mont; + src: url('@/assets/fonts/Mont-Black.woff2') format('woff2'); + font-weight: 900; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: Mont; + src: url('@/assets/fonts/Mont-BlackItalic.woff2') format('woff2'); + font-weight: 900; + font-style: italic; + font-display: swap; +} diff --git a/src/styles/_mixins.scss b/src/styles/_mixins.scss new file mode 100644 index 00000000000..1904a203109 --- /dev/null +++ b/src/styles/_mixins.scss @@ -0,0 +1,122 @@ +@use 'variables' as *; + +/// Applies styles from the given breakpoint and up (mobile-first). +/// Example: @include media-up($breakpoint-tablet) { ... } +@mixin media-up($breakpoint) { + @media (min-width: $breakpoint) { + @content; + } +} + +/// Applies styles below the given breakpoint (exclusive). +/// Uses -1px to avoid overlap with media-up at the same value. +@mixin media-down($breakpoint) { + @media (max-width: ($breakpoint - 1px)) { + @content; + } +} + +/// Applies styles within a range: from $from to $to (exclusive). +/// Useful for targeting only a specific viewport segment. +@mixin media-between($from, $to) { + @media (min-width: $from) and (max-width: ($to - 1px)) { + @content; + } +} + +/// Main layout grid based on design specs: +/// mobile: 4 columns, tablet: 12, desktop: 24 (fixed container). +/// Use on parent layout wrappers/sections. +@mixin layout-grid { + display: grid; + grid-template-columns: repeat($grid-columns-mobile, minmax(0, 1fr)); + column-gap: $grid-gutter; + padding-inline: $grid-padding-mobile; + + @include media-up($breakpoint-tablet) { + grid-template-columns: repeat($grid-columns-tablet, minmax(0, 1fr)); + padding-inline: $grid-padding-tablet; + } + + @include media-up($breakpoint-desktop) { + grid-template-columns: repeat($grid-columns-desktop, $grid-desktop-column-width); + width: $grid-desktop-container; + padding-inline: 0; + margin-inline: auto; + } +} + +/// Controls how many columns an item spans per breakpoint. +/// Use on children inside a layout-grid container. +/// tablet/desktop fallback to the previous value if omitted. +@mixin grid-span($mobile, $tablet: $mobile, $desktop: $tablet) { + grid-column: span $mobile; + + @include media-up($breakpoint-tablet) { + grid-column: span $tablet; + } + + @include media-up($breakpoint-desktop) { + grid-column: span $desktop; + } +} + +/// Controls the starting grid column per breakpoint. +/// Combine with grid-span for precise positioning. +@mixin grid-start($mobile, $tablet: $mobile, $desktop: $tablet) { + grid-column-start: $mobile; + + @include media-up($breakpoint-tablet) { + grid-column-start: $tablet; + } + + @include media-up($breakpoint-desktop) { + grid-column-start: $desktop; + } +} + +/// Quick flex centering on both axes. +/// Great for icons, buttons, loaders, empty states. +@mixin flex-center { + display: flex; + justify-content: center; + align-items: center; +} + +/// Flex layout with space-between and vertical centering. +/// Common for header rows, toolbars, card tops. +@mixin flex-between { + display: flex; + justify-content: space-between; + align-items: center; +} + +/// Content container similar to Bootstrap container: +/// max-width + centered + responsive horizontal paddings. +@mixin content-width { + max-width: $content-max-width; + padding: 0 $content-padding-mobile; + margin: 0 auto; + + @include media-up($breakpoint-tablet) { + padding: 0 $content-padding-tablet; + } + + @include media-up($breakpoint-desktop) { + padding: 0 $content-padding-desktop; + } +} + +/// Visually hides an element while keeping it accessible +/// for screen readers (a11y helper). +@mixin visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + border: 0; + white-space: nowrap; + clip: rect(0, 0, 0, 0); +} diff --git a/src/styles/_reset.scss b/src/styles/_reset.scss new file mode 100644 index 00000000000..3fdf9961c9f --- /dev/null +++ b/src/styles/_reset.scss @@ -0,0 +1,76 @@ +// https://piccalil.li/blog/a-modern-css-reset + +/* Box sizing rules */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +/* Remove default margin */ +body, +h1, +h2, +h3, +h4, +p, +figure, +blockquote, +dl, +dd { + margin: 0; +} + +/* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */ +ul[role='list'], +ol[role='list'] { + list-style: none; +} + +/* Set core root defaults */ +html:focus-within { + scroll-behavior: smooth; +} + +/* Set core body defaults */ +body { + min-height: 100vh; + text-rendering: optimizeSpeed; + line-height: 1.5; +} + +/* A elements that don't have a class get default styles */ +a:not([class]) { + text-decoration-skip-ink: auto; +} + +/* Make images easier to work with */ +img, +picture { + display: block; + max-width: 100%; +} + +/* Inherit fonts for inputs and buttons */ +input, +button, +textarea, +select { + font: inherit; +} + +/* Remove all animations, transitions and smooth scroll for people that prefer not to see them */ +@media (prefers-reduced-motion: reduce) { + html:focus-within { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/src/styles/_typography.scss b/src/styles/_typography.scss new file mode 100644 index 00000000000..9c5fb9ee2ac --- /dev/null +++ b/src/styles/_typography.scss @@ -0,0 +1,103 @@ +@use 'variables' as *; + +body { + font-family: $font-family-base; + color: $color-primary; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + margin: 0; + font-family: $font-family-base; + color: $color-primary; +} + +h1 { + font-size: 32px; + font-weight: 700; + line-height: 41px; + letter-spacing: -0.01em; + + @media (min-width: $breakpoint-tablet) { + font-size: 48px; + line-height: 56px; + } +} + +h2 { + font-size: 22px; + font-weight: 700; + line-height: 31px; + letter-spacing: 0; + + @media (min-width: $breakpoint-tablet) { + font-size: 32px; + line-height: 41px; + letter-spacing: -0.01em; + } +} + +h3 { + font-size: 20px; + font-weight: 600; + line-height: 26px; + letter-spacing: 0; + + @media (min-width: $breakpoint-tablet) { + font-size: 22px; + font-weight: 700; + line-height: 31px; + } +} + +h4 { + font-size: 16px; + font-weight: 600; + line-height: 20px; + letter-spacing: 0; + + @media (min-width: $breakpoint-tablet) { + font-size: 20px; + line-height: 26px; + } +} + +.body-text, +p { + margin: 0; + font-family: $font-family-base; + font-size: 14px; + font-weight: 400; + line-height: 21px; + letter-spacing: 0; + color: $color-primary; +} + +.button-text { + font-family: $font-family-base; + font-size: 14px; + font-weight: 600; + line-height: 21px; + letter-spacing: 0; +} + +.small-text { + font-family: $font-family-base; + font-size: 12px; + font-weight: 600; + line-height: 15px; + letter-spacing: 0; +} + +.uppercase-text { + font-family: $font-family-base; + font-size: 12px; + font-weight: 700; + line-height: 11px; + letter-spacing: 0.04em; + text-transform: uppercase; +} diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss new file mode 100644 index 00000000000..a88ce921fa2 --- /dev/null +++ b/src/styles/_variables.scss @@ -0,0 +1,55 @@ +// Colors +$color-primary: #313237; +$color-secondary: #89939a; +$color-icons: #b4bdc3; +$color-elements: #e2e6e9; +$color-bg: #fafbfc; +$color-white: #fff; +$color-border: #e2e6e9; +$color-hover: #dcdfe1; +$color-error: #eb5757; +$color-success: #27ae60; + +// Typography +$font-family-base: 'Mont', 'Helvetica Neue', Arial, sans-serif; +$font-size-base: 16px; +$line-height-base: 1.5; + +// Breakpoints +$breakpoint-mobile: 320px; +$breakpoint-tablet: 640px; +$breakpoint-desktop: 1200px; + +// Spacing +$spacing-xs: 4px; +$spacing-sm: 8px; +$spacing-md: 16px; +$spacing-lg: 24px; +$spacing-xl: 32px; +$spacing-2xl: 48px; +$spacing-3xl: 64px; + +// Layout +$content-max-width: 1200px; +$content-padding-mobile: 16px; +$content-padding-tablet: 24px; +$content-padding-desktop: 32px; + +// Transitions +$transition-base: 0.3s ease; + +// Grid +$grid-gutter: 16px; +$grid-columns-mobile: 4; +$grid-columns-tablet: 12; +$grid-columns-desktop: 24; +$grid-padding-mobile: 16px; +$grid-padding-tablet: 24px; +$grid-desktop-container: 1200px; +$grid-desktop-column-width: 32px; + +// Elements +$header-height-mobile: 48px; +$header-height-desktop: 64px; +$logo-width-mobile: 64px; +$logo-width-desktop: 80px; diff --git a/src/styles/index.scss b/src/styles/index.scss new file mode 100644 index 00000000000..036b6c72b63 --- /dev/null +++ b/src/styles/index.scss @@ -0,0 +1,3 @@ +@use 'fonts'; +@use 'reset'; +@use 'typography'; diff --git a/src/types/CartItem.ts b/src/types/CartItem.ts new file mode 100644 index 00000000000..d189582dce0 --- /dev/null +++ b/src/types/CartItem.ts @@ -0,0 +1,6 @@ +import type { Product } from './Product'; + +export type CartItem = { + product: Product; + quantity: number; +}; 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/ProductDetail.ts b/src/types/ProductDetail.ts new file mode 100644 index 00000000000..10c381238ea --- /dev/null +++ b/src/types/ProductDetail.ts @@ -0,0 +1,21 @@ +export type ProductDetail = { + 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[] }[]; + screen: string; + resolution: string; + processor: string; + ram: string; + camera: string; + zoom: string; + cell: string[]; +}; diff --git a/src/utils/storage.ts b/src/utils/storage.ts new file mode 100644 index 00000000000..828525a29d1 --- /dev/null +++ b/src/utils/storage.ts @@ -0,0 +1,9 @@ +export function loadFromStorage(key: string, fallback: T): T { + try { + const raw = localStorage.getItem(key); + + return raw ? (JSON.parse(raw) as T) : fallback; + } catch { + return fallback; + } +} diff --git a/tsconfig.json b/tsconfig.json index cfb168bb26c..195ac8bfeb1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,10 +1,12 @@ { "extends": "@mate-academy/students-ts-config", - "include": [ - "src" - ], + "include": ["src"], "compilerOptions": { "sourceMap": false, - "types": ["node", "cypress"] + "types": ["node", "cypress"], + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } } } diff --git a/vite.config.ts b/vite.config.ts index 5a33944a9b4..7938d6d930a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,13 @@ +import path from 'node:path'; import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], -}) + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +});