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
+
+
+
+
+
+ Checkout
+
+
+
+ )}
+
+ );
+};
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
+
+
+
+
+
+ );
+};
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}
+
{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) => (
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
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 (
+
+
+
+
navigate(-1)}>
+
+ Back
+
+
+
{product.name}
+
+
+
+ {detail && }
+
+
+ {detail && (
+ <>
+
+ ID: {detail.id}
+ Available colors
+
+
+
+
+ {detail.colorsAvailable.map(color => (
+
+ {
+ const target = findVariant(
+ allDetails,
+ detail.namespaceId,
+ color,
+ selectedCapacity,
+ );
+
+ if (target) {
+ navigate(`/product/${target.id}`);
+ }
+ }}
+ className={styles.radioInput}
+ aria-label={`Select color ${color.replace(/-/g, ' ')}`}
+ />
+
+
+ ))}
+
+
+
+
+
+
+
Select capacity
+
+ {detail.capacityAvailable.map(cap => (
+
+ {
+ const target = findVariant(
+ allDetails,
+ detail.namespaceId,
+ selectedColor,
+ cap,
+ );
+
+ if (target) {
+ navigate(`/product/${target.id}`);
+ }
+ }}
+ className={styles.radioInput}
+ />
+
+ {cap}
+
+
+ ))}
+
+
+
+
+
+
+ ${detail.priceDiscount}
+
+ ${detail.priceRegular}
+
+
+
+
+
toggleCart(product)}
+ >
+ {isProductInCart ? 'Added to cart' : 'Add to cart'}
+
+
toggleFavorite(product)}
+ aria-label="Add to favourites"
+ >
+
+
+
+
+
+ {(
+ [
+ ['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) => (
+
handleThumbnailClick(i)}
+ aria-label={`${alt} - thumbnail ${i + 1}`}
+ >
+
+
+ ))}
+
+
+
+
{
+ swiperRef.current = s;
+ }}
+ onSlideChange={s => setActiveIndex(s.activeIndex)}
+ slidesPerView={1}
+ className={styles.swiper}
+ >
+ {images.map((src, i) => (
+
+
+
+ ))}
+
+
+
+ );
+};
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 (
+
+
+
+
+
+
+
+
+
+
+
+
+ {navLinks.map(({ href, label, external }) =>
+ external ? (
+
+ {label}
+
+ ) : (
+
+ {label}
+
+ ),
+ )}
+
+
+
+
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 (
+
+
+
+
+
+
+
+
+
+
+ {navItems.map(item => (
+
+
+ classNames(styles.navLink, {
+ [styles.navLinkActive]: isActive,
+ })
+ }
+ >
+ {item.label}
+
+
+ ))}
+
+
+
+
+
+ classNames(styles.headerActionsLink, {
+ [styles.headerActionsLinkActive]: isActive,
+ })
+ }
+ >
+ Favorites
+
+
+ {favCount > 0 && (
+ {favCount}
+ )}
+
+
+
+
+
+ classNames(styles.headerActionsLink, {
+ [styles.headerActionsLinkActive]: isActive,
+ })
+ }
+ >
+ Cart
+
+
+ {cartCount > 0 && (
+ {cartCount}
+ )}
+
+
+
+
+
+
+
+
+
+
+ );
+};
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 (
+
+
+
+
+
+ {items.map(item => (
+
+
+
+
+
+ {item.path ? (
+
+ {item.label}
+
+ ) : (
+ {item.label}
+ )}
+
+ ))}
+
+ );
+};
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 (
+
+
+
removeFromCart(product.id)}
+ aria-label="Remove from cart"
+ >
+
+
+
+
+
+
{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
+
setRetryCount(c => c + 1)}
+ >
+ Reload
+
+
+ ) : 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 (
+
+
+ {label}
+
+
+ onChange(e.target.value)}
+ >
+ {options.map(opt => (
+
+ {opt.label}
+
+ ))}
+
+
+
+
+
+
+ );
+};
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 (
+
+
+
+ onPageChange(currentPage - 1)}
+ disabled={currentPage === 1}
+ aria-label="Go to previous page"
+ >
+
+
+
+
+
+
+ {getVisiblePages(currentPage, totalPages, delta).map(item =>
+ item === 'ellipsis-left' || item === 'ellipsis-right' ? (
+
+ ...
+
+ ) : (
+
+ handlePageClick(item)}
+ aria-current={item === currentPage ? 'page' : undefined}
+ >
+ {item}
+
+
+ ),
+ )}
+
+
+ onPageChange(currentPage + 1)}
+ disabled={currentPage === totalPages}
+ aria-label="Go to next page"
+ >
+
+
+
+
+
+
+
+ );
+};
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.price}
+ {hasDiscount && (
+ ${product.fullPrice}
+ )}
+
+
+
+
+
+
+ Screen
+ {product.screen}
+
+
+ Capacity
+ {product.capacity}
+
+
+ RAM
+ {product.ram}
+
+
+
+
+
toggleCart(product)}
+ >
+ {isProductInCart ? 'Added to cart' : 'Add to cart'}
+
+
toggleFavorite(product)}
+ >
+
+
+
+
+ );
+};
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'),
+ },
+ },
+});