diff --git a/declarations.d.ts b/declarations.d.ts new file mode 100644 index 0000000000..a38a891d7d --- /dev/null +++ b/declarations.d.ts @@ -0,0 +1,2 @@ +declare module '*.module.css'; +declare module '*.module.scss'; diff --git a/package-lock.json b/package-lock.json index 98392dc382..43d59c209a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2421,9 +2421,9 @@ } }, "@mate-academy/scripts": { - "version": "1.7.9", - "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.7.9.tgz", - "integrity": "sha512-TDtSLf9BVwkaib4xpMB8r8VA18N6ABRpePGxpqk+aYOHcXq1DFwrzqCbOW9LyrOxWbqLVJBhP5exEgFXiaWhfw==", + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.9.4.tgz", + "integrity": "sha512-cFthaQW4MhphBbhLch1FmFA6xcdiLhRFNVgEVj5cUvjnw8VQ/I4Q/kKFT39y8yD9nNggAfNDrCTC1VB/wxpUPQ==", "dev": true, "requires": { "@octokit/rest": "^17.11.2", @@ -2777,10 +2777,28 @@ "source-map": "^0.7.3" } }, + "@reduxjs/toolkit": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.2.7.tgz", + "integrity": "sha512-faI3cZbSdFb8yv9dhDTmGwclW0vk0z5o1cia+kf7gCbaCwHI5e+7tP57mJUv22pNcNbeA62GSrPpfrUfdXcQ6g==", + "requires": { + "immer": "^10.0.3", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "dependencies": { + "immer": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", + "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==" + } + } + }, "@remix-run/router": { - "version": "1.15.3", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.15.3.tgz", - "integrity": "sha512-Oy8rmScVrVxWZVOpEF57ovlnhpZ8CCPlnIIumVcV9nFdiSIrus99+Lw78ekXyGvVDlIsFJbSfmSovJUhCWYV3w==" + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.19.1.tgz", + "integrity": "sha512-S45oynt/WH19bHbIXjtli6QmwNYvaz+vtnubvNpNDvUOoA/OWh6j1OikIP3G+v5GHdxyC6EXoChG3HgYGEUfcg==" }, "@rollup/plugin-babel": { "version": "5.3.1", @@ -2868,9 +2886,9 @@ } }, "@sinonjs/text-encoding": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz", - "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.3.tgz", + "integrity": "sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==", "dev": true }, "@surma/rollup-plugin-off-main-thread": { @@ -3255,6 +3273,21 @@ "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==" }, + "@types/lodash": { + "version": "4.17.7", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz", + "integrity": "sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==", + "dev": true + }, + "@types/lodash.debounce": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/lodash.debounce/-/lodash.debounce-4.0.9.tgz", + "integrity": "sha512-Ma5JcgTREwpLRwMM+XwBR7DaWe96nC38uCBDFKZWbNKD+osjVzdpnUSwBcqCptrp16sSOLBAUb50Car5I0TCsQ==", + "dev": true, + "requires": { + "@types/lodash": "*" + } + }, "@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -3473,6 +3506,11 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" }, + "@types/use-sync-external-store": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz", + "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==" + }, "@types/ws": { "version": "8.5.10", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", @@ -6653,13 +6691,13 @@ } }, "eslint-plugin-prettier": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.1.3.tgz", - "integrity": "sha512-C9GCVAs4Eq7ZC/XFQHITLiHJxQngdtraXaM+LoUFoFp/lHNl2Zn8f3WQbe9HvTBBQ9YnKFB0/2Ajdqwo5D1EAw==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.1.tgz", + "integrity": "sha512-gH3iR3g4JfF+yYPaJYkN7jEl9QbweL/YfkoRlNnuIEHEz1vHVlCmWOS+eGGiRuzHQXdJFCOTxRgvju9b8VUmrw==", "dev": true, "requires": { "prettier-linter-helpers": "^1.0.0", - "synckit": "^0.8.6" + "synckit": "^0.9.1" } }, "eslint-plugin-react": { @@ -12998,26 +13036,35 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "react-redux": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.1.2.tgz", + "integrity": "sha512-0OA4dhM1W48l3uzmv6B7TXPCGmokUU4p1M44DGN2/D9a1FjVPukVjER1PcPX97jIg6aUeLq1XJo1IpfbgULn0w==", + "requires": { + "@types/use-sync-external-store": "^0.0.3", + "use-sync-external-store": "^1.0.0" + } + }, "react-refresh": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", "integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==" }, "react-router": { - "version": "6.22.3", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.22.3.tgz", - "integrity": "sha512-dr2eb3Mj5zK2YISHK++foM9w4eBnO23eKnZEDs7c880P6oKbrjz/Svg9+nxqtHQK+oMW4OtjZca0RqPglXxguQ==", + "version": "6.26.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.26.1.tgz", + "integrity": "sha512-kIwJveZNwp7teQRI5QmwWo39A5bXRyqpH0COKKmPnyD2vBvDwgFXSqDUYtt1h+FEyfnE8eXr7oe0MxRzVwCcvQ==", "requires": { - "@remix-run/router": "1.15.3" + "@remix-run/router": "1.19.1" } }, "react-router-dom": { - "version": "6.22.3", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.22.3.tgz", - "integrity": "sha512-7ZILI7HjcE+p31oQvwbokjk6OA/bnFxrhJ19n82Ex9Ph8fNAq+Hm/7KchpMGlTgWhUxRHMMCut+vEtNpWpowKw==", + "version": "6.26.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.26.1.tgz", + "integrity": "sha512-veut7m41S1fLql4pLhxeSW3jlqs+4MtjRLj0xvuCEXsxusJCbs6I8yn9BxzzDX2XDgafrccY6hwjmd/bL54tFw==", "requires": { - "@remix-run/router": "1.15.3", - "react-router": "6.22.3" + "@remix-run/router": "1.19.1", + "react-router": "6.26.1" } }, "react-scripts": { @@ -13226,6 +13273,11 @@ } } }, + "react-swipeable": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/react-swipeable/-/react-swipeable-7.0.1.tgz", + "integrity": "sha512-RKB17JdQzvECfnVj9yDZsiYn3vH0eyva/ZbrCZXZR0qp66PBRhtg4F9yJcJTWYT5Adadi+x4NoG53BxKHwIYLQ==" + }, "react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -13328,6 +13380,16 @@ } } }, + "redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" + }, + "redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==" + }, "reflect.getprototypeof": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.6.tgz", @@ -13572,6 +13634,11 @@ "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" }, + "reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==" + }, "resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -14911,9 +14978,9 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==" }, "synckit": { - "version": "0.8.8", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.8.tgz", - "integrity": "sha512-HwOKAP7Wc5aRGYdKH+dw0PRRpbO841v2DENBtjnR5HFWoiNByAl7vrx3p0G/rCyYXQsrxqtX48TImFtPcIHSpQ==", + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.1.tgz", + "integrity": "sha512-7gr8p9TQP6RAHusBOSLs46F4564ZrjV8xFmw5zCmgmhGUcw2hxsShhJ6CEiHQMgPDwAQ1fWHPM0ypc4RMAig4A==", "dev": true, "requires": { "@pkgr/core": "^0.1.0", @@ -14921,9 +14988,9 @@ }, "dependencies": { "tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", "dev": true } } @@ -15498,6 +15565,11 @@ "requires-port": "^1.0.0" } }, + "use-sync-external-store": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz", + "integrity": "sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==" + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index 222c88a139..5975fa06b9 100644 --- a/package.json +++ b/package.json @@ -8,21 +8,27 @@ "dependencies": { "@cypress/react18": "^2.0.0", "@fortawesome/fontawesome-free": "^6.2.0", + "@reduxjs/toolkit": "^2.2.7", "@types/react-transition-group": "^4.4.5", "bulma": "^0.9.4", "classnames": "^2.5.1", + "lodash.debounce": "^4.0.8", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-router-dom": "^6.22.3", + "react-redux": "^9.1.2", + "react-router-dom": "^6.26.1", "react-scripts": "5.0.1", - "react-transition-group": "^4.4.5" + "react-swipeable": "^7.0.1", + "react-transition-group": "^4.4.5", + "redux": "^5.0.1" }, "devDependencies": { "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@mate-academy/eslint-config-react-typescript": "latest", - "@mate-academy/scripts": "^1.7.9", + "@mate-academy/scripts": "^1.9.4", "@mate-academy/students-ts-config": "latest", "@mate-academy/stylelint-config": "latest", + "@types/lodash.debounce": "^4.0.9", "@types/node": "^16.18.80", "@types/react": "^18.2.55", "@types/react-dom": "^18.2.19", diff --git a/public/img/category-accessories.png b/public/img/category-accessories.png index 67c5bfdb35..9ac6ee6318 100644 Binary files a/public/img/category-accessories.png and b/public/img/category-accessories.png differ diff --git a/public/img/category-phones.png b/public/img/category-phones.png index fd7616042f..0cb88ba4ee 100644 Binary files a/public/img/category-phones.png and b/public/img/category-phones.png differ diff --git a/public/img/category-tablets.png b/public/img/category-tablets.png index 57e33c5807..e5ccbf65a6 100644 Binary files a/public/img/category-tablets.png and b/public/img/category-tablets.png differ diff --git a/public/img/favicon/favicon.png b/public/img/favicon/favicon.png new file mode 100644 index 0000000000..5ef129f073 Binary files /dev/null and b/public/img/favicon/favicon.png differ diff --git a/public/img/icons/CartIcon--DarkTheme.svg b/public/img/icons/CartIcon--DarkTheme.svg new file mode 100644 index 0000000000..380aed0a0a --- /dev/null +++ b/public/img/icons/CartIcon--DarkTheme.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/img/icons/CartIcon.svg b/public/img/icons/CartIcon.svg new file mode 100644 index 0000000000..6deb3bf9b7 --- /dev/null +++ b/public/img/icons/CartIcon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/img/icons/ChevronIcon--DarkTheme.svg b/public/img/icons/ChevronIcon--DarkTheme.svg new file mode 100644 index 0000000000..9c6976541b --- /dev/null +++ b/public/img/icons/ChevronIcon--DarkTheme.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/ChevronIcon.svg b/public/img/icons/ChevronIcon.svg new file mode 100644 index 0000000000..dfd84c3e92 --- /dev/null +++ b/public/img/icons/ChevronIcon.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/CloseMenuIcon--DarkTheme.svg b/public/img/icons/CloseMenuIcon--DarkTheme.svg new file mode 100644 index 0000000000..925e5fce49 --- /dev/null +++ b/public/img/icons/CloseMenuIcon--DarkTheme.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/CloseMenuIcon.svg b/public/img/icons/CloseMenuIcon.svg new file mode 100644 index 0000000000..78d418ab46 --- /dev/null +++ b/public/img/icons/CloseMenuIcon.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/CrossIcon--DarkTheme.svg b/public/img/icons/CrossIcon--DarkTheme.svg new file mode 100644 index 0000000000..aec415e35a --- /dev/null +++ b/public/img/icons/CrossIcon--DarkTheme.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/CrossIcon.svg b/public/img/icons/CrossIcon.svg new file mode 100644 index 0000000000..61e7a40832 --- /dev/null +++ b/public/img/icons/CrossIcon.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/FavoritesIcon--DarkTheme.svg b/public/img/icons/FavoritesIcon--DarkTheme.svg new file mode 100644 index 0000000000..8fb5abef51 --- /dev/null +++ b/public/img/icons/FavoritesIcon--DarkTheme.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/FavoritesIcon.svg b/public/img/icons/FavoritesIcon.svg new file mode 100644 index 0000000000..ca57cfedd8 --- /dev/null +++ b/public/img/icons/FavoritesIcon.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/HomeIcon--DarkTheme.svg b/public/img/icons/HomeIcon--DarkTheme.svg new file mode 100644 index 0000000000..e16ca7d794 --- /dev/null +++ b/public/img/icons/HomeIcon--DarkTheme.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/img/icons/HomeIcon.svg b/public/img/icons/HomeIcon.svg new file mode 100644 index 0000000000..474476cb02 --- /dev/null +++ b/public/img/icons/HomeIcon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/img/icons/LogoIcon--DarkTheme.svg b/public/img/icons/LogoIcon--DarkTheme.svg new file mode 100644 index 0000000000..d59f941639 --- /dev/null +++ b/public/img/icons/LogoIcon--DarkTheme.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/img/icons/LogoIcon.svg b/public/img/icons/LogoIcon.svg new file mode 100644 index 0000000000..0a2d076bef --- /dev/null +++ b/public/img/icons/LogoIcon.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/img/icons/MenuIcon--DarkTheme.svg b/public/img/icons/MenuIcon--DarkTheme.svg new file mode 100644 index 0000000000..c8c52c08a9 --- /dev/null +++ b/public/img/icons/MenuIcon--DarkTheme.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/img/icons/MenuIcon.svg b/public/img/icons/MenuIcon.svg new file mode 100644 index 0000000000..2c535f4586 --- /dev/null +++ b/public/img/icons/MenuIcon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/img/icons/MinusIcon--DarkTheme.svg b/public/img/icons/MinusIcon--DarkTheme.svg new file mode 100644 index 0000000000..7ca53e577a --- /dev/null +++ b/public/img/icons/MinusIcon--DarkTheme.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/MinusIcon.svg b/public/img/icons/MinusIcon.svg new file mode 100644 index 0000000000..97c41038ac --- /dev/null +++ b/public/img/icons/MinusIcon.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/PlusIcon--DarkTheme.svg b/public/img/icons/PlusIcon--DarkTheme.svg new file mode 100644 index 0000000000..aa791a47ad --- /dev/null +++ b/public/img/icons/PlusIcon--DarkTheme.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/PlusIcon.svg b/public/img/icons/PlusIcon.svg new file mode 100644 index 0000000000..ab3c34061b --- /dev/null +++ b/public/img/icons/PlusIcon.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/SelectedToFaforitesIcon.svg b/public/img/icons/SelectedToFaforitesIcon.svg new file mode 100644 index 0000000000..7138d7522b --- /dev/null +++ b/public/img/icons/SelectedToFaforitesIcon.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/index.html b/public/index.html index 4b622dad39..b75336a005 100644 --- a/public/index.html +++ b/public/index.html @@ -4,6 +4,7 @@ Phone catalog +
diff --git a/src/App.module.scss b/src/App.module.scss new file mode 100644 index 0000000000..c988ac9df2 --- /dev/null +++ b/src/App.module.scss @@ -0,0 +1,46 @@ +@import './styles/main'; + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + overflow-y: scroll; + min-width: 320px; + background-color: var(--color-background); + color: var(--color-primary); +} + +.app { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.container { + flex: 1; + padding-top: 24px; + padding-bottom: 80px; + + @include on-tablet { + padding-top: 32px; + } + + @include on-desktop { + padding-top: 52px; + } +} + +.containerHidden { + opacity: 0; +} + +a img { + transition: transform $transition-duration ease; +} + +a:hover img { + transform: scale(1.1); +} diff --git a/src/App.scss b/src/App.scss deleted file mode 100644 index 71bc413aad..0000000000 --- a/src/App.scss +++ /dev/null @@ -1 +0,0 @@ -// not empty diff --git a/src/App.tsx b/src/App.tsx index 372e4b4206..1b846fac3a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,24 @@ -import './App.scss'; +import { Outlet } from 'react-router-dom'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import styles from './App.module.scss'; +import { Header } from './components/Header'; +import { Footer } from './components/Footer'; -export const App = () => ( -
-

Product Catalog

-
-); +export const App: React.FC = () => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + + return ( +
+
+
+ +
+
+ ); +}; diff --git a/src/Root.tsx b/src/Root.tsx new file mode 100644 index 0000000000..e0262748bd --- /dev/null +++ b/src/Root.tsx @@ -0,0 +1,36 @@ +import { HashRouter as Router, Route, Routes } from 'react-router-dom'; +import { App } from './App'; +import { ThemeProvider } from './context/ThemeContext'; +import { AppProvider } from './context/AppContext'; +import { HomePage } from './modules/HomePage'; +import { PhonesPage } from './modules/PhonesPage'; +import { TabletsPage } from './modules/TabletsPage'; +import { AccessoriesPage } from './modules/AccessoriesPage'; +import { CartPage } from './modules/CartPage'; +import { FavoritesPage } from './modules/FavoritesPage'; +import { ProductDetailsPage } from './modules/ProductDetailsPage'; +import { NotFoundPage } from './modules/NotFoundPage'; + +export const Root = () => ( + + + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } + /> + } /> + + + + + +); diff --git a/src/components/ActionButtons/ActionButtons.module.scss b/src/components/ActionButtons/ActionButtons.module.scss new file mode 100644 index 0000000000..81a101e33a --- /dev/null +++ b/src/components/ActionButtons/ActionButtons.module.scss @@ -0,0 +1,59 @@ +@import '../../styles/main'; + +.buttons { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + gap: 8px; +} + +.buttonCard { + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + height: 40px; + width: 100%; + border: 0; + background-color: var(--color-button); + transition: transform $transition-duration ease; + + &:hover { + transform: scale(1.05); + box-shadow: 0 3px 13px 0 #17203166; + background-color: var(--color-button-hover); + } +} + +.buttonText { + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + + @extend %body-text; + + color: var(--color-white); +} + +.buttonFavorite { + display: flex; + justify-content: center; + align-items: center; + width: 40px; + height: 40px; + flex-shrink: 0; + flex-grow: 0; + + background-color: var(--color-icons); + border: 1px solid var(--color-surface); + box-sizing: border-box; + + cursor: pointer; + + &:hover { + border: 1px solid var(--color-primary); + background-color: var(--color-hover); + } +} diff --git a/src/components/ActionButtons/ActionButtons.tsx b/src/components/ActionButtons/ActionButtons.tsx new file mode 100644 index 0000000000..e9cdf93efc --- /dev/null +++ b/src/components/ActionButtons/ActionButtons.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { useAppContext } from '../../context/AppContext'; +import { useTheme } from '../../context/ThemeContext'; +import { Product } from '../../types/Product'; +import { getFavoritesIconSrc } from '../../servises/iconSrc'; +import styles from './ActionButtons.module.scss'; +import { BASE_URL } from '../../utils/const'; + +type Props = { + product: Product; +}; + +export const ActionButtons: React.FC = ({ product }) => { + const { + addToFavorites, + removeFromFavorites, + favorites, + cart, + addToCart, + removeFromCart, + } = useAppContext(); + const { theme } = useTheme(); + + const isFavorite = favorites.some(favProduct => favProduct.id === product.id); + + const handleFavoriteClick = () => { + if (isFavorite) { + removeFromFavorites(product.id); + } else { + addToFavorites(product); + } + }; + + const isProductInCart = cart.some( + cartItem => cartItem.product.id === product.id, + ); + + const handleCartClick = () => { + if (isProductInCart) { + removeFromCart(product.id); + } else { + addToCart(product); + } + }; + + const favoritesIconSrc = () => { + if (isFavorite) { + return `${BASE_URL}/img/icons/SelectedToFaforitesIcon.svg`; + } + + return getFavoritesIconSrc(theme); + }; + + return ( +
+ + +
+ ); +}; diff --git a/src/components/ActionButtons/index.ts b/src/components/ActionButtons/index.ts new file mode 100644 index 0000000000..64917b7b7d --- /dev/null +++ b/src/components/ActionButtons/index.ts @@ -0,0 +1 @@ +export * from './ActionButtons'; diff --git a/src/components/Breadcrumbs/Breadcrumbs.module.scss b/src/components/Breadcrumbs/Breadcrumbs.module.scss new file mode 100644 index 0000000000..c984036041 --- /dev/null +++ b/src/components/Breadcrumbs/Breadcrumbs.module.scss @@ -0,0 +1,35 @@ +@import '../../styles/main'; + +.breadcrumbs { + grid-column: 1 / -1; + display: flex; + overflow: hidden; + gap: 8px; + height: 16px; +} + +.chevronSpan { + display: block; +} + +.chevronIcon { + transform: rotate(180deg); + opacity: 0.5; +} + +.label { + display: flex; + text-align: center; + padding-top: 2px; + height: 15px; + text-decoration: none; + + @extend %small-text; + + color: var(--color-secondary); +} + + +.active { + color: var(--color-primary); +} diff --git a/src/components/Breadcrumbs/Breadcrumbs.tsx b/src/components/Breadcrumbs/Breadcrumbs.tsx new file mode 100644 index 0000000000..2db76b6b60 --- /dev/null +++ b/src/components/Breadcrumbs/Breadcrumbs.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { Link, useLocation } from 'react-router-dom'; +import styles from './Breadcrumbs.module.scss'; +import { getChevronIconSrc, getHomeIconSrc } from '../../servises/iconSrc'; +import { useTheme } from '../../context/ThemeContext'; +import classNames from 'classnames'; + +type BreadcrumbsProps = { + product?: { + category: string; + name: string; + }; +}; + +export const Breadcrumbs: React.FC = ({ product }) => { + const { theme } = useTheme(); + const { pathname } = useLocation(); + const homeIconSrs = getHomeIconSrc(theme); + const chevronIconSrc = getChevronIconSrc(theme); + + function capitalize(str: string) { + return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); + } + + const getBreadcrumbs = () => { + if (product) { + return [ + { path: `/${product.category}`, label: capitalize(product.category) }, + { label: product.name }, + ]; + } else { + const paths = pathname.split('/').filter(Boolean); + + return paths.map((path, index, array) => ({ + path: `/${array.slice(0, index + 1).join('/')}`, + label: capitalize(path), + })); + } + }; + + const breadcrumbs = getBreadcrumbs(); + + return ( + + ); +}; diff --git a/src/components/Breadcrumbs/index.tsx b/src/components/Breadcrumbs/index.tsx new file mode 100644 index 0000000000..ce977548b1 --- /dev/null +++ b/src/components/Breadcrumbs/index.tsx @@ -0,0 +1 @@ +export * from './Breadcrumbs'; diff --git a/src/components/Dropdown/Dropdown.module.scss b/src/components/Dropdown/Dropdown.module.scss new file mode 100644 index 0000000000..2209f170e1 --- /dev/null +++ b/src/components/Dropdown/Dropdown.module.scss @@ -0,0 +1,42 @@ +@import '../../styles/main'; + +.label { + display: flex; + gap: 8px; + width: 100%; + flex-direction: column; + position: relative; + color: var(--color-secondary); + + @extend %small-text; + + &::before { + content: ''; + position: absolute; + display: block; + width: 6px; + height: 6px; + border-right: 1px solid var(--color-secondary); + border-top: 1px solid var(--color-secondary); + transform: rotate(135deg); + bottom: 18px; + right: 12px; + + } +} + +.select { + display: flex; + align-items: center; + justify-content: center; + height: 40px; + width: 100%; + padding: 0 12px; + appearance: none; + outline: none; + border: 1px solid var(--color-elements); + background-color: var(--color-filter-bg); + color: var(--color-primary); + + @extend %body-text; +} diff --git a/src/components/Dropdown/Dropdown.tsx b/src/components/Dropdown/Dropdown.tsx new file mode 100644 index 0000000000..c91153e73e --- /dev/null +++ b/src/components/Dropdown/Dropdown.tsx @@ -0,0 +1,38 @@ +import styles from './Dropdown.module.scss'; + +type Option = { + value: string; + label: string; +}; + +type DropdownProps = { + label: string; + value: string | number; + options: Option[]; + onChange: (event: React.ChangeEvent) => void; +}; + +export const Dropdown: React.FC = ({ + label, + value, + options, + onChange, +}) => { + return ( + + ); +}; diff --git a/src/components/Footer/Footer.module.scss b/src/components/Footer/Footer.module.scss new file mode 100644 index 0000000000..266d38f8d4 --- /dev/null +++ b/src/components/Footer/Footer.module.scss @@ -0,0 +1,127 @@ +@import '../../styles/main'; + +.footer { + display: flex; + width: 100%; + border-top: 1px solid var(--color-elements); + box-shadow: 0 -1px 0 0 var(--color-elements); + + background-color: var(--color-background); + + @include on-tablet { + height: 96px; + } +} + +.wrapper { + display: flex; + width: 100%; + flex-direction: column; + padding: 32px 16px; + gap: 32px; + + @include on-tablet { + @include section-grid; + + align-items: center; + justify-content: center; + } + + +} + +.logoLink { + display: flex; + width: 100%; + align-items: center; + + @include on-tablet { + grid-column: span 4; + } + + @include on-desktop { + grid-column: span 8; + } +} + +.logo { + width: 89px; + height: 32px; +} + +.nav { + display: flex; + flex-direction: column; + gap: 16px; + width: 100%; + + + @include on-tablet { + grid-column: span 4; + width: 100%; + flex-direction: row; + justify-content: space-between; + } + + @include on-desktop { + grid-column: span 8; + } +} + +.item { + @extend %uppercase-text; + + color: var(--color-secondary); + + cursor: pointer; + + &:hover { + color: var(--color-primary); + } +} + +.backToTop { + display: flex; + align-items: center; + justify-content: center; + + @include on-tablet { + justify-content: flex-end; + grid-column: span 4; + } + + @include on-desktop { + grid-column: span 8; + } + +} + +.backToTopText { + @extend %small-text; + + color: var(--color-secondary); + text-decoration: none; + margin-right: 16px; +} + +.backToTopButton { + display: flex; + width: 32px; + height: 32px; + background-color: var(--color-icons); + border: 1px solid var(--color-surface); + border-radius: 5%; + cursor: pointer; + justify-content: center; + align-items: center; + + &:hover { + border: 1px solid var(--color-primary); + background-color: var(--color-hover); + } +} + +.backToTopIcon { + transform : rotate(90deg) +} + diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 0000000000..284818f0d4 --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,92 @@ +import React, { useEffect, useState } from 'react'; +import { Link, NavLink } from 'react-router-dom'; +import styles from './Footer.module.scss'; +import { useTheme } from '../../context/ThemeContext'; +import { getChevronIconSrc, getLogoIconSrc } from '../../servises/iconSrc'; + +const Footer: React.FC = () => { + const { theme } = useTheme(); + + const [showBackToTop, setShowBackToTop] = useState(false); + + useEffect(() => { + const observer = new MutationObserver(() => { + const contentHeight = document.documentElement.scrollHeight; + const viewportHeight = window.innerHeight; + + setShowBackToTop(contentHeight > viewportHeight); + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + characterData: true, + }); + + return () => observer.disconnect(); + }, []); + + const scrollToTop = () => { + window.scrollTo({ + top: 0, + behavior: 'smooth', + }); + }; + + const logoImgSrc = getLogoIconSrc(theme); + const backToTopImgSrc = getChevronIconSrc(theme); + + return ( +
+
+ + logo + + + {showBackToTop && ( +
+

Back to top

+ +
+ )} +
+
+ ); +}; + +export default Footer; diff --git a/src/components/Footer/index.ts b/src/components/Footer/index.ts new file mode 100644 index 0000000000..da94c29367 --- /dev/null +++ b/src/components/Footer/index.ts @@ -0,0 +1 @@ +export { default as Footer } from './Footer'; diff --git a/src/components/Header/Header.module.scss b/src/components/Header/Header.module.scss new file mode 100644 index 0000000000..3be38b9cbf --- /dev/null +++ b/src/components/Header/Header.module.scss @@ -0,0 +1,194 @@ +@import '../../styles/main'; + +.header { + display: flex; + align-items: center; + justify-content: space-between; + background-color: var(--color-background); + width: 100%; + height: $height-on-tablet; + gap: 16px; + + + position: sticky; + top: 0; + z-index: 1; + + box-shadow: 0 1px 0 0 var(--color-elements); + + @include on-desktop { + gap: 24px; + height: $height-on-desktop; + } +} + +.logoLink { + display: flex; + align-items: center; + justify-content: space-between; + height: 100%; + padding-inline: 16px; + + @include on-desktop { + padding-inline: 24px; + } +} + +.logo { + width: 64px; + height: 22px; + + @include on-desktop { + width: 80px; + height: 28px; + } +} + +.container { + display: none; + + @include on-tablet { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + } +} + +.nav { + display: flex; + align-items: center; +} + +.navItem { + display: flex; + position: relative; + align-items: center; + margin-right: 32px; + height: $height-on-tablet; + + color: var(--color-secondary); + + @extend %uppercase-text; + + cursor: pointer; + + &:hover { + color: var(--color-primary); + } + + &:last-child { + margin-right: 0; + } + + @include on-desktop { + margin-right: 64px; + height: $height-on-desktop; + } +} + +.actionsContainer { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + + @include middle-screen { + gap: 16px; + } +} + +.actions { + display: flex; + align-items: center; +} + +.actionItem { + display: flex; + position: relative; + align-items: center; + justify-content: center; + cursor: pointer; + + width: $with-icons-tablet; + height: $height-on-tablet; + + flex-shrink: 0; + flex-grow: 0; + + box-shadow: -1px 0 0 0 var(--color-elements); + + @include on-desktop { + width: $with-icons-desktop; + height: $height-on-desktop; + } +} + +.actionIcon { + display: flex; + align-items: center; + justify-content: center; + position: relative; + width: 28px; + height: 28px; + + +} + +.count { + display: flex; + align-items: center; + justify-content: center; + position: absolute; + top: 0; + left: 14px; + width: 14px; + height: 14px; + border-radius: 50%; + border: 1px solid var(--color-background); + background-color: var(--color-red); +} + +.countText { + color: #F1F2F9; + font-size: 9px; + font-weight: 700; + line-height: 11.5px; + text-align: center; +} + +.mobile { + display: flex; + align-items: center; + justify-content: center; + + @include on-tablet { + display: none; + } +} + +.mobileButton { + display: flex; + align-items: center; + justify-content: center; + height: $height-on-tablet; + background-color: transparent; + border: none; + width: $with-icons-tablet; + box-shadow: -1px 0 0 0 var(--color-elements); + cursor: pointer; +} + +.isActive { + color: var(--color-primary); + + &::after { + position: absolute; + content: ""; + bottom: 0; + left: 0; + right: 0; + height: 3px; + background-color: var(--color-primary); + } +} diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 0000000000..5af6395e3f --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,140 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { NavLink, Link } from 'react-router-dom'; +import classNames from 'classnames'; +import { useTheme } from '../../context/ThemeContext'; +import { HeaderProps } from '../../types/Header'; +import styles from './Header.module.scss'; +import { OverlayMenu } from './components/OverlayMenu'; +import { ToggleTheme } from './components/ToggleTheme'; +import { + getMenuIconSrc, + getLogoIconSrc, + getCartIconSrc, + getFavoritesIconSrc, +} from '../../servises/iconSrc'; +import { useAppContext } from '../../context/AppContext'; +import { Search } from '../Search/Search'; + +const Header: React.FC = ({ isMenuOpen, setIsMenuOpen }) => { + const { theme } = useTheme(); + const { cart, favorites } = useAppContext(); + + const toggleIsMenuOpen = () => { + setIsMenuOpen((prev: boolean) => !prev); + }; + + const menuIconSrc = getMenuIconSrc(isMenuOpen, theme); + const logoIconSrs = getLogoIconSrc(theme); + const cartIconSrc = getCartIconSrc(theme); + const favoritesIconSrc = getFavoritesIconSrc(theme); + + return ( +
+ setIsMenuOpen(false)} + > + logo + + +
+ + +
+ + +
+ + classNames(styles.actionItem, { [styles.isActive]: isActive }) + } + > +
+ Favorites + {favorites.length > 0 && ( + +

{favorites.length}

+
+ )} +
+
+ + classNames(styles.actionItem, { [styles.isActive]: isActive }) + } + > +
+ Cart + {cart.length > 0 && ( + +

+ {cart.reduce((sum, item) => sum + item.quantity, 0)} +

+
+ )} +
+
+
+
+
+ +
+ + +
+ +
+ ); +}; + +export default Header; diff --git a/src/components/Header/components/OverlayMenu/OverlayMenu.module.scss b/src/components/Header/components/OverlayMenu/OverlayMenu.module.scss new file mode 100644 index 0000000000..a13e1354bc --- /dev/null +++ b/src/components/Header/components/OverlayMenu/OverlayMenu.module.scss @@ -0,0 +1,104 @@ +@import '../../../../styles/main'; + +.menuOverlay { + display: none; + position: fixed; + top: 72px; + left: 0; + right: 0; + height: 100vh; + z-index: 1; + + background-color: var(--color-background); +} + +.show { + display: flex; + justify-content: center; +} + +.nav { + display: flex; + align-items: center; + flex-direction: column; + gap: 16px; +} + +.item { + @extend %uppercase-text; + + color: var(--color-secondary); + padding: 8px 0; +} + +.actions { + display: flex; + width: 100%; + position: fixed; + bottom: 0; +} + +.action { + display: flex; + flex: 1; + height: 64px; + align-items: center; + justify-content: center; + color: var(--color-secondary); + box-shadow: 0 -1px 0 0 var(--color-elements), 1px 0 0 0 var(--color-elements); +} + +.actionIcon { + display: flex; + align-items: center; + justify-content: center; + position: relative; + width: 28px; + height: 28px; +} + +.count { + display: flex; + align-items: center; + justify-content: center; + position: absolute; + top: 0; + left: 14px; + width: 14px; + height: 14px; + border-radius: 50%; + border: 1px solid var(--color-background); + background-color: var(--color-red); + +} + +.countText { + color: #F1F2F9; + font-size: 9px; + font-weight: 700; + line-height: 11.5px; + text-align: center; +} + +.item, +.action { + position: relative; + + &:hover { + color: var(--color-primary); + } +} + +.isActive { + color: var(--color-primary); + + &::after { + position: absolute; + content: ""; + bottom: 0; + left: 0; + right: 0; + height: 2px; + background-color: var(--color-primary); + } +} diff --git a/src/components/Header/components/OverlayMenu/OverlayMenu.tsx b/src/components/Header/components/OverlayMenu/OverlayMenu.tsx new file mode 100644 index 0000000000..5729a22df5 --- /dev/null +++ b/src/components/Header/components/OverlayMenu/OverlayMenu.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { NavLink } from 'react-router-dom'; +import classNames from 'classnames'; +import styles from './OverlayMenu.module.scss'; +import { OverlayMenuProps } from '../../../../types/OverlayMenu'; + +export const OverlayMenu: React.FC = ({ + isMenuOpen, + toggleIsMenuOpen, + cartIconSrc, + favoritesIconSrc, + favorites, + cart, +}) => { + return ( +
+ +
+ + classNames(styles.action, { [styles.isActive]: isActive }) + } + > +
+ Favorites + {favorites.length > 0 && ( + +

{favorites.length}

+
+ )} +
+
+ + classNames(styles.action, { [styles.isActive]: isActive }) + } + > +
+ Cart + {cart.length > 0 && ( + +

+ {cart.reduce((sum, item) => sum + item.quantity, 0)} +

+
+ )} +
+
+
+
+ ); +}; diff --git a/src/components/Header/components/OverlayMenu/index.ts b/src/components/Header/components/OverlayMenu/index.ts new file mode 100644 index 0000000000..7e36f742e8 --- /dev/null +++ b/src/components/Header/components/OverlayMenu/index.ts @@ -0,0 +1 @@ +export * from './OverlayMenu'; diff --git a/src/components/Header/components/ToggleTheme/ToggleTheme.module.scss b/src/components/Header/components/ToggleTheme/ToggleTheme.module.scss new file mode 100644 index 0000000000..728922823e --- /dev/null +++ b/src/components/Header/components/ToggleTheme/ToggleTheme.module.scss @@ -0,0 +1,33 @@ +@import '../../../../styles/main'; + +.themeButton { + display: flex; + align-items: center; + justify-content: center; + box-shadow: -1px 0 0 0 var(--color-elements), 1px 0 0 0 var(--color-elements); + + @extend %uppercase-text; + + color: var(--color-secondary); + + cursor: pointer; + background-color: transparent; + border: none; + + padding: 0; + + flex-shrink: 0; + flex-grow: 0; + + width: $with-icons-tablet; + height: $height-on-tablet; + + &:hover { + color: var(--color-primary); + } + + @include on-desktop { + width: $with-icons-desktop; + height: $height-on-desktop; + } +} diff --git a/src/components/Header/components/ToggleTheme/ToggleTheme.tsx b/src/components/Header/components/ToggleTheme/ToggleTheme.tsx new file mode 100644 index 0000000000..c18a366977 --- /dev/null +++ b/src/components/Header/components/ToggleTheme/ToggleTheme.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { useTheme } from '../../../../context/ThemeContext'; +import styles from './ToggleTheme.module.scss'; + +export const ToggleTheme: React.FC = () => { + const { theme, toggleTheme } = useTheme(); + + return ( + + ); +}; diff --git a/src/components/Header/components/ToggleTheme/index.tsx b/src/components/Header/components/ToggleTheme/index.tsx new file mode 100644 index 0000000000..eae4e1cfdc --- /dev/null +++ b/src/components/Header/components/ToggleTheme/index.tsx @@ -0,0 +1 @@ +export * from './ToggleTheme'; diff --git a/src/components/Header/index.tsx b/src/components/Header/index.tsx new file mode 100644 index 0000000000..5653319dec --- /dev/null +++ b/src/components/Header/index.tsx @@ -0,0 +1 @@ +export { default as Header } from './Header'; diff --git a/src/components/Loader/Loader.module.scss b/src/components/Loader/Loader.module.scss new file mode 100644 index 0000000000..9ae4116303 --- /dev/null +++ b/src/components/Loader/Loader.module.scss @@ -0,0 +1,24 @@ +.loader { + display: flex; + justify-content: center; + align-items: center; +} + +.loaderContent { + border-radius: 50%; + width: 5em; + height: 5em; + margin: 1em auto; + border: 0.3em solid #ddd; + border-left-color: #000; + animation: load8 1.2s infinite linear; +} + +@keyframes load8 { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} diff --git a/src/components/Loader/Loader.tsx b/src/components/Loader/Loader.tsx new file mode 100644 index 0000000000..3af23d162d --- /dev/null +++ b/src/components/Loader/Loader.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import styles from './Loader.module.scss'; + +export const Loader: React.FC = () => ( +
+
+
+); diff --git a/src/components/Loader/index.tsx b/src/components/Loader/index.tsx new file mode 100644 index 0000000000..d5ce981151 --- /dev/null +++ b/src/components/Loader/index.tsx @@ -0,0 +1 @@ +export * from './Loader'; diff --git a/src/components/Pagination/Pagination.module.scss b/src/components/Pagination/Pagination.module.scss new file mode 100644 index 0000000000..07b74717b6 --- /dev/null +++ b/src/components/Pagination/Pagination.module.scss @@ -0,0 +1,51 @@ +@import '../../styles/main'; + +.pagination { + grid-column: 1 / -1; + display: flex; + gap: 8px; + align-items: center; + justify-content: center; +} + +.button { + display: flex; + justify-content: center; + align-items: center; + width: 32px; + height: 32px; + flex-shrink: 0; + background-color: var(--color-icons); + border: 1px solid var(--color-surface); + color: var(--color-primary); + cursor: pointer; + + &:hover { + border: 1px solid var(--color-primary); + background-color: var(--color-hover); + } +} + +.active { + cursor: pointer; + border: 1px solid var(--color-primary); + background-color: var(--color-primary); + color: var(--color-background); + + &:hover { + background-color: var(--color-primary); + } +} + +.disabled { + pointer-events: none; + opacity: 0.5; + + &:hover { + background-color: transparent; + } +} + +.next { + transform: rotate(180deg); +} diff --git a/src/components/Pagination/Pagination.tsx b/src/components/Pagination/Pagination.tsx new file mode 100644 index 0000000000..a8376f2602 --- /dev/null +++ b/src/components/Pagination/Pagination.tsx @@ -0,0 +1,99 @@ +import classNames from 'classnames'; +import styles from './Pagination.module.scss'; +import { getChevronIconSrc } from '../../servises/iconSrc'; +import { useTheme } from '../../context/ThemeContext'; + +interface Props { + total: number; + perPage: number; + currentPage: number; + onPageChange: (page: number) => void; +} + +export const Pagination: React.FC = ({ + total, + perPage, + currentPage, + onPageChange, +}) => { + const { theme } = useTheme(); + const chevronIconSrc = getChevronIconSrc(theme); + const totalPages = Math.ceil(total / perPage); + const isFirstPage = currentPage === 1; + const isLastPage = currentPage === totalPages; + const isCurrentPage = (page: number) => currentPage === page; + + const visiblePages = () => { + const pages = []; + const startPage = Math.max(1, Math.min(currentPage - 1, totalPages - 3)); + const endPage = Math.min(totalPages, startPage + 3); + + for (let page = startPage; page <= endPage; page++) { + pages.push(page); + } + + return pages; + }; + + const goPreviousPage = () => { + if (!isFirstPage) { + onPageChange(currentPage - 1); + } + }; + + const selectPage = (page: number) => { + if (page !== currentPage) { + onPageChange(page); + } + }; + + const goNextPage = () => { + if (!isLastPage) { + onPageChange(currentPage + 1); + } + }; + + return ( +
    + + {visiblePages().map(page => ( + + ))} + +
+ ); +}; diff --git a/src/components/Pagination/index.ts b/src/components/Pagination/index.ts new file mode 100644 index 0000000000..e016c96b72 --- /dev/null +++ b/src/components/Pagination/index.ts @@ -0,0 +1 @@ +export * from './Pagination'; diff --git a/src/components/ProductCard/ProductCard.module.scss b/src/components/ProductCard/ProductCard.module.scss new file mode 100644 index 0000000000..a9ead3e6e0 --- /dev/null +++ b/src/components/ProductCard/ProductCard.module.scss @@ -0,0 +1,119 @@ +@import '../../styles/main'; + +.ProductCard { + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 8px; + align-items: center; + height: 440px; + min-width: 212px; + width: 100%; + padding: 32px; + border: 1px solid var(--color-elements); + border-radius: 2px; + + @include on-tablet { + height: 506px; + } + + @include on-desktop { + width: 272px; + } +} + +.ProductCard:hover { + border: 1px solid var(--color-primary); +} + +.imageContainer { + display: flex; + align-items: flex-start; + cursor: pointer; + + width: 148px; + min-height: 109px; + + @include on-tablet { + min-width: 173px; + min-height: 181px; + } + + @include on-desktop { + width: 208px; + height: 196px; + } +} + +.image { + width: 100%; + height: 100%; + object-fit: contain; + cursor: pointer; +} + +.wrapper { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; +} + +.titleContainer { + display: flex; + width: 100%; +} + +.title { + text-decoration: none; + + @extend %body-text; + + color: var(--color-primary); + padding-top: 16px; +} + + +.price { + display: flex; + gap: 8px; + + @extend %h3-tablet; +} + +.existPrice { + color: var(--color-primary); +} + +.hotPrice { + display: flex; + color: var(--color-secondary); + text-decoration: line-through; +} + +.divider { + border-top: 1px solid var(--color-elements); +} + +.description { + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px 0; +} + +.existDescription { + display: flex; + justify-content: space-between; + align-items: center; +} + +.descriptionTitle { + @extend %small-text; +} + +.descriptionText { + @extend %small-text; + + color: var(--color-primary); +} diff --git a/src/components/ProductCard/ProductCard.tsx b/src/components/ProductCard/ProductCard.tsx new file mode 100644 index 0000000000..81dc050992 --- /dev/null +++ b/src/components/ProductCard/ProductCard.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import styles from './ProductCard.module.scss'; +import { Product } from '../../types/Product'; +import { Link } from 'react-router-dom'; +import { ActionButtons } from '../ActionButtons'; +import { BASE_URL } from '../../utils/const'; + +type ProductCardProps = { + product: Product; +}; + +export const ProductCard: React.FC = ({ product }) => { + const { image, name, fullPrice, price, screen, capacity, ram } = product; + + return ( +
+ + image + + +
+ + {name} + + +
+
${fullPrice}
+
${price}
+
+ +
+
+
+

Screen

+

{screen}

+
+ +
+

Capacity

+

{capacity}

+
+ +
+

RAM

+

{ram}

+
+
+ +
+
+ ); +}; diff --git a/src/components/ProductCard/index.tsx b/src/components/ProductCard/index.tsx new file mode 100644 index 0000000000..7ce031c382 --- /dev/null +++ b/src/components/ProductCard/index.tsx @@ -0,0 +1 @@ +export * from './ProductCard'; diff --git a/src/components/ProductsList/ProductsList.module.scss b/src/components/ProductsList/ProductsList.module.scss new file mode 100644 index 0000000000..8d6655a160 --- /dev/null +++ b/src/components/ProductsList/ProductsList.module.scss @@ -0,0 +1,93 @@ +@import '../../styles/main'; + +.ProductsPage { + @include section-grid; +} + +.topContainer { + grid-column: 1 / -1; +} + +.title { + margin-top: 40px; + + @extend %h1-mobile; + + @include on-tablet { + @include h1-tablet; + } +} + +.count { + @extend %body-text; + + color: var(--color-secondary); +} + +.noResults { + grid-column: 1 / -1; + + @extend %h3-tablet; + + color: var(--color-secondary); +} + +.filters { + margin-top: 16px; + grid-column: 1 / -1; + + @include section-grid; + + padding: 0; + + @include on-tablet { + padding: 0; + } +} + +.sortBy { + grid-column: span 2; + + @include on-tablet { + grid-column: span 4; + } +} + +.perPage { + grid-column: span 2; + + @include on-tablet { + grid-column: span 3; + } +} + +.container { + grid-column: 1 / -1; + margin-top: 20px; + margin-bottom: 40px; + + @include section-grid; + + padding: 0; + + @include on-tablet { + padding: 0; + } +} + +.product { + grid-column: span 4; + + @include on-tablet { + grid-column: span 6; + } + + @include middle-screen { + grid-column: span 4; + } + + @include on-desktop { + grid-column: span 6; + } +} + diff --git a/src/components/ProductsList/ProductsList.tsx b/src/components/ProductsList/ProductsList.tsx new file mode 100644 index 0000000000..ec267cb6a7 --- /dev/null +++ b/src/components/ProductsList/ProductsList.tsx @@ -0,0 +1,168 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { getProductsByCategory } from '../../servises/Products'; +import { Product } from '../../types/Product'; +import { Breadcrumbs } from '../Breadcrumbs'; +import styles from './ProductsList.module.scss'; +import { ProductCard } from '../ProductCard'; +import { Loader } from '../Loader'; +import { Pagination } from '../Pagination'; +import { useSearchParams } from 'react-router-dom'; +import { FilterType, ItemsPerPage } from '../../types/Filter'; +import { sortProducts } from '../../utils/sortProducts'; +import { Dropdown } from '../Dropdown/Dropdown'; + +type ProductsListProps = { + category: string; + title: string; +}; + +export const ProductsList: React.FC = ({ + category, + title, +}) => { + const [searchParams, setSearchParams] = useSearchParams(); + const [products, setProducts] = useState([]); + const [loading, setLoading] = useState(true); + + const page = parseInt(searchParams.get('page') || '1'); + const perPage = searchParams.get('perPage') || ItemsPerPage.Four; + const sortType = searchParams.get('sort') || 'age'; + const searchQuery = searchParams.get('query') || ''; + + const actualPerPage = perPage === 'All' ? products.length : parseInt(perPage); + const total = products.length; + + const scrollToTop = () => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + useEffect(() => { + const fetchProducts = async () => { + setLoading(true); + try { + const fetchedProducts = await getProductsByCategory(category); + + setProducts(fetchedProducts); + } finally { + setLoading(false); + } + }; + + fetchProducts(); + }, [category]); + + const handleSortChange = useCallback( + (event: React.ChangeEvent) => { + setSearchParams({ + sort: event.target.value, + query: searchQuery, + page: '1', + perPage, + }); + }, + [setSearchParams, perPage, searchQuery], + ); + + const handlePerPageChange = useCallback( + (event: React.ChangeEvent) => { + setSearchParams({ + perPage: event.target.value, + query: searchQuery, + page: '1', + sort: sortType, + }); + }, + [setSearchParams, sortType, searchQuery], + ); + + const handlePageChange = useCallback( + (newPage: number) => { + setSearchParams({ page: String(newPage), perPage, sort: sortType }); + setTimeout(scrollToTop, 0); + }, + [setSearchParams, perPage, sortType], + ); + + const startIndex = (page - 1) * actualPerPage; + const filteredProducts = products.filter( + product => + product.name.toLowerCase().includes(searchQuery.toLowerCase()) || + product.screen.toLowerCase().includes(searchQuery.toLowerCase()) || + product.capacity.toLowerCase().includes(searchQuery.toLowerCase()) || + product.ram.toLowerCase().includes(searchQuery.toLowerCase()) || + product.price.toString().includes(searchQuery), + ); + + const sortedProducts = sortProducts(filteredProducts, sortType); + const selectedProducts = sortedProducts.slice( + startIndex, + startIndex + actualPerPage, + ); + + if (loading) { + return ; + } + + return ( +
+
+ +

{title}

+

+ {`${filteredProducts.length} item${products.length > 1 ? 's' : ''}`} +

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

+ There are no {category} matching your query. +

+ ) : ( + <> +
+
+ ({ + value: key, + label: value, + }))} + /> +
+ +
+ ({ + value, + label: value, + }))} + /> +
+
+ +
+ {selectedProducts.map(product => ( +
+ +
+ ))} +
+ + {perPage !== 'All' && ( + + )} + + )} +
+ ); +}; diff --git a/src/components/ProductsList/index.ts b/src/components/ProductsList/index.ts new file mode 100644 index 0000000000..09f9887f27 --- /dev/null +++ b/src/components/ProductsList/index.ts @@ -0,0 +1 @@ +export * from './ProductsList'; diff --git a/src/components/ProductsSlider/ProductsSlider.module.scss b/src/components/ProductsSlider/ProductsSlider.module.scss new file mode 100644 index 0000000000..845175ccfc --- /dev/null +++ b/src/components/ProductsSlider/ProductsSlider.module.scss @@ -0,0 +1,84 @@ +@import '../../styles/main'; + +.productsSlider { + @include section-grid; + + padding-right: 0; + + @include on-tablet { + padding-right: 0; + } +} + +.topWrapper { + grid-column: 1 / -1; + display: flex; + justify-content: space-between; + margin-bottom: 24px; + padding-right: 16px; + + @include on-tablet { + padding-right: 16px; + } +} + +.title { + @extend %h2-tablet; +} + +.buttons { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; +} + +.button { + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + width: 32px; + height: 32px; + background-color: var(--color-icons); + border: 1px solid var(--color-surface); + border-radius: 5%; + + &:hover { + border: 1px solid var(--color-primary); + background-color: var(--color-hover); + } +} + +.isDisabled { + cursor: default; + opacity: 0.5; + + &:hover { + border: 1px solid var(--color-surface); + } +} + +.iconNext { + transform: rotate(180deg); +} + +.container { + grid-column: 1 / -1; + display: flex; + overflow: hidden; + gap: 16px; +} + +.mainContainer { + grid-column: 1 / -1; + display: flex; + overflow: hidden; + gap: 16px; +} + +.sliderWrapper { + transition: transform 0.5s ease; + will-change: transform; +} + diff --git a/src/components/ProductsSlider/ProductsSlider.tsx b/src/components/ProductsSlider/ProductsSlider.tsx new file mode 100644 index 0000000000..12771988d8 --- /dev/null +++ b/src/components/ProductsSlider/ProductsSlider.tsx @@ -0,0 +1,113 @@ +import React, { useEffect, useState } from 'react'; +import styles from './ProductsSlider.module.scss'; +import { Product } from '../../types/Product'; +import { ProductCard } from '../ProductCard'; +import { getChevronIconSrc } from '../../servises/iconSrc'; +import { useTheme } from '../../context/ThemeContext'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { useSwipeable } from 'react-swipeable'; +import classNames from 'classnames'; + +type Props = { + products: Product[]; + title: string; + isHomePage?: boolean; +}; + +export const ProductsSlider: React.FC = ({ products, title }) => { + const [currentIndex, setCurrentIndex] = useState(0); + const [visibleCardsCount, setVisibleCardsCount] = useState(3); + const { theme } = useTheme(); + + const updateVisibleCardsCount = () => { + const width = window.innerWidth; + + if (width < 640) { + setVisibleCardsCount(1); + } else if (width >= 640 && width < 1200) { + setVisibleCardsCount(2); + } else { + setVisibleCardsCount(4); + } + }; + + useEffect(() => { + updateVisibleCardsCount(); + window.addEventListener('resize', updateVisibleCardsCount); + + return () => { + window.removeEventListener('resize', updateVisibleCardsCount); + }; + }, []); + + const chevronIconSrc = getChevronIconSrc(theme); + + const handlePrevClick = () => { + setCurrentIndex(prevIndex => Math.max(prevIndex - 1, 0)); + }; + + const handleNextClick = () => { + setCurrentIndex(prevIndex => + Math.min(prevIndex + 1, products.length - visibleCardsCount), + ); + }; + + const isPrevDisabled = currentIndex === 0; + const isNextDisabled = currentIndex === products.length - visibleCardsCount; + + const handlers = useSwipeable({ + onSwipedLeft: () => handleNextClick(), + onSwipedRight: () => handlePrevClick(), + }); + + return ( +
+
+

{title}

+
+ + + +
+
+ +
+ {products.map(product => ( +
+ +
+ ))} +
+
+ ); +}; diff --git a/src/components/ProductsSlider/index.ts b/src/components/ProductsSlider/index.ts new file mode 100644 index 0000000000..68d3ea0012 --- /dev/null +++ b/src/components/ProductsSlider/index.ts @@ -0,0 +1 @@ +export * from './ProductsSlider'; diff --git a/src/components/Search/Search.module.scss b/src/components/Search/Search.module.scss new file mode 100644 index 0000000000..70be92e7f9 --- /dev/null +++ b/src/components/Search/Search.module.scss @@ -0,0 +1,53 @@ +@import './../../styles/main'; + +.search { + display: flex; + align-items: center; + box-shadow: -1px 0 0 0 var(--color-elements), 1px 0 0 0 var(--color-elements); + cursor: pointer; + background-color: transparent; + border: none; + overflow: hidden; + height: $height-on-tablet; + transition: width 0.5s ease; + + @include on-desktop { + height: $height-on-desktop; + min-width: 320px; + } + + &:hover { + color: var(--color-primary); + + cursor: text; + } +} + +.searchInput { + padding: 0 10px; + width: 100%; + height: 100%; + align-items: center; + padding-inline: 14px; + border: none; + outline: none; + + background-color: transparent; + color: var(--color-primary); + + @extend %body-text; + + @include on-desktop { + padding-inline: 16px; + } + + &::placeholder { + color: var(--color-secondary); + + @extend %body-text; + } +} + +.hidden { + display: none; +} diff --git a/src/components/Search/Search.tsx b/src/components/Search/Search.tsx new file mode 100644 index 0000000000..41f6216051 --- /dev/null +++ b/src/components/Search/Search.tsx @@ -0,0 +1,62 @@ +import React, { useEffect, useState } from 'react'; +import { useLocation, useSearchParams } from 'react-router-dom'; +import styles from './Search.module.scss'; +import classNames from 'classnames'; +import debounce from 'lodash.debounce'; +import { useTheme } from '../../context/ThemeContext'; + +export const Search: React.FC = () => { + const { pathname } = useLocation(); + const { theme } = useTheme(); + + const [searchParams, setSearchParams] = useSearchParams(); + const [query, setQuery] = useState(searchParams.get('query') || ''); + + useEffect(() => { + setQuery(''); + setSearchParams({}); + }, [pathname, theme]); + + const updateSearchQuery = (value: string) => { + const trimmedValue = value.trim(); + const newQuery = new URLSearchParams(searchParams.toString()); + + if (trimmedValue) { + newQuery.set('query', trimmedValue); + } else { + newQuery.delete('query'); + } + + setSearchParams(newQuery); + }; + + const debouncedUpdateSearchQuery = debounce(updateSearchQuery, 500); + + const handleSearchChange = (event: React.ChangeEvent) => { + const inputValue = event.target.value; + + debouncedUpdateSearchQuery(inputValue); + setQuery(inputValue); + }; + + const isHidden = + pathname === '/' || + pathname === '/favorites' || + pathname === '/cart' || + pathname === '/products' || + pathname.startsWith('/products/'); + + return ( +
+ +
+ ); +}; diff --git a/src/context/AppContext.tsx b/src/context/AppContext.tsx new file mode 100644 index 0000000000..af49789a49 --- /dev/null +++ b/src/context/AppContext.tsx @@ -0,0 +1,121 @@ +import React, { createContext, useCallback, useContext } from 'react'; +import { Product } from '../types/Product'; +import { useLocalStorage } from '../hooks/useLocalStorage'; +import { CartItemProps } from '../types/CartItemProps'; + +type Props = { + children: React.ReactNode; +}; + +type AppContextType = { + favorites: Product[]; + addToFavorites: (product: Product) => void; + removeFromFavorites: (productId: string) => void; + cart: CartItemProps[]; + addToCart: (product: Product) => void; + removeFromCart: (productId: string) => void; + updateCartQuantity: (productId: string, quantity: number) => void; + calculateTotalPrice: () => number; + clearCart: () => void; +}; + +const AppContext = createContext({ + favorites: [], + addToFavorites: () => {}, + removeFromFavorites: () => {}, + cart: [], + addToCart: () => {}, + removeFromCart: () => {}, + updateCartQuantity: () => {}, + calculateTotalPrice: () => 0, + clearCart: () => {}, +}); + +export const useAppContext = () => { + return useContext(AppContext); +}; + +export const AppProvider: React.FC = ({ children }) => { + const [favorites, setFavorites] = useLocalStorage('favorites', []); + const [cart, setCart] = useLocalStorage('cart', []); + + const addToFavorites = useCallback( + (product: Product) => { + setFavorites((prevFavorites: Product[]) => [...prevFavorites, product]); + }, + [setFavorites], + ); + + const removeFromFavorites = useCallback( + (productId: string) => { + setFavorites((prevFavorites: Product[]) => + prevFavorites.filter(product => product.id !== productId), + ); + }, + [setFavorites], + ); + + const addToCart = useCallback( + (product: Product) => { + setCart(prevCart => [...prevCart, { product, quantity: 1 }]); + }, + [setCart], + ); + + const removeFromCart = useCallback( + (productId: string) => { + setCart((prevCart: CartItemProps[]) => + prevCart.filter(item => item.product.id !== productId), + ); + }, + [setCart], + ); + + const updateCartQuantity = useCallback( + (productId: string, delta: number) => { + setCart(currentCart => + currentCart.map(cartItem => { + if (cartItem.product.id === productId) { + const newQuantity = Math.max(cartItem.quantity + delta, 1); + + return { ...cartItem, quantity: newQuantity }; + } + + return cartItem; + }), + ); + }, + [setCart], + ); + + const clearCart = useCallback(() => { + setCart([]); + }, [setCart]); + + const calculateTotalPrice = useCallback(() => { + return Math.floor( + cart.reduce( + (total, { product, quantity }) => total + product.price * quantity, + 0, + ), + ); + }, [cart]); + + return ( + + {children} + + ); +}; diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx new file mode 100644 index 0000000000..a486e82851 --- /dev/null +++ b/src/context/ThemeContext.tsx @@ -0,0 +1,44 @@ +import React, { createContext, useContext, useEffect, useState } from 'react'; +import { ThemeType } from '../types/ThemeType'; + +interface ThemeContextType { + theme: ThemeType; + toggleTheme: () => void; +} + +type Props = { + children: React.ReactNode; +}; +export const ThemeContext = createContext( + undefined, +); + +export const ThemeProvider: React.FC = ({ children }) => { + const [theme, setTheme] = useState(ThemeType.LIGHT); + + useEffect(() => { + document.body.setAttribute('data-theme', theme); + }, [theme]); + + const toggleTheme = () => { + setTheme(current => + current === ThemeType.LIGHT ? ThemeType.DARK : ThemeType.LIGHT, + ); + }; + + return ( + + {children} + + ); +}; + +export const useTheme = (): ThemeContextType => { + const context = useContext(ThemeContext); + + if (context === undefined) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + + return context; +}; diff --git a/src/fonts/Mont-Bold.otf b/src/fonts/Mont-Bold.otf new file mode 100755 index 0000000000..7f1598293a Binary files /dev/null and b/src/fonts/Mont-Bold.otf differ diff --git a/src/fonts/Mont-Regular.otf b/src/fonts/Mont-Regular.otf new file mode 100755 index 0000000000..d5543feaf0 Binary files /dev/null and b/src/fonts/Mont-Regular.otf differ diff --git a/src/fonts/Mont-SemiBold.otf b/src/fonts/Mont-SemiBold.otf new file mode 100755 index 0000000000..a9fa16a9c5 Binary files /dev/null and b/src/fonts/Mont-SemiBold.otf differ diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts new file mode 100644 index 0000000000..b64b85bbb9 --- /dev/null +++ b/src/hooks/useLocalStorage.ts @@ -0,0 +1,25 @@ +import { useState } from 'react'; + +export function useLocalStorage( + key: string, + initialValue: T, +): [T, (v: T | ((val: T) => T)) => void] { + const [storedValue, setStoredValue] = useState(() => { + try { + const item = window.localStorage.getItem(key); + + return item ? JSON.parse(item) : initialValue; + } catch (error) { + return initialValue; + } + }); + + const setValue = (value: T | ((val: T) => T)) => { + const valueToStore = value instanceof Function ? value(storedValue) : value; + + setStoredValue(valueToStore); + window.localStorage.setItem(key, JSON.stringify(valueToStore)); + }; + + return [storedValue, setValue]; +} diff --git a/src/index.tsx b/src/index.tsx index 50470f1508..21951d42dd 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,7 @@ import { createRoot } from 'react-dom/client'; -import { App } from './App'; -createRoot(document.getElementById('root') as HTMLElement).render(); +import { Root } from './Root'; + +const container = document.getElementById('root') as HTMLElement; + +createRoot(container).render(); diff --git a/src/modules/AccessoriesPage/AccessoriesPage.scss b/src/modules/AccessoriesPage/AccessoriesPage.scss new file mode 100644 index 0000000000..af05f80224 --- /dev/null +++ b/src/modules/AccessoriesPage/AccessoriesPage.scss @@ -0,0 +1,3 @@ +.accessories { + display: flex; +} diff --git a/src/modules/AccessoriesPage/AccessoriesPage.tsx b/src/modules/AccessoriesPage/AccessoriesPage.tsx new file mode 100644 index 0000000000..05b55deaef --- /dev/null +++ b/src/modules/AccessoriesPage/AccessoriesPage.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { useLocation } from 'react-router-dom'; +import { ProductsList } from '../../components/ProductsList'; + +export const AccessoriesPage: React.FC = () => { + const location = useLocation(); + + const category = location.pathname.split('/')[1]; + + return ; +}; diff --git a/src/modules/AccessoriesPage/index.ts b/src/modules/AccessoriesPage/index.ts new file mode 100644 index 0000000000..486474aa0b --- /dev/null +++ b/src/modules/AccessoriesPage/index.ts @@ -0,0 +1 @@ +export * from './AccessoriesPage'; diff --git a/src/modules/CartPage/CartItem/CartItem.module.scss b/src/modules/CartPage/CartItem/CartItem.module.scss new file mode 100644 index 0000000000..cea0d37446 --- /dev/null +++ b/src/modules/CartPage/CartItem/CartItem.module.scss @@ -0,0 +1,130 @@ +@import '../../../styles/main'; + +.cartItem { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + padding: 16px; + border: 1px solid var(--color-elements); + width: 100%; + + @include on-tablet { + gap: 24px; + flex-direction: row; + justify-content: space-between; + } +} + +.mainContainer { + display: flex; + width: 100%; + align-items: center; + gap: 16px; + +} + +.deleteButton { + display: flex; + align-items: center; + justify-content: center; + border: none; + background-color: transparent; + cursor: pointer; + + &:hover img { + transform: scale(1.1); + } +} + +.deleteButtonIcon { + width: 16px; + height: 16px; +} + +.productImage { + display: flex; + align-items: center; + justify-content: center; + width: 80px; + height: 80px; +} + +.image { + width: 66px; + height: 66px; + object-fit: contain; +} + +.quantityControl { + display: flex; + height: 32px; + width: 100%; + align-items: center; + justify-content: space-between; + + @include on-tablet { + flex: 1; + gap: 24px; + } +} + +.quantity { + display: flex; + align-items: center; + width: 96px; +} + +.button { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + border: 1px solid var(--color-surface); + background-color: var(--color-icons); + height: 32px; + cursor: pointer; + + transition: transform $transition-duration ease; + + &:hover { + transform: scale(1.1); + border: 1px solid var(--color-primary); + background-color: var(--color-hover); + } +} + +.disabled { + cursor: default; + background-color: var(--color-background); + opacity: 0.5; + + &:hover { + transform: none; + border: 1px solid var(--color-surface); + background-color: transparent; + } +} + +.controlButtonIcon { + width: 16px; + height: 16px; + border: none; + background: transparent; +} + +.quantityValueContainer { + display: flex; + align-items: center; + justify-content: center; + height: 32px; + width: 32px; +} + +.quantityValue { + @extend %body-text; +} + +.price{ + @extend %h3-tablet; +} diff --git a/src/modules/CartPage/CartItem/CartItem.tsx b/src/modules/CartPage/CartItem/CartItem.tsx new file mode 100644 index 0000000000..de5d30752f --- /dev/null +++ b/src/modules/CartPage/CartItem/CartItem.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { useAppContext } from '../../../context/AppContext'; +import styles from './CartItem.module.scss'; +import { CartItemProps } from '../../../types/CartItemProps'; +import { + getDecreaseIconSrc, + getRemoveIconSrc, + getIncreaseIconSrc, +} from '../../../servises/iconSrc'; +import { useTheme } from '../../../context/ThemeContext'; +import { Link } from 'react-router-dom'; +import classNames from 'classnames'; +import { BASE_URL } from '../../../utils/const'; + +type Props = { + item: CartItemProps; +}; + +export const CartItem: React.FC = ({ item }) => { + const { updateCartQuantity, removeFromCart } = useAppContext(); + const { theme } = useTheme(); + const { product, quantity } = item; + const { image, name, id, price, itemId } = product; + + const handleDeleteItem = () => { + removeFromCart(id); + }; + + const deleteImgSrc = getRemoveIconSrc(theme); + const degreaseImgSrc = getDecreaseIconSrc(theme); + const increaseImgSrc = getIncreaseIconSrc(theme); + + return ( +
+
+ + + {name} + +

{name}

+
+
+
+ +
+

{quantity}

+
+ +
+

${price}

+
+
+ ); +}; diff --git a/src/modules/CartPage/CartItem/index.tsx b/src/modules/CartPage/CartItem/index.tsx new file mode 100644 index 0000000000..37a0553540 --- /dev/null +++ b/src/modules/CartPage/CartItem/index.tsx @@ -0,0 +1 @@ +export * from './CartItem'; diff --git a/src/modules/CartPage/CartPage.module.scss b/src/modules/CartPage/CartPage.module.scss new file mode 100644 index 0000000000..64c5655a30 --- /dev/null +++ b/src/modules/CartPage/CartPage.module.scss @@ -0,0 +1,136 @@ +@import '../../styles/main'; + +.cartPage { + @include section-grid; +} + +.topContainer { + grid-column: 1 / -1; + display: flex; + width: 100%; + flex-direction: column; + justify-content: center; +} + +.goBackButton { + display: flex; + align-items: center; + overflow: hidden; + gap: 8px; + height: 16px; + width: 66px; + border: 0; + cursor: pointer; + background-color: transparent; +} + +.goBackText { + display: block; + padding-top: 2px; + + @extend %small-text; + + color: var(--color-secondary); +} + +.title { + margin-top: 40px; + + @extend %h1-mobile; + + @include on-tablet { + @include h1-tablet; + } +} + +.emptyContainer { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + color: var(--color-secondary); + + @extend %h3-tablet; +} + +.container { + grid-column: 1 / -1; + + @include section-grid; + + padding: 0; + margin-top: 24px; + + @include on-tablet { + padding: 0; + } +} + +.cartItems { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + gap: 16px; + margin-bottom: 30px; + + @include on-desktop { + grid-column: span 16; + } +} + +.bottomContainer { + grid-column: 1 / -1; + display: flex; + width: 100%; + flex-direction: column; + align-items: center; + gap: 16px; + + @include on-desktop { + grid-column: span 8; + } +} + +.checkout { + display: flex; + width: 100%; + flex-direction: column; + align-items: center; + gap: 16px; + padding: 24px; + border: 1px solid var(--color-elements); +} + +.totalPrice { + @extend %h2-tablet; +} + +.totalItems { + @extend %body-text; + + color: var(--color-secondary); +} + +.divider { + width: 100%; + height: 1px; + background-color: var(--color-elements); +} + +.checkoutButton { + width: 100%; + height: 48px; + background-color: var(--color-button); + cursor: pointer; + text-align: center; + + @extend %body-text; + + color: var(--color-white); + transition: transform $transition-duration ease; + + &:hover { + transform: scale(1.05); + box-shadow: 0 3px 13px 0 #17203166; + background-color: var(--color-button-hover); + } +} diff --git a/src/modules/CartPage/CartPage.tsx b/src/modules/CartPage/CartPage.tsx new file mode 100644 index 0000000000..0c7e799a2a --- /dev/null +++ b/src/modules/CartPage/CartPage.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import styles from './CartPage.module.scss'; +import { useAppContext } from '../../context/AppContext'; +import { useNavigate } from 'react-router-dom'; +import { getChevronIconSrc } from '../../servises/iconSrc'; +import { useTheme } from '../../context/ThemeContext'; +import { CartItem } from './CartItem'; + +const CartPage: React.FC = () => { + const { cart, calculateTotalPrice, clearCart } = useAppContext(); + const totalItems = cart.reduce( + (total, cartItem) => total + cartItem.quantity, + 0, + ); + + const { theme } = useTheme(); + const navigate = useNavigate(); + const chevronIconSrc = getChevronIconSrc(theme); + + const handleCheckout = () => { + const confirmClear = window.confirm( + 'Checkout is not implemented yet. Do you want to clear the Cart?', + ); + + if (confirmClear) { + clearCart(); + } + }; + + return ( +
+
+
+ +
+

Cart

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

Your cart is empty.

+
+ ) : ( +
+
+ {cart.map(cartItem => ( + + ))} +
+
+
+

${calculateTotalPrice()}

+

{`Total for ${totalItems} item${totalItems > 1 ? 's' : ''}`}

+
+ +
+
+
+ )} +
+ ); +}; + +export default CartPage; diff --git a/src/modules/CartPage/index.ts b/src/modules/CartPage/index.ts new file mode 100644 index 0000000000..cba584bad9 --- /dev/null +++ b/src/modules/CartPage/index.ts @@ -0,0 +1 @@ +export { default as CartPage } from './CartPage'; diff --git a/src/modules/FavoritesPage/FavoritesPage.module.scss b/src/modules/FavoritesPage/FavoritesPage.module.scss new file mode 100644 index 0000000000..bf0a0acb0d --- /dev/null +++ b/src/modules/FavoritesPage/FavoritesPage.module.scss @@ -0,0 +1,54 @@ +@import '../../styles/main'; + +.favoritesPage { + @include section-grid; +} + +.topContainer { + grid-column: 1 / -1; + display: flex; + width: 100%; + flex-direction: column; + justify-content: center; +} + +.title { + margin-top: 40px; + + @extend %h1-mobile; + + @include on-tablet { + @include h1-tablet; + } +} + +.count { + @extend %body-text; + + color: var(--color-secondary); +} + +.container { + grid-column: 1 / -1; + margin-top: 40px; + + @include section-grid; + + padding: 0; +} + +.product { + grid-column: span 4; + + @include on-tablet { + grid-column: span 6; + } + + @include middle-screen { + grid-column: span 4; + } + + @include on-desktop { + grid-column: span 6; + } +} diff --git a/src/modules/FavoritesPage/FavoritesPage.tsx b/src/modules/FavoritesPage/FavoritesPage.tsx new file mode 100644 index 0000000000..a38cdc5838 --- /dev/null +++ b/src/modules/FavoritesPage/FavoritesPage.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import styles from './FavoritesPage.module.scss'; +import { useAppContext } from '../../context/AppContext'; +import { ProductCard } from '../../components/ProductCard'; +import { Breadcrumbs } from '../../components/Breadcrumbs'; + +const FavoritesPage: React.FC = () => { + const { favorites } = useAppContext(); + + return ( +
+
+ +

Favorites

+

+ {`${favorites.length} item${favorites.length > 1 ? 's' : ''}`} +

+
+
+ {favorites.map(product => ( +
+ +
+ ))} +
+
+ ); +}; + +export default FavoritesPage; diff --git a/src/modules/FavoritesPage/index.ts b/src/modules/FavoritesPage/index.ts new file mode 100644 index 0000000000..cfb19c003a --- /dev/null +++ b/src/modules/FavoritesPage/index.ts @@ -0,0 +1 @@ +export { default as FavoritesPage } from './FavoritesPage'; diff --git a/src/modules/HomePage/HomePage.module.scss b/src/modules/HomePage/HomePage.module.scss new file mode 100644 index 0000000000..f1051c2766 --- /dev/null +++ b/src/modules/HomePage/HomePage.module.scss @@ -0,0 +1,20 @@ +@import '../../styles/main'; + +.homePage { + display: flex; + flex-direction: column; + gap: 80px; +} + +.visuallyHidden{ + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + border: 0; + padding: 0; + white-space: nowrap; + clip-path: inset(100%); + clip: rect(0 0 0 0); + overflow: hidden; +} diff --git a/src/modules/HomePage/HomePage.tsx b/src/modules/HomePage/HomePage.tsx new file mode 100644 index 0000000000..6c042c085f --- /dev/null +++ b/src/modules/HomePage/HomePage.tsx @@ -0,0 +1,52 @@ +import React, { useEffect, useState } from 'react'; +import styles from './HomePage.module.scss'; +import { PicturesSlider } from './components/PicturesSlider'; +import { Categories } from './components/Categories'; +import { getHotPriceProducts, getNewProducts } from '../../servises/Products'; +import { Product } from '../../types/Product'; +import { Loader } from '../../components/Loader'; +import { ProductsSlider } from '../../components/ProductsSlider'; + +const HomePage: React.FC = () => { + const [newProducts, setNewProducts] = useState([]); + const [hotProducts, setHotProducts] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const fetchProducts = async () => { + try { + const [fetchedHotProducts, fetchedNewProducts] = await Promise.all([ + getHotPriceProducts(), + getNewProducts(), + ]); + + setHotProducts(fetchedHotProducts); + setNewProducts(fetchedNewProducts); + } finally { + setIsLoading(false); + } + }; + + fetchProducts(); + }, []); + + return ( +
+

Product Catalog

+ {isLoading ? ( + + ) : ( + <> + + +
+ +
+ + + )} +
+ ); +}; + +export default HomePage; diff --git a/src/modules/HomePage/components/Categories/Categories.module.scss b/src/modules/HomePage/components/Categories/Categories.module.scss new file mode 100644 index 0000000000..24d8196b5a --- /dev/null +++ b/src/modules/HomePage/components/Categories/Categories.module.scss @@ -0,0 +1,61 @@ +@import '../../../../styles/main'; + +.categories { + @include section-grid; +} + +.title { + grid-column: 1 / -1; + + @extend %h2-mobile; + + @include on-tablet { + font-family: Mont, sans-serif; + font-size: 32px; + line-height: 41px; + letter-spacing: 0.01em; + font-weight: bold; + color: var(--color-primary); + } +} + +.category { + grid-column: 1 / -1; + + + @include on-tablet { + grid-column: span 4; + align-self: start; + } + + @include on-desktop { + grid-column: span 8; + } +} + +.link { + display: block; + text-decoration: none; + transition: transform $transition-duration ease; +} + +.image { + object-fit: cover; + width: 100%; + margin-bottom: 10px; + + &:hover { + transform: scale(1.05); + } +} + +.category__title { + @extend %h4-tablet; +} + +.category__subtitle { + @extend %body-text; + + color: var(--color-secondary); +} + diff --git a/src/modules/HomePage/components/Categories/Categories.tsx b/src/modules/HomePage/components/Categories/Categories.tsx new file mode 100644 index 0000000000..8dcdf30019 --- /dev/null +++ b/src/modules/HomePage/components/Categories/Categories.tsx @@ -0,0 +1,56 @@ +import React, { useEffect, useState } from 'react'; +import styles from './Categories.module.scss'; +import { Link } from 'react-router-dom'; +import { ShopByCategoryMap } from '../Helpers/ShopByCategoryMap'; +import { getProductsByCategory } from '../../../../servises/Products'; +import { BASE_URL } from '../../../../utils/const'; + +export const Categories: React.FC = () => { + const [phonesCount, setPhonesCount] = useState(0); + const [tabletsCount, setTabletsCount] = useState(0); + const [accessoriesCount, setAccessoriesCount] = useState(0); + + useEffect(() => { + const fetchProductCounts = async () => { + const categories = ['phones', 'tablets', 'accessories']; + + const productCounts = await Promise.all( + categories.map(category => getProductsByCategory(category)), + ); + + setPhonesCount(productCounts[0].length); + setTabletsCount(productCounts[1].length); + setAccessoriesCount(productCounts[2].length); + }; + + fetchProductCounts(); + }, []); + + return ( +
+

Shop by category

+ + {ShopByCategoryMap.map(({ id, src, title, path }) => ( +
+ + {title} + +

{title}

+ + + + {(title === 'Mobile phones' && `${phonesCount} models`) || + (title === 'Tablets' && `${tabletsCount} models`) || + (title === 'Accessories' && `${accessoriesCount} models`)} + + + +
+ ))} +
+ ); +}; diff --git a/src/modules/HomePage/components/Categories/index.tsx b/src/modules/HomePage/components/Categories/index.tsx new file mode 100644 index 0000000000..79c7c7dcde --- /dev/null +++ b/src/modules/HomePage/components/Categories/index.tsx @@ -0,0 +1 @@ +export * from './Categories'; diff --git a/src/modules/HomePage/components/Helpers/PicturesSliderMap.ts b/src/modules/HomePage/components/Helpers/PicturesSliderMap.ts new file mode 100644 index 0000000000..c2d2e41846 --- /dev/null +++ b/src/modules/HomePage/components/Helpers/PicturesSliderMap.ts @@ -0,0 +1,17 @@ +export const PicturesSliderMap = [ + { + id: 1, + src: './img/banner-phones.png', + title: 'phones', + }, + { + id: 2, + src: './img/banner-tablets.png', + title: 'tablets', + }, + { + id: 3, + src: './img/banner-accessories.png', + title: 'accessories', + }, +]; diff --git a/src/modules/HomePage/components/Helpers/ShopByCategoryMap.ts b/src/modules/HomePage/components/Helpers/ShopByCategoryMap.ts new file mode 100644 index 0000000000..14d9428e00 --- /dev/null +++ b/src/modules/HomePage/components/Helpers/ShopByCategoryMap.ts @@ -0,0 +1,20 @@ +export const ShopByCategoryMap = [ + { + id: 1, + src: './img/category-phones.png', + title: 'Mobile phones', + path: '/phones', + }, + { + id: 2, + src: './img/category-tablets.png', + title: 'Tablets', + path: '/tablets', + }, + { + id: 3, + src: './img/category-accessories.png', + title: 'Accessories', + path: '/accessories', + }, +]; diff --git a/src/modules/HomePage/components/PicturesSlider/PicturesSlider.module.scss b/src/modules/HomePage/components/PicturesSlider/PicturesSlider.module.scss new file mode 100644 index 0000000000..4c0cc30042 --- /dev/null +++ b/src/modules/HomePage/components/PicturesSlider/PicturesSlider.module.scss @@ -0,0 +1,114 @@ +@import '../../../../styles/main'; + +.slider { + @include section-grid; + + padding-inline: 0; +} + +.container { + grid-column: 1 / -1; + display: flex; + overflow: hidden; + width: 100%; + height: 320px; + + + @include on-tablet { + grid-column: 2 / -2; + height: 360px; + } + + @include middle-screen { + height: 410px; + } + + @include on-desktop { + height: 432px; + } +} + +.link { + display: flex; + align-items: center; + min-width: 100%; + height: 100%; + cursor: pointer; +} + +.image { + width: 100%; + height: 100%; + object-fit: cover; + + flex-shrink: 0; + + transition: transform $transition-duration ease; +} + +.button { + display: none; + width: 32px; + height: 100%; + background-color: var(--color-icons); + border: 1px solid var(--color-surface); + border-radius: 4px; + cursor: pointer; + + @include on-tablet { + display: block; + grid-column: span 1; + } + + &:hover { + border: 1px solid var(--color-primary); + background-color: var(--color-hover); + } +} + +.next { + @include on-tablet { + justify-self: end; + } +} + +.iconNext { + transform : rotate(180deg); +} + + +.dashes { + grid-column: 1 / -1; + display: flex; + justify-content: center; + align-items: center; + gap: 4px; + height: 24px; + + @include on-tablet { + grid-column: 6 / 8; + } + + @include on-desktop { + grid-column: 12 / 14; + } +} + +.dashContainer { + display: flex; + justify-content: center; + align-items: center; + width: 24px; + height: 24px; + cursor: pointer; +} + +.dash { + height: 4px; + width: 14px; + background-color: var(--color-surface); + + &.active { + background-color: var(--color-primary); + } +} diff --git a/src/modules/HomePage/components/PicturesSlider/PicturesSlider.tsx b/src/modules/HomePage/components/PicturesSlider/PicturesSlider.tsx new file mode 100644 index 0000000000..ff22c24707 --- /dev/null +++ b/src/modules/HomePage/components/PicturesSlider/PicturesSlider.tsx @@ -0,0 +1,98 @@ +import React, { useEffect, useState } from 'react'; +import classNames from 'classnames'; +import styles from './PicturesSlider.module.scss'; +import { PicturesSliderMap } from '../Helpers/PicturesSliderMap'; +import { useTheme } from '../../../../context/ThemeContext'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { useSwipeable } from 'react-swipeable'; +import { getChevronIconSrc } from '../../../../servises/iconSrc'; +import { Link } from 'react-router-dom'; +import { BASE_URL } from '../../../../utils/const'; + +export const PicturesSlider: React.FC = () => { + const [currentIndex, setCurrentIndex] = useState(0); + const { theme } = useTheme(); + + const chevronIconSrc = getChevronIconSrc(theme); + + useEffect(() => { + const intervalId = setInterval(() => { + setCurrentIndex(prevIndex => (prevIndex + 1) % PicturesSliderMap.length); + }, 5000); + + return () => clearInterval(intervalId); + }, []); + + const handlePrevClick = () => { + setCurrentIndex(prevIndex => + prevIndex === 0 ? PicturesSliderMap.length - 1 : prevIndex - 1, + ); + }; + + const handleNextClick = () => { + setCurrentIndex(prevIndex => + prevIndex === PicturesSliderMap.length - 1 ? 0 : prevIndex + 1, + ); + }; + + const handlers = useSwipeable({ + onSwipedLeft: () => handleNextClick(), + onSwipedRight: () => handlePrevClick(), + }); + + return ( +
+ +
+ {PicturesSliderMap.map(({ id, src, title }) => ( + + {`Slide + + ))} +
+ + +
+ {PicturesSliderMap.map(({ id }, index) => ( + setCurrentIndex(index)} + className={styles.dashContainer} + onKeyDown={() => setCurrentIndex(index)} + aria-label={`Slide ${index + 1}`} + > +
+ + ))} +
+
+ ); +}; diff --git a/src/modules/HomePage/components/PicturesSlider/index.tsx b/src/modules/HomePage/components/PicturesSlider/index.tsx new file mode 100644 index 0000000000..81a373f3aa --- /dev/null +++ b/src/modules/HomePage/components/PicturesSlider/index.tsx @@ -0,0 +1 @@ +export * from './PicturesSlider'; diff --git a/src/modules/HomePage/index.ts b/src/modules/HomePage/index.ts new file mode 100644 index 0000000000..6ba9fc8ac9 --- /dev/null +++ b/src/modules/HomePage/index.ts @@ -0,0 +1 @@ +export { default as HomePage } from './HomePage'; diff --git a/src/modules/NotFoundPage/NotFoundPage.module.scss b/src/modules/NotFoundPage/NotFoundPage.module.scss new file mode 100644 index 0000000000..8ec3e111a4 --- /dev/null +++ b/src/modules/NotFoundPage/NotFoundPage.module.scss @@ -0,0 +1,11 @@ +@import '../../styles/main'; + +.notFoundPage { + display: flex; + flex-direction: column; + align-items: center; + + @extend %h2-mobile; +} + + diff --git a/src/modules/NotFoundPage/NotFoundPage.tsx b/src/modules/NotFoundPage/NotFoundPage.tsx new file mode 100644 index 0000000000..1bf958df67 --- /dev/null +++ b/src/modules/NotFoundPage/NotFoundPage.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import styles from './NotFoundPage.module.scss'; + +export const NotFoundPage: React.FC = () => { + return ( +
+

Oops, page not found

+
+ ); +}; diff --git a/src/modules/NotFoundPage/index.ts b/src/modules/NotFoundPage/index.ts new file mode 100644 index 0000000000..6197aa75aa --- /dev/null +++ b/src/modules/NotFoundPage/index.ts @@ -0,0 +1 @@ +export * from './NotFoundPage'; diff --git a/src/modules/PhonesPage/PhonesPage.module.scss b/src/modules/PhonesPage/PhonesPage.module.scss new file mode 100644 index 0000000000..19b4146bf7 --- /dev/null +++ b/src/modules/PhonesPage/PhonesPage.module.scss @@ -0,0 +1 @@ +@import '../../styles/main'; diff --git a/src/modules/PhonesPage/PhonesPage.tsx b/src/modules/PhonesPage/PhonesPage.tsx new file mode 100644 index 0000000000..0af2f5c0a3 --- /dev/null +++ b/src/modules/PhonesPage/PhonesPage.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { useLocation } from 'react-router-dom'; +import { ProductsList } from '../../components/ProductsList'; + +export const PhonesPage: React.FC = () => { + const location = useLocation(); + + const category = location.pathname.split('/')[1]; + + return ; +}; diff --git a/src/modules/PhonesPage/index.ts b/src/modules/PhonesPage/index.ts new file mode 100644 index 0000000000..380be65cc7 --- /dev/null +++ b/src/modules/PhonesPage/index.ts @@ -0,0 +1 @@ +export * from './PhonesPage'; diff --git a/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss b/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss new file mode 100644 index 0000000000..18a872986d --- /dev/null +++ b/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss @@ -0,0 +1,50 @@ +@import '../../styles/main'; + +.productDetailsPage { + display: flex; + flex-direction: column; + gap: 80px; +} + +.container { + @include section-grid; +} + +.breadcrumbs { + grid-column: 1 / -1; +} + +.goBackButton { + margin-top: 16px; + grid-column: 1 / -1; + display: flex; + align-items: center; + overflow: hidden; + gap: 8px; + height: 16px; + width: 66px; + border: 0; + cursor: pointer; + background-color: transparent; +} + +.goBackText { + display: block; + padding-top: 2px; + + @extend %small-text; + + color: var(--color-secondary); +} + +.title { + grid-column: 1 / -1; + margin-bottom: 40px; + + @extend %h2-mobile; + + @include on-tablet { + @include h2-tablet; + } +} + diff --git a/src/modules/ProductDetailsPage/ProductDetailsPage.tsx b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx new file mode 100644 index 0000000000..b73c7af76c --- /dev/null +++ b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx @@ -0,0 +1,102 @@ +import React, { useEffect, useState } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { ProductDetails } from '../../types/ProductDetails'; +import { getProductDetails } from '../../servises/ProductsDetails'; +import styles from './ProductDetailsPage.module.scss'; +import { Breadcrumbs } from '../../components/Breadcrumbs/Breadcrumbs'; +import { getChevronIconSrc } from '../../servises/iconSrc'; +import { useTheme } from '../../context/ThemeContext'; +import { Product } from '../../types/Product'; +import { getAllProducts, getSuggestedProducts } from '../../servises/Products'; +import { ImageGallery } from './components/ImageGallery'; +import { TechSpecs } from './components/TechSpecs'; +import { Description } from './components/Description'; +import { MainControls } from './components/MainControls'; +import { Loader } from '../../components/Loader'; +import { ProductsSlider } from '../../components/ProductsSlider'; + +const ProductDetailsPage: React.FC = () => { + const { productId } = useParams<{ + productId: string; + }>(); + const [productDetails, setProductDetails] = useState( + null, + ); + const [product, setProduct] = useState(); + const [isLoading, setIsLoading] = useState(true); + const [suggestedProducts, setSuggestedProducts] = useState([]); + const navigate = useNavigate(); + const { theme } = useTheme(); + const chevronIconSrc = getChevronIconSrc(theme); + + useEffect(() => { + const fetchProductData = async () => { + setIsLoading(true); + try { + const allProducts: Product[] = await getAllProducts(); + const productMatch = allProducts.find(p => p.itemId === productId); + + setProduct(productMatch); + + const cugettedProducts: Product[] = await getSuggestedProducts(); + + setSuggestedProducts(cugettedProducts); + + if (productId && productMatch) { + const detailedProduct = await getProductDetails( + productId, + productMatch.category, + ); + + setProductDetails(detailedProduct); + } else { + setProductDetails(null); + } + } finally { + setIsLoading(false); + } + }; + + if (productId) { + fetchProductData(); + } + }, [productId]); + + if (isLoading) { + return ; + } + + if (!productDetails || !product) { + return
No product found or failed to load product details.
; + } + + return ( +
+
+ + +

{productDetails.name}

+ + + + +
+ + +
+ ); +}; + +export default ProductDetailsPage; diff --git a/src/modules/ProductDetailsPage/components/Description/Description.module.scss b/src/modules/ProductDetailsPage/components/Description/Description.module.scss new file mode 100644 index 0000000000..db5e9ad540 --- /dev/null +++ b/src/modules/ProductDetailsPage/components/Description/Description.module.scss @@ -0,0 +1,60 @@ +@import '../../../../styles/main'; + + +.section { + grid-column: 1 / -1; + + @include on-desktop { + grid-column: 1 / 13; + } +} + +.descriptionSection { + display: flex; + flex-direction: column; + gap: 16px; +} + +.sectionTitle { + @extend %h3-mobile; + + color: var(--color-primary); + + @include on-tablet { + font-family: Mont, sans-serif; + font-size: 22px; + line-height: 31.8px; + letter-spacing: 0; + font-weight: 800; + color: var(--color-primary); + } +} + +.divider { + width: 100%; + height: 1px; + background-color: var(--color-elements); +} + +.descriptionTitle { + display: flex; + align-items: center; + gap: 8px; + + @extend %h4-mobile; + + @include on-tablet { + font-family: Mont, sans-serif; + font-size: 20px; + line-height: 26px; + font-weight: 600; + letter-spacing: 0; + color: var(--color-primary); + } +} + +.descriptionText { + @extend %body-text; + + color: var(--color-secondary); +} diff --git a/src/modules/ProductDetailsPage/components/Description/Description.tsx b/src/modules/ProductDetailsPage/components/Description/Description.tsx new file mode 100644 index 0000000000..80e2e2bc5b --- /dev/null +++ b/src/modules/ProductDetailsPage/components/Description/Description.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import styles from './Description.module.scss'; +import classNames from 'classnames'; + +type DescriptionProps = { + description: { + map( + arg0: ( + desc: { title: string; text: string[] }, + index: number, + ) => import('react/jsx-runtime').JSX.Element, + ): React.ReactNode; + }; +}; + +export const Description: React.FC = ({ description }) => { + return ( +
+

About

+
+ + {description.map( + (desc: { title: string; text: string[] }, index: number) => ( +
+

{desc.title}

+ {desc.text.map((paragraph, idx) => ( +

+ {paragraph} +

+ ))} +
+ ), + )} +
+ ); +}; diff --git a/src/modules/ProductDetailsPage/components/Description/index.ts b/src/modules/ProductDetailsPage/components/Description/index.ts new file mode 100644 index 0000000000..2b6c4564b9 --- /dev/null +++ b/src/modules/ProductDetailsPage/components/Description/index.ts @@ -0,0 +1 @@ +export * from './Description'; diff --git a/src/modules/ProductDetailsPage/components/ImageGallery/ImageGallery.module.scss b/src/modules/ProductDetailsPage/components/ImageGallery/ImageGallery.module.scss new file mode 100644 index 0000000000..b519d4d375 --- /dev/null +++ b/src/modules/ProductDetailsPage/components/ImageGallery/ImageGallery.module.scss @@ -0,0 +1,109 @@ +@import '../../../../styles/main'; + +.imageGallery { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + gap: 16px; + margin-bottom: 24px; + + @include on-tablet { + flex-direction: row; + grid-column: 1 / 8; + } + + @include on-desktop { + grid-column: 1 / 13; + } +} + +.thumbnailContainerTablet { + display: none; + + @include on-tablet { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + + + @include on-desktop { + gap: 16px; + } + + } +} + +.mainImageContainer { + display: flex; + justify-content: center; + width: 100%; + position: relative; + + cursor: pointer; + height: 288px; + + @include middle-screen { + height: 360px; + } + + @include on-desktop { + height: 464px; + } +} + +.mainImage { + width: 100%; + height: 100%; + object-fit: contain; + position: absolute; + top: 0; + left: 0; + transition: opacity 0.5s ease; + opacity: 0; +} + +.mainImageActive { + opacity: 1; +} + +.thumbnailContainerMobile { + grid-column: 1 / -1; + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + width: 100%; + + @include on-tablet { + display: none; + } +} + +.thumbnail { + width: 50px; + height: 50px; + object-fit: contain; + cursor: pointer; + border: 1px solid var(--color-elements); + transition: transform $transition-duration ease; + + @include middle-screen { + width: 60px; + height: 60px; + } + + @include on-desktop { + width: 80px; + height: 80px; + } + + &:hover { + transform: scale(1.1); + } +} + +.selectedThumbnail { + border-color: var(--color-primary); +} + diff --git a/src/modules/ProductDetailsPage/components/ImageGallery/ImageGallery.tsx b/src/modules/ProductDetailsPage/components/ImageGallery/ImageGallery.tsx new file mode 100644 index 0000000000..9c391a7cff --- /dev/null +++ b/src/modules/ProductDetailsPage/components/ImageGallery/ImageGallery.tsx @@ -0,0 +1,69 @@ +import React, { useEffect, useState } from 'react'; +import styles from './ImageGallery.module.scss'; +import classNames from 'classnames'; +import { BASE_URL } from '../../../../utils/const'; + +type ImageGalleryProps = { + images: string[]; + productName: string; +}; + +export const ImageGallery: React.FC = ({ + images, + productName, +}) => { + const [selectedImage, setSelectedImage] = useState(images[0]); + const [activeIndex, setActiveIndex] = useState(0); + + useEffect(() => { + const index = images.indexOf(selectedImage); + + if (index !== activeIndex) { + setActiveIndex(index); + } + }, [selectedImage, images, activeIndex]); + + return ( +
+
+ {images.map(image => ( + {`${productName}`} setSelectedImage(image)} + /> + ))} +
+ +
+ {images.map((image, index) => ( + {productName} + ))} +
+
+ {images.map(image => ( + {`${productName}`} setSelectedImage(image)} + /> + ))} +
+
+ ); +}; diff --git a/src/modules/ProductDetailsPage/components/ImageGallery/index.ts b/src/modules/ProductDetailsPage/components/ImageGallery/index.ts new file mode 100644 index 0000000000..9d76db4f79 --- /dev/null +++ b/src/modules/ProductDetailsPage/components/ImageGallery/index.ts @@ -0,0 +1 @@ +export * from './ImageGallery'; diff --git a/src/modules/ProductDetailsPage/components/MainControls/MainControls.module.scss b/src/modules/ProductDetailsPage/components/MainControls/MainControls.module.scss new file mode 100644 index 0000000000..0b42683edf --- /dev/null +++ b/src/modules/ProductDetailsPage/components/MainControls/MainControls.module.scss @@ -0,0 +1,121 @@ +@import '../../../../styles/main'; + +.mainControls { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + gap: 24px; + + @include on-tablet { + grid-column: 8 / -1; + } + + @include on-desktop { + grid-column: 14 / -5; + + } +} + +.divider { + width: 100%; + height: 1px; + background-color: var(--color-elements); +} + +.selector { + display: flex; + flex-direction: column; + gap: 8px; + + @extend %small-text; + + color: var(--color-secondary); +} + +.buttons { + display: flex; + gap: 8px; +} + +.colorButtonContainer { + width: 32px; + height: 32px; + border: 2px solid var(--color-surface); + border-radius: 50%; + padding: 2px; +} + +.colorButton { + width: 100%; + height: 100%; + border-radius: 50%; + border: 0; + padding: 4px; + cursor: pointer; + +} + +.activeColorsAvailable { + border: 2px solid var(--color-primary); +} + +.capacityButton { + display: flex; + align-items: center; + justify-content: center; + padding-top: 3px; + padding-inline: 4px; + height: 32px; + border: 1px solid var(--color-surface); + background-color: transparent; + color: var(--color-primary); + cursor: pointer; + + @extend %body-text; + +} + +.active { + border: 1px solid var(--color-primary); + background-color: var(--color-primary); + color: var(--color-background); +} + +.price { + display: flex; + gap: 8px; + + @extend %h3-tablet; +} + +.existPrice { + color: var(--color-primary); +} + +.hotPrice { + display: flex; + color: var(--color-secondary); +} + +.specsList { + display: flex; + flex-direction: column; + gap: 8px; +} + +.specs { + display: flex; + justify-content: space-between; +} + +.specsKey { + @extend %body-text; + + color: var(--color-secondary); +} + +.specsValue { + @extend %body-text; + + color: var(--color-primary); +} diff --git a/src/modules/ProductDetailsPage/components/MainControls/MainControls.tsx b/src/modules/ProductDetailsPage/components/MainControls/MainControls.tsx new file mode 100644 index 0000000000..ed5b1913ea --- /dev/null +++ b/src/modules/ProductDetailsPage/components/MainControls/MainControls.tsx @@ -0,0 +1,109 @@ +import React from 'react'; +import classNames from 'classnames'; +import styles from './MainControls.module.scss'; +import { ProductDetails } from '../../../../types/ProductDetails'; +import { ActionButtons } from '../../../../components/ActionButtons'; +import { Product } from '../../../../types/Product'; +// eslint-disable-next-line max-len +import { fetchProductByColorAndCapacity } from '../../../../servises/ProductsDetails'; +import { useNavigate } from 'react-router-dom'; + +type Props = { + productDetails: ProductDetails; + setProductDetails: (productDetails: ProductDetails) => void; + product: Product; +}; + +export const MainControls: React.FC = ({ productDetails, product }) => { + const navigate = useNavigate(); + + const handleAttributeChange = async (color: string, capacity: string) => { + const newProductId = await fetchProductByColorAndCapacity( + productDetails.category, + productDetails.namespaceId, + color, + capacity, + ); + + if (newProductId) { + navigate(`/products/${newProductId}`); + } + }; + + return ( +
+
+

Available colors

+ +
+ {productDetails.colorsAvailable.map(color => ( +
+
+ ))} +
+
+ +
+ +
+

Select Capacity

+ +
+ {productDetails.capacityAvailable.map(capacity => ( + + ))} +
+
+ +
+ +
+
${productDetails.priceRegular}
+
${productDetails.priceDiscount}
+
+ + {product && } + +
    +
  • + Screen + {productDetails.screen} +
  • + +
  • + Resolution + {productDetails.resolution} +
  • + +
  • + Processor + {productDetails.processor} +
  • +
+
+ ); +}; diff --git a/src/modules/ProductDetailsPage/components/MainControls/index.ts b/src/modules/ProductDetailsPage/components/MainControls/index.ts new file mode 100644 index 0000000000..0444e1d193 --- /dev/null +++ b/src/modules/ProductDetailsPage/components/MainControls/index.ts @@ -0,0 +1 @@ +export * from './MainControls'; diff --git a/src/modules/ProductDetailsPage/components/TechSpecs/TechSpecs.module.scss b/src/modules/ProductDetailsPage/components/TechSpecs/TechSpecs.module.scss new file mode 100644 index 0000000000..4e56cd8c2f --- /dev/null +++ b/src/modules/ProductDetailsPage/components/TechSpecs/TechSpecs.module.scss @@ -0,0 +1,59 @@ +@import '../../../../styles/main'; + +.section { + grid-column: 1 / -1; + + @include on-desktop { + grid-column: 14 / -1; + } +} + +.techSpecsSection { + display: flex; + flex-direction: column; + gap: 16px; +} + +.sectionTitle { + @extend %h3-mobile; + + color: var(--color-primary); + + @include on-tablet { + font-family: Mont, sans-serif; + font-size: 22px; + line-height: 31.8px; + letter-spacing: 0; + font-weight: 800; + color: var(--color-primary); + } +} + +.divider { + width: 100%; + height: 1px; + background-color: var(--color-elements); +} + +.specsList { + display: flex; + flex-direction: column; + gap: 8px; +} + +.specs { + display: flex; + justify-content: space-between; +} + +.specsKey { + @extend %body-text; + + color: var(--color-secondary); +} + +.specsValue { + @extend %body-text; + + color: var(--color-primary); +} diff --git a/src/modules/ProductDetailsPage/components/TechSpecs/TechSpecs.tsx b/src/modules/ProductDetailsPage/components/TechSpecs/TechSpecs.tsx new file mode 100644 index 0000000000..d55ae622bd --- /dev/null +++ b/src/modules/ProductDetailsPage/components/TechSpecs/TechSpecs.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import styles from './TechSpecs.module.scss'; +import { ProductDetails } from '../../../../types/ProductDetails'; +import classNames from 'classnames'; + +type TechSpecsProps = { + productDetails: ProductDetails; +}; + +export const TechSpecs: React.FC = ({ productDetails }) => { + return ( +
+

Tech specs

+
+ +
    +
  • + Screen + {productDetails.screen} +
  • + +
  • + Resolution + {productDetails.resolution} +
  • + +
  • + Processor + {productDetails.processor} +
  • + +
  • + Ram + {productDetails.ram} +
  • + +
  • + Built in memory + {productDetails.capacity} +
  • + + {productDetails.camera && ( +
  • + Camera + {productDetails.camera} +
  • + )} + + {productDetails.zoom && ( +
  • + Zoom + {productDetails.zoom} +
  • + )} + +
  • + Cell + + {productDetails.cell.join(', ')} + +
  • +
+
+ ); +}; diff --git a/src/modules/ProductDetailsPage/components/TechSpecs/index.ts b/src/modules/ProductDetailsPage/components/TechSpecs/index.ts new file mode 100644 index 0000000000..eada3132a0 --- /dev/null +++ b/src/modules/ProductDetailsPage/components/TechSpecs/index.ts @@ -0,0 +1 @@ +export * from './TechSpecs'; diff --git a/src/modules/ProductDetailsPage/index.ts b/src/modules/ProductDetailsPage/index.ts new file mode 100644 index 0000000000..55be825664 --- /dev/null +++ b/src/modules/ProductDetailsPage/index.ts @@ -0,0 +1 @@ +export { default as ProductDetailsPage } from './ProductDetailsPage'; diff --git a/src/modules/TabletsPage/TabletsPage.scss b/src/modules/TabletsPage/TabletsPage.scss new file mode 100644 index 0000000000..c177aaf35d --- /dev/null +++ b/src/modules/TabletsPage/TabletsPage.scss @@ -0,0 +1,5 @@ +.tablets-page { + display: flex; + align-items: center; + width: 100%; +} diff --git a/src/modules/TabletsPage/TabletsPage.tsx b/src/modules/TabletsPage/TabletsPage.tsx new file mode 100644 index 0000000000..5347ae1e5b --- /dev/null +++ b/src/modules/TabletsPage/TabletsPage.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { useLocation } from 'react-router-dom'; +import { ProductsList } from '../../components/ProductsList'; + +export const TabletsPage: React.FC = () => { + const location = useLocation(); + + const category = location.pathname.split('/')[1]; + + return ; +}; diff --git a/src/modules/TabletsPage/index.ts b/src/modules/TabletsPage/index.ts new file mode 100644 index 0000000000..6988826db6 --- /dev/null +++ b/src/modules/TabletsPage/index.ts @@ -0,0 +1 @@ +export * from './TabletsPage'; diff --git a/src/servises/Products.ts b/src/servises/Products.ts new file mode 100644 index 0000000000..5c54b373ff --- /dev/null +++ b/src/servises/Products.ts @@ -0,0 +1,40 @@ +import { Product } from '../types/Product'; +import { ShuffleArray } from '../utils/ShaffleArray'; +import { getData } from '../utils/httpClient'; + +export const getAllProducts = async (): Promise => { + return getData('/api/products.json'); +}; + +export const getProductsByCategory = async (category: string) => { + const products = await getData('/api/products.json'); + + return products.filter((product: Product) => product.category === category); +}; + +export const getHotPriceProducts = async () => { + const response = await getData('/api/products.json'); + + return response.sort((a: Product, b: Product) => { + return b.fullPrice - b.price - (a.fullPrice - a.price); + }); +}; + +export const getNewProducts = async () => { + const response = await getData('/api/products.json'); + const latestYear = response.reduce( + (acc: number, product: Product) => Math.max(acc, product.year), + 0, + ); + + return response + .filter((product: Product) => product.year === latestYear) + .sort((a: Product, b: Product) => b.fullPrice - a.fullPrice); +}; + +export const getSuggestedProducts = async () => { + const products = await getData('/api/products.json'); + const suggestedProducts = ShuffleArray(products); + + return suggestedProducts; +}; diff --git a/src/servises/ProductsDetails.ts b/src/servises/ProductsDetails.ts new file mode 100644 index 0000000000..5b55022807 --- /dev/null +++ b/src/servises/ProductsDetails.ts @@ -0,0 +1,32 @@ +import { ProductDetails } from '../types/ProductDetails'; +import { getData } from '../utils/httpClient'; + +export const getProductDetails = async ( + itemId: string, + category = 'products', +): Promise => { + const categoryProducts: ProductDetails[] = await getData( + `/api/${category}.json`, + ); + const detailedProduct = categoryProducts.find(p => p.id === itemId); + + return detailedProduct ?? null; +}; + +export const fetchProductByColorAndCapacity = async ( + category: string, + namespaceId: string, + color: string, + capacity: string, +): Promise => { + const allVariants = await getData(`/api/${category}.json`); + + const matchedProduct = allVariants.find( + p => + p.namespaceId === namespaceId && + p.color === color && + p.capacity === capacity, + ); + + return matchedProduct ? matchedProduct.id : undefined; +}; diff --git a/src/servises/iconSrc.ts b/src/servises/iconSrc.ts new file mode 100644 index 0000000000..fb2b4895fb --- /dev/null +++ b/src/servises/iconSrc.ts @@ -0,0 +1,40 @@ +import { ThemeType } from '../types/ThemeType'; +import { BASE_URL } from '../utils/const'; + +const ICON_BASE_PATH = `${BASE_URL}/img/icons`; + +const getIconSrc = (iconName: string, theme: ThemeType): string => { + const themePath = theme === 'dark' ? `--DarkTheme` : ''; + + return `${ICON_BASE_PATH}/${iconName}${themePath}.svg`; +}; + +export const getLogoIconSrc = (theme: ThemeType): string => + getIconSrc('LogoIcon', theme); +export const getMenuIconSrc = ( + isMenuOpen: boolean, + theme: ThemeType, +): string => { + return getIconSrc(isMenuOpen ? 'CloseMenuIcon' : 'MenuIcon', theme); +}; + +export const getFavoritesIconSrc = (theme: ThemeType): string => + getIconSrc('FavoritesIcon', theme); + +export const getCartIconSrc = (theme: ThemeType): string => + getIconSrc('CartIcon', theme); + +export const getChevronIconSrc = (theme: ThemeType): string => + getIconSrc('ChevronIcon', theme); + +export const getHomeIconSrc = (theme: ThemeType): string => + getIconSrc('HomeIcon', theme); + +export const getRemoveIconSrc = (theme: ThemeType): string => + getIconSrc('CrossIcon', theme); + +export const getIncreaseIconSrc = (theme: ThemeType): string => + getIconSrc('PlusIcon', theme); + +export const getDecreaseIconSrc = (theme: ThemeType): string => + getIconSrc('MinusIcon', theme); diff --git a/src/styles/main.scss b/src/styles/main.scss new file mode 100644 index 0000000000..2ab6869999 --- /dev/null +++ b/src/styles/main.scss @@ -0,0 +1,4 @@ +@import './utils/typography'; +@import './utils/variables'; +@import './utils/mixins'; +@import './utils/fonts'; diff --git a/src/styles/utils/_fonts.scss b/src/styles/utils/_fonts.scss new file mode 100644 index 0000000000..69131d8c22 --- /dev/null +++ b/src/styles/utils/_fonts.scss @@ -0,0 +1,14 @@ +@font-face { + font-family: Mont; + src: url('/fonts/Mont-Regular.otf') format('opentype'); +} + +@font-face { + font-family: Mont; + src: url('/fonts/Mont-SemiBold.otf') format('opentype'); +} + +@font-face { + font-family: Mont; + src: url('/fonts/Mont-Bold.otf') format('opentype'); +} diff --git a/src/styles/utils/_mixins.scss b/src/styles/utils/_mixins.scss new file mode 100644 index 0000000000..f9b9c8e812 --- /dev/null +++ b/src/styles/utils/_mixins.scss @@ -0,0 +1,48 @@ +@import './variables'; + +@mixin on-tablet { + @media (min-width: $breakpoint-tablet) { + @content; + } +} + +@mixin on-desktop { + @media (min-width: $breakpoint-desktop) { + @content; + } +} + +@mixin middle-screen { + @media (min-width: $breakpoint-middle) { + @content; + } +} + +@mixin padding-inline { + padding-inline:16px; + + @include on-tablet { + padding-inline: 24px; + } + + @include on-desktop { + padding-inline: 0; + } +} + +@mixin section-grid { + display: grid; + grid-gap: 16px; + grid-template-columns:repeat(4, 1fr); + justify-content: center; + + @include padding-inline; + + @include on-tablet { + grid-template-columns:repeat(12, 1fr); + } + + @include on-desktop { + grid-template-columns:repeat(24, 32px); + } +} diff --git a/src/styles/utils/_typography.scss b/src/styles/utils/_typography.scss new file mode 100644 index 0000000000..467803e356 --- /dev/null +++ b/src/styles/utils/_typography.scss @@ -0,0 +1,125 @@ +@mixin h1-tablet { + font-family: Mont, sans-serif; + font-size: 48px; + line-height: 56px; + letter-spacing: -0.01em; + font-weight: bold; + color: var(--color-primary); +} + +@mixin h2-tablet { + font-family: Mont, sans-serif; + font-size: 32px; + line-height: 41px; + letter-spacing: 0.01em; + font-weight: bold; + color: var(--color-primary); +} + +%h1-tablet { + font-family: Mont, sans-serif; + font-size: 48px; + line-height: 56px; + letter-spacing: -0.01em; + font-weight: bold; + color: var(--color-primary); +} + +%h2-tablet { + font-family: Mont, sans-serif; + font-size: 32px; + line-height: 41px; + letter-spacing: 0.01em; + font-weight: bold; + color: var(--color-primary); +} + +%h3-tablet { + font-family: Mont, sans-serif; + font-size: 22px; + line-height: 31.8px; + letter-spacing: 0; + font-weight: 800; + color: var(--color-primary); +} + +%h4-tablet { + font-family: Mont, sans-serif; + font-size: 20px; + line-height: 26px; + font-weight: 600; + letter-spacing: 0; + color: var(--color-primary); +} + +%uppercase-text { + font-family: Mont, sans-serif; + font-weight: bold; + font-size: 12px; + line-height: 11px; + letter-spacing: 0.04em; + text-transform: uppercase; + text-decoration: none; +} + +%buttons { + font-family: Mont, sans-serif; + font-weight: 600; + font-size: 12px; + line-height: 11px; + letter-spacing: 0; +} + +%body-text { + font-family: Mont, sans-serif; + font-weight: 500; + font-size: 14px; + line-height: 21px; + letter-spacing: 0; +} + +%small-text { + font-family: Mont, sans-serif; + font-weight: 700; + font-size: 12px; + line-height: 15.34px; + letter-spacing: 0; +} + + + +%h1-mobile { + font-family: Mont, sans-serif; + font-size: 32px; + line-height: 41px; + letter-spacing: -0.01em; + font-weight: bold; + color: var(--color-primary); +} + +%h2-mobile { + font-family: Mont, sans-serif; + font-size: 22px; + line-height: 31px; + letter-spacing: 0; + font-weight: bold; + color: var(--color-primary); +} + +%h3-mobile { + font-family: Mont, sans-serif; + font-size: 20px; + line-height: 26px; + letter-spacing: 0; + font-weight: 600; + color: var(--color-primary); +} + +%h4-mobile { + font-family: Mont, sans-serif; + font-size: 16px; + line-height: 20px; + letter-spacing: 0; + font-weight: 600; + color: var(--color-primary); +} diff --git a/src/styles/utils/_variables.scss b/src/styles/utils/_variables.scss new file mode 100644 index 0000000000..d754427212 --- /dev/null +++ b/src/styles/utils/_variables.scss @@ -0,0 +1,40 @@ +$breakpoint-tablet: 640px; +$breakpoint-middle: 768px; +$breakpoint-desktop: 1200px; +$with-icons-desktop: 64px; +$with-icons-tablet: 48px; +$height-on-desktop: 64px; +$height-on-tablet: 48px; +$transition-duration: 0.5s; + +:root { + --color-background: #fff; + --color-surface: #B4BDC3; + --color-primary: #313237; + --color-secondary: #89939A; + --color-icons: #fff; + --color-elements: #E2E6E9; + --color-hover: #FAFBFC; + --color-red: #EB5757; + --color-button: #313237; + --color-button-hover: #313237; + --color-white: #fff; + --color-black: #000; + --color-filter-bg:#fff +} + +[data-theme="dark"] { + --color-background: #0F1121; + --color-surface: #323542; + --color-primary: #F1F2F9; + --color-secondary: #75767F; + --color-icons: #4A4D58; + --color-elements: #313237; + --color-hover: #89939A; + --color-button: #905BFF; + --color-button-hover: #A378FF; + --color-red: #EB5757; + --color-white: #fff; + --color-black: #000; + --color-filter-bg:#323542; +} diff --git a/src/types/CartItemProps.ts b/src/types/CartItemProps.ts new file mode 100644 index 0000000000..dca696f8c2 --- /dev/null +++ b/src/types/CartItemProps.ts @@ -0,0 +1,6 @@ +import { Product } from './Product'; + +export interface CartItemProps { + product: Product; + quantity: number; +} diff --git a/src/types/Filter.ts b/src/types/Filter.ts new file mode 100644 index 0000000000..c392c7781b --- /dev/null +++ b/src/types/Filter.ts @@ -0,0 +1,12 @@ +export enum FilterType { + age = 'Newest', + title = 'Alphabetically', + price = 'Cheapest', +} + +export enum ItemsPerPage { + Four = '4', + Eight = '8', + Sixteen = '16', + All = 'All', +} diff --git a/src/types/Header.ts b/src/types/Header.ts new file mode 100644 index 0000000000..0154760c02 --- /dev/null +++ b/src/types/Header.ts @@ -0,0 +1,4 @@ +export interface HeaderProps { + isMenuOpen: boolean; + setIsMenuOpen: React.Dispatch>; +} diff --git a/src/types/OverlayMenu.ts b/src/types/OverlayMenu.ts new file mode 100644 index 0000000000..1a1deb49ed --- /dev/null +++ b/src/types/OverlayMenu.ts @@ -0,0 +1,11 @@ +import { CartItemProps } from './CartItemProps'; +import { Product } from './Product'; + +export interface OverlayMenuProps { + isMenuOpen: boolean; + toggleIsMenuOpen: () => void; + favoritesIconSrc: string; + favorites: Product[]; + cartIconSrc: string; + cart: CartItemProps[]; +} diff --git a/src/types/Product.ts b/src/types/Product.ts new file mode 100644 index 0000000000..afb02cd58c --- /dev/null +++ b/src/types/Product.ts @@ -0,0 +1,15 @@ +export interface Product { + id: string; + category: string; + phoneId: 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/ProductDetails.ts b/src/types/ProductDetails.ts new file mode 100644 index 0000000000..80067fcc72 --- /dev/null +++ b/src/types/ProductDetails.ts @@ -0,0 +1,32 @@ +interface ProductDescription { + map( + arg0: ( + desc: { title: string; text: string[] }, + index: number, + ) => import('react/jsx-runtime').JSX.Element, + ): import('react').ReactNode; + title: string; + text: string[]; +} + +export interface ProductDetails { + id: string; + category: string; + namespaceId: string; + name: string; + capacityAvailable: string[]; + capacity: string; + priceRegular: number; + priceDiscount: number; + colorsAvailable: string[]; + color: string; + images: string[]; + description: ProductDescription; + screen: string; + resolution: string; + processor: string; + ram: string; + camera: string; + zoom: string; + cell: string[]; +} diff --git a/src/types/ThemeType.ts b/src/types/ThemeType.ts new file mode 100644 index 0000000000..41861f3924 --- /dev/null +++ b/src/types/ThemeType.ts @@ -0,0 +1,4 @@ +export enum ThemeType { + DARK = 'dark', + LIGHT = 'light', +} diff --git a/src/utils/ShaffleArray.ts b/src/utils/ShaffleArray.ts new file mode 100644 index 0000000000..f21a7a7d1d --- /dev/null +++ b/src/utils/ShaffleArray.ts @@ -0,0 +1,13 @@ +import { Product } from '../types/Product'; + +export const ShuffleArray = (array: Product[]) => { + const shuffledArray = array.slice(); + + for (let i = shuffledArray.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + + [shuffledArray[i], shuffledArray[j]] = [shuffledArray[j], shuffledArray[i]]; + } + + return shuffledArray; +}; diff --git a/src/utils/const.ts b/src/utils/const.ts new file mode 100644 index 0000000000..8122617b6f --- /dev/null +++ b/src/utils/const.ts @@ -0,0 +1 @@ +export const BASE_URL = `https://oleksiinesteruk.github.io/react_phone-catalog`; diff --git a/src/utils/httpClient.ts b/src/utils/httpClient.ts new file mode 100644 index 0000000000..52ba067d11 --- /dev/null +++ b/src/utils/httpClient.ts @@ -0,0 +1,7 @@ +import { BASE_URL } from './const'; + +export const getData = async (url: string): Promise => { + const response = await fetch(BASE_URL + url); + + return response.json(); +}; diff --git a/src/utils/sortProducts.ts b/src/utils/sortProducts.ts new file mode 100644 index 0000000000..520bddcc99 --- /dev/null +++ b/src/utils/sortProducts.ts @@ -0,0 +1,14 @@ +import { Product } from '../types/Product'; + +export const sortProducts = (products: Product[], sortKey: string) => { + switch (sortKey) { + case 'age': + return [...products].sort((a, b) => b.year - a.year); + case 'title': + return [...products].sort((a, b) => a.name.localeCompare(b.name)); + case 'price': + return [...products].sort((a, b) => a.price - b.price); + default: + return products; + } +}; diff --git a/tsconfig.json b/tsconfig.json index cfb168bb26..da76cc58f9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "@mate-academy/students-ts-config", "include": [ - "src" + "src", + "declarations.d.ts" ], "compilerOptions": { "sourceMap": false,