From b0f5b5a76a3769e8e76f1a07c6b76c612cd78a95 Mon Sep 17 00:00:00 2001 From: Andrii Tsopych Date: Mon, 18 May 2026 22:43:45 +0300 Subject: [PATCH 01/18] add task soultion --- package-lock.json | 154 +++++++- package.json | 6 +- public/img/arrow-left.png | Bin 0 -> 240 bytes public/img/arrow-right.png | Bin 0 -> 233 bytes public/img/arrow-up.png | Bin 0 -> 236 bytes public/img/bag.png | Bin 0 -> 451 bytes public/img/close.png | Bin 0 -> 282 bytes public/img/heart-filled.png | Bin 0 -> 368 bytes public/img/heart.png | Bin 0 -> 413 bytes public/img/home.png | Bin 0 -> 395 bytes public/img/logo.svg | 25 ++ public/img/menu.png | Bin 0 -> 251 bytes public/img/minus.png | Bin 0 -> 190 bytes public/img/plus.png | Bin 0 -> 252 bytes src/App.scss | 54 ++- src/App.tsx | 50 ++- src/api/fetchClient.ts | 15 + src/api/products.ts | 69 ++++ .../BannerSlider/BannerSlider.module.scss | 93 +++++ src/components/BannerSlider/BannerSlider.tsx | 71 ++++ .../Breadcrumbs/Breadcrumbs.module.scss | 65 +++ src/components/Breadcrumbs/Breadcrumbs.tsx | 62 +++ src/components/Footer/Footer.module.scss | 113 ++++++ src/components/Footer/Footer.tsx | 46 +++ src/components/Header/Header.module.scss | 164 ++++++++ src/components/Header/Header.tsx | 92 +++++ src/components/Header/index.ts | 0 .../MobileMenu/MobileMenu.module.scss | 107 +++++ src/components/MobileMenu/MobileMenu.tsx | 80 ++++ .../Pagination/Pagination.module.scss | 68 ++++ src/components/Pagination/Pagination.tsx | 77 ++++ .../ProductCard/ProductCard.module.scss | 186 +++++++++ src/components/ProductCard/ProductCard.tsx | 105 +++++ .../ProductsList/ProductsList.module.scss | 6 + src/components/ProductsList/ProductsList.tsx | 17 + .../ProductsSlider/ProductsSlider.module.scss | 79 ++++ .../ProductsSlider/ProductsSlider.tsx | 83 ++++ .../ShopByCategory/ShopByCategory.module.scss | 68 ++++ .../ShopByCategory/ShopByCategory.tsx | 54 +++ src/index.tsx | 15 +- src/modules/CartPage/CartPage.module.scss | 236 +++++++++++ src/modules/CartPage/CartPage.tsx | 148 +++++++ src/modules/CartPage/index.ts | 1 + .../CatalogPage/CatalogPage.module.scss | 125 ++++++ src/modules/CatalogPage/CatalogPage.tsx | 175 +++++++++ src/modules/CatalogPage/index.ts | 1 + .../FavoritesPage/FavoritesPage.module.scss | 32 ++ src/modules/FavoritesPage/FavoritesPage.tsx | 27 ++ src/modules/FavoritesPage/index.ts | 1 + src/modules/HomePage/HomePage.tsx | 48 +++ src/modules/HomePage/index.ts | 1 + src/modules/NotFoundPage/NotFoundPage.tsx | 15 + src/modules/NotFoundPage/indext.ts | 1 + .../ProductDetailsPage.module.scss | 370 ++++++++++++++++++ .../ProductDetailsPage/ProductDetailsPage.tsx | 276 +++++++++++++ src/modules/ProductDetailsPage/index.ts | 1 + src/store/cartSlice.ts | 62 +++ src/store/favoritesSlice.ts | 43 ++ src/store/hooks.ts | 5 + src/store/index.ts | 20 + src/styles/_mixins.scss | 13 + src/styles/_variables.scss | 19 + src/types/CartItem.ts | 7 + src/types/Product.ts | 37 ++ 64 files changed, 3659 insertions(+), 29 deletions(-) create mode 100644 public/img/arrow-left.png create mode 100644 public/img/arrow-right.png create mode 100644 public/img/arrow-up.png create mode 100644 public/img/bag.png create mode 100644 public/img/close.png create mode 100644 public/img/heart-filled.png create mode 100644 public/img/heart.png create mode 100644 public/img/home.png create mode 100644 public/img/logo.svg create mode 100644 public/img/menu.png create mode 100644 public/img/minus.png create mode 100644 public/img/plus.png create mode 100644 src/api/fetchClient.ts create mode 100644 src/api/products.ts create mode 100644 src/components/BannerSlider/BannerSlider.module.scss create mode 100644 src/components/BannerSlider/BannerSlider.tsx create mode 100644 src/components/Breadcrumbs/Breadcrumbs.module.scss create mode 100644 src/components/Breadcrumbs/Breadcrumbs.tsx create mode 100644 src/components/Footer/Footer.module.scss create mode 100644 src/components/Footer/Footer.tsx create mode 100644 src/components/Header/Header.module.scss create mode 100644 src/components/Header/Header.tsx create mode 100644 src/components/Header/index.ts create mode 100644 src/components/MobileMenu/MobileMenu.module.scss create mode 100644 src/components/MobileMenu/MobileMenu.tsx create mode 100644 src/components/Pagination/Pagination.module.scss create mode 100644 src/components/Pagination/Pagination.tsx create mode 100644 src/components/ProductCard/ProductCard.module.scss create mode 100644 src/components/ProductCard/ProductCard.tsx create mode 100644 src/components/ProductsList/ProductsList.module.scss create mode 100644 src/components/ProductsList/ProductsList.tsx create mode 100644 src/components/ProductsSlider/ProductsSlider.module.scss create mode 100644 src/components/ProductsSlider/ProductsSlider.tsx create mode 100644 src/components/ShopByCategory/ShopByCategory.module.scss create mode 100644 src/components/ShopByCategory/ShopByCategory.tsx create mode 100644 src/modules/CartPage/CartPage.module.scss create mode 100644 src/modules/CartPage/CartPage.tsx create mode 100644 src/modules/CartPage/index.ts create mode 100644 src/modules/CatalogPage/CatalogPage.module.scss create mode 100644 src/modules/CatalogPage/CatalogPage.tsx create mode 100644 src/modules/CatalogPage/index.ts create mode 100644 src/modules/FavoritesPage/FavoritesPage.module.scss create mode 100644 src/modules/FavoritesPage/FavoritesPage.tsx create mode 100644 src/modules/FavoritesPage/index.ts create mode 100644 src/modules/HomePage/HomePage.tsx create mode 100644 src/modules/HomePage/index.ts create mode 100644 src/modules/NotFoundPage/NotFoundPage.tsx create mode 100644 src/modules/NotFoundPage/indext.ts create mode 100644 src/modules/ProductDetailsPage/ProductDetailsPage.module.scss create mode 100644 src/modules/ProductDetailsPage/ProductDetailsPage.tsx create mode 100644 src/modules/ProductDetailsPage/index.ts create mode 100644 src/store/cartSlice.ts create mode 100644 src/store/favoritesSlice.ts create mode 100644 src/store/hooks.ts create mode 100644 src/store/index.ts create mode 100644 src/styles/_mixins.scss create mode 100644 src/styles/_variables.scss create mode 100644 src/types/CartItem.ts create mode 100644 src/types/Product.ts diff --git a/package-lock.json b/package-lock.json index 836b9e63b46..5d2ace399ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,16 +11,18 @@ "license": "GPL-3.0", "dependencies": { "@fortawesome/fontawesome-free": "^6.5.2", + "@reduxjs/toolkit": "^2.11.2", "bulma": "^1.0.1", "classnames": "^2.5.1", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.25.1", + "react-redux": "^9.2.0", + "react-router-dom": "^6.30.3", "react-transition-group": "^4.4.5" }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^2.1.3", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", @@ -1184,10 +1186,11 @@ } }, "node_modules/@mate-academy/scripts": { - "version": "1.8.5", - "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.8.5.tgz", - "integrity": "sha512-mHRY2FkuoYCf5U0ahIukkaRo5LSZsxrTSgMJheFoyf3VXsTvfM9OfWcZIDIDB521kdPrScHHnRp+JRNjCfUO5A==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-2.1.3.tgz", + "integrity": "sha512-a07wHTj/1QUK2Aac5zHad+sGw4rIvcNl5lJmJpAD7OxeSbnCdyI6RXUHwXhjF5MaVo9YHrJ0xVahyERS2IIyBQ==", "dev": true, + "license": "MIT", "dependencies": { "@octokit/rest": "^17.11.2", "@types/get-port": "^4.2.0", @@ -1875,10 +1878,37 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@remix-run/router": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.18.0.tgz", - "integrity": "sha512-L3jkqmqoSVBVKHfpGZmLrex0lxR5SucGA0sUfFzGctehw+S/ggL9L/0NnC5mw6P8HUWpFZ3nQw3cRApjjWx9Sw==", + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", "engines": { "node": ">=14.0.0" } @@ -2126,6 +2156,18 @@ "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", "dev": true }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2216,13 +2258,13 @@ "version": "15.7.12", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", - "dev": true + "devOptional": true }, "node_modules/@types/react": { "version": "18.3.3", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz", "integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==", - "dev": true, + "devOptional": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -2258,6 +2300,12 @@ "integrity": "sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg==", "dev": true }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -3265,7 +3313,8 @@ "node_modules/classnames": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", - "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" }, "node_modules/clean-stack": { "version": "2.2.0", @@ -5936,6 +5985,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/immutable": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.6.tgz", @@ -8734,6 +8793,29 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", @@ -8744,11 +8826,12 @@ } }, "node_modules/react-router": { - "version": "6.25.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.25.1.tgz", - "integrity": "sha512-u8ELFr5Z6g02nUtpPAggP73Jigj1mRePSwhS/2nkTrlPU5yEkH1vYzWNyvSnSzeeE2DNqWdH+P8OhIh9wuXhTw==", + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", "dependencies": { - "@remix-run/router": "1.18.0" + "@remix-run/router": "1.23.2" }, "engines": { "node": ">=14.0.0" @@ -8758,12 +8841,13 @@ } }, "node_modules/react-router-dom": { - "version": "6.25.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.25.1.tgz", - "integrity": "sha512-0tUDpbFvk35iv+N89dWNrJp+afLgd+y4VtorJZuOCXK0kkCWjEvb3vTJM++SYvMEpbVwXKf3FjeVveVEb6JpDQ==", + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", "dependencies": { - "@remix-run/router": "1.18.0", - "react-router": "6.25.1" + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" }, "engines": { "node": ">=14.0.0" @@ -8893,6 +8977,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.6.tgz", @@ -8976,6 +9075,12 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", "dev": true }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -10438,6 +10543,15 @@ "requires-port": "^1.0.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index ae251685c8b..72a4c65c120 100644 --- a/package.json +++ b/package.json @@ -7,16 +7,18 @@ "license": "GPL-3.0", "dependencies": { "@fortawesome/fontawesome-free": "^6.5.2", + "@reduxjs/toolkit": "^2.11.2", "bulma": "^1.0.1", "classnames": "^2.5.1", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.25.1", + "react-redux": "^9.2.0", + "react-router-dom": "^6.30.3", "react-transition-group": "^4.4.5" }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^2.1.3", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", diff --git a/public/img/arrow-left.png b/public/img/arrow-left.png new file mode 100644 index 0000000000000000000000000000000000000000..28affdda9cd79da6a07b43522848761105a0e36f GIT binary patch literal 240 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#S9GG!XV7ZFl&wkP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBK3|DzL`iUdT1k0gQ7VI5W_oVoyp7Y6fil^i zE{-7;j87+6^ED_4xUidSG^%p$KOop+{$Yhq6a%Zqu_ECqF5I``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBK3|DzL`iUdT1k0gQ7VI5W_oVoyp7Y6fifwc zE{-7;jCUuUNib;p6g&DQDmE zY0)PSnM^L$UeLHkA*&(d=VXuH>VH;jxL>&F`<1KfUtTOYbo|+yvu8y;mro3uKauSL YgR)*wlrT$yCD1+wPgg&ebxsLQ02;hev;Y7A literal 0 HcmV?d00001 diff --git a/public/img/arrow-up.png b/public/img/arrow-up.png new file mode 100644 index 0000000000000000000000000000000000000000..6ed660ad6e892df7b9dfda6bae4d32db94b41046 GIT binary patch literal 236 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#S9GG!XV7ZFl&wkP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBK3|DzL`iUdT1k0gQ7VI5W_oVoyp7Y6fimfy zE{-7;jBh6#{$KJ@JZ$oo6!&}O-Vq1d%Z@EmUF;R~ bFrH6%UhF|1(Ide?D;Ydp{an^LB{Ts5H}OvE literal 0 HcmV?d00001 diff --git a/public/img/bag.png b/public/img/bag.png new file mode 100644 index 0000000000000000000000000000000000000000..96a2ea26acf4a2b8e3b12b52437dec18cf33d096 GIT binary patch literal 451 zcmV;!0X+VRP);Hnzbi z#0C-UJ^~^NsT|)ah4D!sPO=Z3WI>wJ?N8t+qM1(i0q1T`D$x4y1V~FF@VNpX`YHgj z5pd3>ET+@Rn{lKeheM%|*b6?Mh};5%fap}V(F%DtS7R3j2)FUx=%O1;lKdgoSUMI-v72vn9ZHRSp0Zbz#Y*dJS^0Qy3vS+5*OvDy zK@BL#6;iyg`dZn1?7GZH&zEv95-4Sbyt&`2R|Nb~{*Rz~VM9R#Fl+RB1_H&Zq|)OD tLPJSV%Ex+d9Mx|G3G+edVzO?2;}>lagcQ{|oBsd+002ovPDHLkV1oC*wekP} literal 0 HcmV?d00001 diff --git a/public/img/close.png b/public/img/close.png new file mode 100644 index 0000000000000000000000000000000000000000..0a30e214f99f23a10c0c69a9da1d81d5aaf7526c GIT binary patch literal 282 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#S9GG!XV7ZFl&wkP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBK3|DzL`iUdT1k0gQ7VI5W_oVoyp7Y6fihD) zT^vIy81GK<6>2izX}$cSN3Y~Tn;c{QfePlmnel~=kpe5Zd8attoD}*g?*8@pniuz- z+WC%cOXw;7xQNB3*P0EcrkoM}P%0z0<)H98?T zXYHc%w_;_h-}!DjY_BWMR1*5?T>Xh3^JTs!@TIgqiHkY6UUR{Yd2$uouL(ro3a@48 XshM3}pYrk~(18q|u6{1-oD!M``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBK3|DzL`iUdT1k0gQ7VI5W_oVoyp7Y6fikZ> zT^vIy7`IN^$lDYk(Dpv|$|)z|nWsg|;9^H0$D$&w2;`-;J$cuvdyZT51%Swa@-(kpn}z-P(2Q);E`b%P>)K>9r5*II*gF zUe4@is$zGJJzgo`RCLqpR;0?r`5#v-NP3s9f^GMS7t@W1JfqavH(H;!v5UD7Dc)XbzC-WzbW&mx*+^&>b3W$nw(dk zE3@u?Wq#jd(nEg7i7K+^M4otVI<&KCi;8|s$J$lh4lMtlaE5jy{$2Ce&l~7%22WQ% Jmvv4FO#pEekKO

4%P){MzKz>jl6QW2GqlqXs8-M#mGcP9ELxW5>)*Gj1rj5V{`GN_2-=qc2y z>iycoXCP>)7170IzFP6yVBlPndLUiZ!W!MTq+Q|)iY`EL^wv>e=|Z5>}l zx3mDIJG{ymTP3sgb1ef{2dxr^X9=~nhs|7V;L5_ar_YxzQ8f?kvfOc5zaeni<%;H~ zYq!}+lR+%p)2&UI{3)I?nJqqLvRFJeIZHg4biZ?+crLyGVwVFIgF0Ir00000NkvXX Hu0mjfM*gMk literal 0 HcmV?d00001 diff --git a/public/img/home.png b/public/img/home.png new file mode 100644 index 0000000000000000000000000000000000000000..56d817af16f3b0f391ac0e9553e29f312de26406 GIT binary patch literal 395 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#S9GG!XV7ZFl&wkP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBK3|DzL`iUdT1k0gQ7VI5W_oVoyp7Y6fx6f{ zT^vIy7|#a1?LB0`Q<_m)erX|>S_7{{W8VR`4o-eFfQ@=o!Mnu!G7#X^3t_ej(I)P@7#Lsxr&SIW!c-22e;0( z72TAYx+(Xf>CUp$bB0A**2`-4@jH8UY&p4Tm5QF9tJ2cV8lImR@0^dmVN#~3TJ}md zslE7)$IUNR#;hlQaY;Ez?oQ$@-p|7&@amc%Q`>f>s{5=5&tEFs@nqX7D+|N6rnuJA z9PhcOeOM@VW#eD##IRdyow)6eh>I@pe`PMJcpx>LMSf4Ky3N}MtQ_a1S^xiYo5C0u m`avOQiQT`oyJNSTr^kO=yPLOjSt~HC7(8A5T-G@yGywo#iIsZ* literal 0 HcmV?d00001 diff --git a/public/img/logo.svg b/public/img/logo.svg new file mode 100644 index 00000000000..7ad0b0efc03 --- /dev/null +++ b/public/img/logo.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/img/menu.png b/public/img/menu.png new file mode 100644 index 0000000000000000000000000000000000000000..0f19532f0daf760f7887d18f030466a32a06e81f GIT binary patch literal 251 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#S9GG!XV7ZFl&wkP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBK3|DzL`iUdT1k0gQ7VI5W_oVoyp7Y6fih*D zE{-7;jBh6%``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBK3|DzL`iUdT1k0gQ7VI5W_oVoyp7Y6fijk! zE{-7;jBn2@6l74~alUBg@O~HT>_e-30y>`xaLCO1wlZI6(FaDmL^ExnTc``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBK3|DzL`iUdT1k0gQ7VI5W_oVoyp7Y6fimTu zE{-7;jCUv9=WI|AIQD)G%kmY0QA<6uCWmECRhv*Xk!RC8#SI6XUmX0ve(Lz$_KJFj zy54Kc43Ax%v;4`VMFM;8>Ymhl(b&JFo+00nck!&hONuYVnJYYcay(U)eQAN(-wdUF t|BG23ZPa;fcIlywq)(%2+SJR%Oh^0`Uii9Xc>tZj;OXk;vd$@?2>@_0TZRAt literal 0 HcmV?d00001 diff --git a/src/App.scss b/src/App.scss index 71bc413aade..6641eaf2c78 100644 --- a/src/App.scss +++ b/src/App.scss @@ -1 +1,53 @@ -// not empty +@import './styles/variables'; + +@font-face { + font-family: 'Mont'; + src: url('/fonts/Mont-Regular.otf') format('opentype'); + font-weight: 400; +} +@font-face { + font-family: 'Mont'; + src: url('/fonts/Mont-SemiBold.otf') format('opentype'); + font-weight: 600; +} +@font-face { + font-family: 'Mont'; + src: url('/fonts/Mont-Bold.otf') format('opentype'); + font-weight: 700; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + background-color: $c-bg-dark; + color: $c-text-primary; + font-family: $font-family; + -webkit-font-smoothing: antialiased; +} + +.App { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.main-content { + flex: 1; +} + +.visually-hidden { + 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/App.tsx b/src/App.tsx index 372e4b42066..101f332a3d6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,47 @@ +import { Routes, Route, Navigate } from 'react-router-dom'; +import { HomePage } from './modules/HomePage'; +import { CatalogPage } from './modules/CatalogPage'; +import { ProductDetailsPage } from './modules/ProductDetailsPage'; +import { CartPage } from './modules/CartPage'; +import { FavoritesPage } from './modules/FavoritesPage'; +import { NotFoundPage } from './modules/NotFoundPage/NotFoundPage'; import './App.scss'; +import { Header } from './components/Header/Header'; +import { Footer } from './components/Footer/Footer'; -export const App = () => ( -

-

Product Catalog

-
-); +export const App = () => { + return ( +
+
+ +
+ + } /> + } /> + + + } /> + } /> + + + + } /> + } /> + + + + } /> + } /> + + + } /> + } /> + + } /> + +
+ +
+
+ ); +}; diff --git a/src/api/fetchClient.ts b/src/api/fetchClient.ts new file mode 100644 index 00000000000..3e09068669e --- /dev/null +++ b/src/api/fetchClient.ts @@ -0,0 +1,15 @@ +// src/api/fetchClient.ts +const BASE_URL = '/api'; + +export const client = { + // Дженерик означає, що функція поверне дані того типу, який ми попросимо + async get(url: string): Promise { + const response = await fetch(`${BASE_URL}${url}`); + + if (!response.ok) { + throw new Error(`Failed to fetch: ${response.statusText}`); + } + + return response.json(); + }, +}; diff --git a/src/api/products.ts b/src/api/products.ts new file mode 100644 index 00000000000..a5737f13e32 --- /dev/null +++ b/src/api/products.ts @@ -0,0 +1,69 @@ +// src/api/products.ts +import { client } from './fetchClient'; +import { Product, ProductDetails } from '../types/Product'; + +// Отримати всі продукти (з products.json) +export const getProducts = () => { + return client.get('/products.json'); +}; + +// ДОДАНО: Отримати продукти за конкретною категорією +export const getProductsByCategory = (category: string) => { + // category буде 'phones', 'tablets' або 'accessories' + // Тому ми динамічно формуємо шлях: /phones.json + return client.get(`/${category}.json`); +}; + +// Пізніше ми додамо сюди функції: +// - getProductsByCategory (читатиме phones.json, tablets.json) +// - getProductById (для сторінки деталей) +// - getSuggestedProducts (для рандомних товарів) + +export const getProductDetails = async (productId: string) => { + // Завантажуємо всі файли категорій паралельно + const [phones, tablets, accessories] = await Promise.all([ + client.get('/phones.json').catch(() => []), + client.get('/tablets.json').catch(() => []), + client.get('/accessories.json').catch(() => []), + ]); + + // Об'єднуємо їх в один великий масив + const allProducts = [...phones, ...tablets, ...accessories]; + + // Шукаємо товар за його ID + const product = allProducts.find( + p => p.id === productId || p.itemId === productId, + ); + + if (!product) { + throw new Error('Product not found'); + } + + return product; +}; + +// Функція для рекомендованих товарів (просто беремо всі і мішаємо) +export const getSuggestedProducts = async () => { + const allProducts = await client.get('/products.json'); + + return [...allProducts].sort(() => Math.random() - 0.5).slice(0, 8); +}; + +export const getHotProducts = async () => { + const products = await client.get('/products.json'); + + return products.sort((a, b) => { + // Рахуємо суму знижки для кожного товару + const discountA = (a.fullPrice || 0) - (a.price || 0); + const discountB = (b.fullPrice || 0) - (b.price || 0); + + return discountB - discountA; // Від найбільшої знижки до найменшої + }); +}; + +export const getBrandNewProducts = async () => { + const products = await client.get('/products.json'); + + // Сортуємо за роком (або ціною, якщо рік однаковий) + return products.sort((a, b) => (b.year || 0) - (a.year || 0)); +}; diff --git a/src/components/BannerSlider/BannerSlider.module.scss b/src/components/BannerSlider/BannerSlider.module.scss new file mode 100644 index 00000000000..cc5262636ef --- /dev/null +++ b/src/components/BannerSlider/BannerSlider.module.scss @@ -0,0 +1,93 @@ +@import '../../styles/variables'; +@import '../../styles/mixins'; + +.banner { + width: 100%; + margin-bottom: 80px; + display: flex; + flex-direction: column; + align-items: center; + + &__container { + display: flex; + width: 100%; + height: 200px; + margin-bottom: 16px; + gap: 16px; + + @include tablet { + height: 400px; + } + } + + &__btn { + display: none; + + @include tablet { + display: flex; + width: 32px; + height: 100%; + background-color: $c-selected; + border: 1px solid transparent; + justify-content: center; + align-items: center; + cursor: pointer; + flex-shrink: 0; + transition: border-color 0.3s, background-color 0.3s; + + img { + width: 16px; + height: 16px; + opacity: 0.5; + transition: opacity 0.3s; + } + + &:hover { + border-color: $c-border; + background-color: #4A4D58; + + img { + opacity: 1; + } + } + } + } + + &__window { + flex: 1; + height: 100%; + overflow: hidden; + } + + &__track { + display: flex; + height: 100%; + transition: transform 0.5s ease-in-out; + } + + &__image { + width: 100%; + height: 100%; + flex-shrink: 0; + object-fit: cover; + } + + &__dots { + display: flex; + gap: 16px; + } + + &__dot { + width: 14px; + height: 4px; + background-color: $c-border; + border: none; + cursor: pointer; + transition: all 0.3s; + + &--active { + width: 24px; + background-color: $c-text-primary; + } + } +} diff --git a/src/components/BannerSlider/BannerSlider.tsx b/src/components/BannerSlider/BannerSlider.tsx new file mode 100644 index 00000000000..01e648d89dc --- /dev/null +++ b/src/components/BannerSlider/BannerSlider.tsx @@ -0,0 +1,71 @@ +import { useState, useEffect } from 'react'; +import cn from 'classnames'; +import styles from './BannerSlider.module.scss'; + +const BANNERS = [ + '/img/banner-phones.png', + '/img/banner-tablets.png', + '/img/banner-accessories.png', +]; + +export const BannerSlider = () => { + const [currentIndex, setCurrentIndex] = useState(0); + + const goNext = () => { + setCurrentIndex(prev => (prev === BANNERS.length - 1 ? 0 : prev + 1)); + }; + + const goPrev = () => { + setCurrentIndex(prev => (prev === 0 ? BANNERS.length - 1 : prev - 1)); + }; + + useEffect(() => { + const interval = setInterval(goNext, 5000); + + return () => clearInterval(interval); + }, []); + + return ( +
+
+ + +
+
+ {BANNERS.map((banner, index) => ( + {`Banner + ))} +
+
+ + +
+ +
+ {BANNERS.map((_, index) => ( +
+
+ ); +}; diff --git a/src/components/Breadcrumbs/Breadcrumbs.module.scss b/src/components/Breadcrumbs/Breadcrumbs.module.scss new file mode 100644 index 00000000000..221bdf90f90 --- /dev/null +++ b/src/components/Breadcrumbs/Breadcrumbs.module.scss @@ -0,0 +1,65 @@ +@import '../../styles/variables'; +@import '../../styles/mixins'; + +.breadcrumbs { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 24px; + height: 16px; + + &__link { + display: flex; + align-items: center; + text-decoration: none; + color: $c-text-secondary; + font-family: inherit; + font-size: 12px; + font-weight: 700; + transition: color 0.3s ease; + + &:hover { + color: $c-text-primary; + + img { + opacity: 1; + } + } + } + + &__current { + color: $c-text-secondary; + font-family: inherit; + font-size: 12px; + font-weight: 700; + + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 150px; + + @include tablet { + max-width: 300px; + } + + @include desktop { + max-width: 500px; + } + } + + &__icon { + width: 16px; + height: 16px; + display: block; + object-fit: contain; + transition: opacity 0.3s ease; + } + + &__divider { + width: 16px; + height: 16px; + display: block; + object-fit: contain; + opacity: 0.5; + } +} diff --git a/src/components/Breadcrumbs/Breadcrumbs.tsx b/src/components/Breadcrumbs/Breadcrumbs.tsx new file mode 100644 index 00000000000..9fa93f9179d --- /dev/null +++ b/src/components/Breadcrumbs/Breadcrumbs.tsx @@ -0,0 +1,62 @@ +import { Link } from 'react-router-dom'; +import styles from './Breadcrumbs.module.scss'; + +interface Props { + category: string; + productName?: string; +} + +export const Breadcrumbs = ({ category, productName }: Props) => { + const formatCategory = (str: string) => { + if (!str) { + return ''; + } + + return str.charAt(0).toUpperCase() + str.slice(1); + }; + + return ( + + ); +}; diff --git a/src/components/Footer/Footer.module.scss b/src/components/Footer/Footer.module.scss new file mode 100644 index 00000000000..df1a5f8f650 --- /dev/null +++ b/src/components/Footer/Footer.module.scss @@ -0,0 +1,113 @@ +@import '../../styles/variables'; +@import '../../styles/mixins'; + +.footer { + border-top: 1px solid $c-border; + background-color: $c-bg-dark; +} + +.content { + max-width: 1200px; + margin: 0 auto; + padding: 32px 16px; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 32px; + + @include tablet { + flex-direction: row; + align-items: center; + justify-content: space-between; + height: 96px; + padding: 0 16px; + } +} + +.logo { + display: flex; + align-items: center; +} + +.links { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 16px; + + @include tablet { + flex-direction: row; + align-items: center; + } + + @include desktop { + gap: 100px; + } +} + +.link { + font-family: inherit; + font-weight: 800; + font-size: 12px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: $c-text-secondary; + text-decoration: none; + transition: color 0.3s ease; + + &:hover { + color: $c-text-primary; + } +} + +.back { + display: flex; + align-items: center; + gap: 16px; + cursor: pointer; + align-self: center; + + @include tablet { + align-self: auto; + } + + &:hover .back_btn { + background-color: #4A4D58; + border-color: $c-border; + + img { + opacity: 1; + } + } + + &:hover .back_text { + color: $c-text-primary; + } +} + +.back_text { + font-family: inherit; + font-size: 12px; + font-weight: 600; + color: $c-text-secondary; + transition: color 0.3s ease; +} + +.back_btn { + width: 32px; + height: 32px; + background-color: #323542; + border: 1px solid transparent; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: border-color 0.3s ease, background-color 0.3s ease; + + img { + width: 16px; + height: 16px; + opacity: 0.5; + transition: opacity 0.3s ease; + } +} diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 00000000000..d2e5fb0dfd0 --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,46 @@ +import { Link } from 'react-router-dom'; +import styles from './Footer.module.scss'; + +export const Footer = () => { + const scrollToTop = () => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + return ( +
+
+ + Nice Gadgets Logo + + + + +
+ Back to top + +
+
+
+ ); +}; diff --git a/src/components/Header/Header.module.scss b/src/components/Header/Header.module.scss new file mode 100644 index 00000000000..679c4d8433c --- /dev/null +++ b/src/components/Header/Header.module.scss @@ -0,0 +1,164 @@ +@import '../../styles/variables'; +@import '../../styles/mixins'; + +.header { + position: sticky; + top: 0; + z-index: 100; + background-color: $c-bg-dark; + height: 48px; + border-bottom: 1px solid $c-border; + display: flex; + align-items: center; + justify-content: space-between; + + @include desktop { + height: 64px; + } + + &__left { + display: flex; + align-items: center; + height: 100%; + padding-left: 16px; + + @include tablet { + padding-left: 24px; + } + + @include desktop { + padding-left: 32px; + } + } + + &__logo { + display: flex; + align-items: center; + height: 100%; + margin-right: 32px; + + @include desktop { + margin-right: 64px; + } + + img { + height: 28px; + } + } + + &__nav { + display: none; + height: 100%; + + @include tablet { + display: flex; + gap: 32px; + } + } + + &__link { + text-decoration: none; + color: $c-text-secondary; + font-weight: 800; + font-size: 12px; + letter-spacing: 0.04em; + text-transform: uppercase; + display: flex; + align-items: center; + height: 100%; + border-bottom: 3px solid transparent; + padding-top: 3px; + transition: color 0.3s ease; + + &:hover { + color: $c-text-primary; + } + + &--active { + color: $c-text-primary; + border-bottom: 3px solid $c-text-primary; + } + } + + &__right { + display: none; + height: 100%; + + @include tablet { + display: flex; + } + } + + &__icon-wrapper { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 100%; + border-left: 1px solid $c-border; + color: $c-text-primary; + text-decoration: none; + transition: background-color 0.3s; + + @include desktop { + width: 64px; + } + + &:hover { + background-color: $c-selected; + } + + img { + width: 16px; + height: 16px; + } + } + + &__badge { + position: absolute; + top: 10px; + right: 10px; + background-color: $c-accent-red; + color: #fff; + font-size: 10px; + font-weight: bold; + width: 14px; + height: 14px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + + @include desktop { + top: 16px; + right: 16px; + } + } + + &__menu-btn { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 100%; + background: transparent; + border: none; + border-left: 1px solid $c-border; + cursor: pointer; + transition: background-color 0.3s; + + @include tablet { + display: none; + } + + &:hover { + background-color: $c-selected; + } + + img { + width: 16px; + height: 16px; + } + } +} diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 00000000000..54e60d2b460 --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,92 @@ +import { useEffect, useState } from 'react'; +import { Link, NavLink, useLocation } from 'react-router-dom'; +import cn from 'classnames'; +import { useAppSelector } from '../../store/hooks'; +import styles from './Header.module.scss'; +import { MobileMenu } from '../MobileMenu/MobileMenu'; + +const NAV_LINKS = [ + { to: '/', text: 'Home' }, + { to: '/phones', text: 'Phones' }, + { to: '/tablets', text: 'Tablets' }, + { to: '/accessories', text: 'Accessories' }, +]; + +const getLinkClass = ({ isActive }: { isActive: boolean }) => + cn(styles.header__link, { + [styles['header__link--active']]: isActive, + }); + +export const Header = () => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const location = useLocation(); + + const cartItems = useAppSelector(state => state.cart.items); + const favoritesItems = useAppSelector(state => state.favorites.items); + + const cartTotalQuantity = cartItems.reduce( + (total, item) => total + item.quantity, + 0, + ); + const favTotalQuantity = favoritesItems.length; + + useEffect(() => { + if (isMenuOpen) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + + return () => { + document.body.style.overflow = ''; + }; + }, [isMenuOpen]); + + useEffect(() => { + setIsMenuOpen(false); + }, [location.pathname]); + + return ( +
+
+ + Nice Gadgets + + + +
+ +
+ + Favorites + {favTotalQuantity > 0 && ( + {favTotalQuantity} + )} + + + + Cart + {cartTotalQuantity > 0 && ( + {cartTotalQuantity} + )} + +
+ + + + setIsMenuOpen(false)} /> +
+ ); +}; diff --git a/src/components/Header/index.ts b/src/components/Header/index.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/components/MobileMenu/MobileMenu.module.scss b/src/components/MobileMenu/MobileMenu.module.scss new file mode 100644 index 00000000000..7cc8761eb47 --- /dev/null +++ b/src/components/MobileMenu/MobileMenu.module.scss @@ -0,0 +1,107 @@ +@import '../../styles/variables'; +@import '../../styles/mixins'; + +.menu { + position: fixed; + top: 48px; + left: 0; + width: 100%; + height: calc(100vh - 48px); + background-color: $c-bg-dark; + z-index: 99; + display: flex; + flex-direction: column; + transform: translateX(100%); + transition: transform 0.3s ease-in-out; + + &--open { + transform: translateX(0); + } + + @include tablet { + display: none; + } + + &__nav { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + padding-top: 24px; + gap: 32px; + } + + &__link { + font-family: inherit; + font-weight: 800; + font-size: 14px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: $c-text-secondary; + text-decoration: none; + padding-bottom: 4px; + border-bottom: 3px solid transparent; + transition: color 0.3s; + + &:hover { + color: $c-text-primary; + } + + &--active { + color: $c-text-primary; + border-bottom: 3px solid $c-text-primary; + } + } + + &__footer { + display: flex; + height: 64px; + border-top: 1px solid $c-border; + } + + &__icon_wrap { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + position: relative; + border-right: 1px solid $c-border; + border-bottom: 3px solid transparent; + text-decoration: none; + transition: background-color 0.3s; + + &:last-child { + border-right: none; + } + + &:hover { + background-color: lighten($c-bg-dark, 5%); + } + + &--active { + border-bottom: 3px solid $c-text-primary; + } + + img { + width: 16px; + height: 16px; + } + } + + &__badge { + position: absolute; + top: 16px; + right: calc(50% - 20px); + background-color: $c-accent-red; + color: #fff; + font-size: 10px; + font-weight: bold; + width: 14px; + height: 14px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + } +} diff --git a/src/components/MobileMenu/MobileMenu.tsx b/src/components/MobileMenu/MobileMenu.tsx new file mode 100644 index 00000000000..95ca8c113c2 --- /dev/null +++ b/src/components/MobileMenu/MobileMenu.tsx @@ -0,0 +1,80 @@ +import { NavLink } from 'react-router-dom'; +import cn from 'classnames'; +import { useAppSelector } from '../../store/hooks'; +import styles from './MobileMenu.module.scss'; + +interface Props { + isOpen: boolean; + onClose: () => void; +} + +const NAV_LINKS = [ + { to: '/', text: 'Home' }, + { to: '/phones', text: 'Phones' }, + { to: '/tablets', text: 'Tablets' }, + { to: '/accessories', text: 'Accessories' }, +]; + +export const MobileMenu = ({ isOpen, onClose }: Props) => { + const cartItems = useAppSelector(state => state.cart.items); + const favoritesItems = useAppSelector(state => state.favorites.items); + + const cartTotalQuantity = cartItems.reduce( + (total, item) => total + item.quantity, + 0, + ); + const favTotalQuantity = favoritesItems.length; + + return ( + + ); +}; diff --git a/src/components/Pagination/Pagination.module.scss b/src/components/Pagination/Pagination.module.scss new file mode 100644 index 00000000000..d60b7ae7e78 --- /dev/null +++ b/src/components/Pagination/Pagination.module.scss @@ -0,0 +1,68 @@ +@import '../../styles/variables'; + +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 16px; + margin-top: 40px; + + &__list { + display: flex; + list-style: none; + gap: 8px; + } + + &__button { + width: 32px; + height: 32px; + display: flex; + justify-content: center; + align-items: center; + background-color: $c-bg-elements; + color: $c-text-primary; + border: 1px solid $c-border; + font-family: inherit; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + + &:hover:not(:disabled) { + border-color: $c-text-secondary; + } + + &--active { + background-color: $c-text-primary; + color: $c-bg-dark; + border-color: $c-text-primary; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + + &__arrow { + width: 32px; + height: 32px; + display: flex; + justify-content: center; + align-items: center; + background-color: $c-bg-elements; + color: $c-text-primary; + border: 1px solid $c-border; + cursor: pointer; + transition: border-color 0.3s; + + &:hover:not(:disabled) { + border-color: $c-text-secondary; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } +} diff --git a/src/components/Pagination/Pagination.tsx b/src/components/Pagination/Pagination.tsx new file mode 100644 index 00000000000..414177acb71 --- /dev/null +++ b/src/components/Pagination/Pagination.tsx @@ -0,0 +1,77 @@ +import cn from 'classnames'; +import styles from './Pagination.module.scss'; + +interface Props { + totalItems: number; + itemsPerPage: number; + currentPage: number; + onPageChange: (page: number) => void; +} + +export const Pagination = ({ + totalItems, + itemsPerPage, + currentPage, + onPageChange, +}: Props) => { + const totalPages = Math.ceil(totalItems / itemsPerPage); + + if (totalPages <= 1) { + return null; + } + + const MAX_VISIBLE_PAGES = 5; + + let startPage = Math.max(1, currentPage - Math.floor(MAX_VISIBLE_PAGES / 2)); + + let endPage = startPage + MAX_VISIBLE_PAGES - 1; + + if (endPage > totalPages) { + endPage = totalPages; + startPage = Math.max(1, endPage - MAX_VISIBLE_PAGES + 1); + } + + const pages = []; + + for (let i = startPage; i <= endPage; i++) { + pages.push(i); + } + + return ( +
+ + +
    + {pages.map(page => ( +
  • + +
  • + ))} +
+ + +
+ ); +}; diff --git a/src/components/ProductCard/ProductCard.module.scss b/src/components/ProductCard/ProductCard.module.scss new file mode 100644 index 00000000000..41f69dbc68b --- /dev/null +++ b/src/components/ProductCard/ProductCard.module.scss @@ -0,0 +1,186 @@ +@import '../../styles/variables'; +@import '../../styles/mixins'; + +.card { + width: 100%; + max-width: 400px; + height: 440px; + background-color: $c-bg-card; + padding: 24px; + box-sizing: border-box; + display: flex; + flex-direction: column; + transition: box-shadow 0.3s ease, transform 0.3s ease; + + @include tablet { + height: 506px; + padding: 32px; + } + + &:hover { + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); + } + + &__image_link { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 150px; + margin-bottom: 24px; + overflow: hidden; + + @include tablet { + height: 196px; + } + } + + &__image { + max-width: 100%; + max-height: 100%; + object-fit: contain; + transition: transform 0.3s ease; + + .card__image_link:hover & { + transform: scale(1.1); + } + } + + &__title { + height: 42px; + color: $c-text-primary; + text-decoration: none; + font-size: 14px; + line-height: 21px; + font-weight: 600; + margin-bottom: 8px; + + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + + &:hover { + text-decoration: underline; + } + } + + &__prices { + display: flex; + align-items: center; + gap: 8px; + height: 31px; + margin-bottom: 8px; + } + + &__price_current { + font-size: 22px; + font-weight: 800; + color: $c-text-primary; + } + + &__price_full { + font-size: 14px; + font-weight: 500; + color: $c-text-secondary; + text-decoration: line-through; + } + + &__divider { + height: 1px; + background-color: $c-border; + border: none; + margin-bottom: 8px; + } + + &__specs { + display: flex; + flex-direction: column; + justify-content: space-between; + width: 100%; + height: 77px; + } + + &__spec { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; + width: 100%; + + &_title { + color: $c-text-secondary; + flex-shrink: 0; + margin-right: 8px; + } + + &_value { + color: $c-text-primary; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: right; + } + } + + &__actions { + display: flex; + gap: 8px; + width: 100%; + height: 40px; + margin-top: auto; + } + + &__button { + height: 40px; + border: none; + font-family: inherit; + font-weight: 600; + font-size: 14px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.3s ease; + } + + &__button_add { + flex: 1; + background-color: $c-primary; + color: #fff; + + &:hover { + background-color: $c-primary-hover; + } + } + + &__button_added { + flex: 1; + background-color: transparent; + color: #fff; + border: 1px solid $c-border; + + &:hover { + border-color: $c-text-primary; + } + } + + &__button_fav { + width: 40px; + flex-shrink: 0; + background-color: transparent; + border: 1px solid $c-border; + color: $c-text-primary; + + &:hover { + border-color: $c-text-primary; + } + + &--active { + color: $c-accent-red; + border-color: $c-border; + } + } +} diff --git a/src/components/ProductCard/ProductCard.tsx b/src/components/ProductCard/ProductCard.tsx new file mode 100644 index 00000000000..ecf8fc26b78 --- /dev/null +++ b/src/components/ProductCard/ProductCard.tsx @@ -0,0 +1,105 @@ +import { Link } from 'react-router-dom'; +import cn from 'classnames'; +import { Product } from '../../types/Product'; +import { useAppDispatch, useAppSelector } from '../../store/hooks'; +import { addToCart } from '../../store/cartSlice'; +import { toggleFavorite } from '../../store/favoritesSlice'; +import styles from './ProductCard.module.scss'; + +interface Props { + product: Product; + hideOldPrice?: boolean; +} + +export const ProductCard = ({ product, hideOldPrice }: Props) => { + const dispatch = useAppDispatch(); + const cartItems = useAppSelector(state => state.cart.items); + const favoritesItems = useAppSelector(state => state.favorites.items); + + const isAddedToCart = cartItems.some(item => item.id === product.id); + const isFavorite = favoritesItems.some(item => item.id === product.id); + + const productLink = `/${product.category}/${product.itemId || product.id}`; + + const handleAddToCart = () => { + if (!isAddedToCart) { + dispatch(addToCart(product)); + } + }; + + const currentImage = + product.image || + product.imageUrl || + (product.images ? product.images[0] : ''); + + const imageSrc = currentImage.startsWith('/') + ? currentImage + : `/${currentImage}`; + + return ( +
+ + {product.name} + + + + {product.name} + + +
+ + ${product.price || product.priceDiscount} + + {!hideOldPrice && (product.fullPrice || product.priceRegular) && ( + + ${product.fullPrice || product.priceRegular} + + )} +
+ +
+ +
+
+ Screen + {product.screen} +
+
+ Capacity + {product.capacity} +
+
+ RAM + {product.ram} +
+
+ +
+ + + +
+
+ ); +}; diff --git a/src/components/ProductsList/ProductsList.module.scss b/src/components/ProductsList/ProductsList.module.scss new file mode 100644 index 00000000000..b097c8c485b --- /dev/null +++ b/src/components/ProductsList/ProductsList.module.scss @@ -0,0 +1,6 @@ +.list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(272px, 1fr)); + gap: 16px; + justify-items: center; +} diff --git a/src/components/ProductsList/ProductsList.tsx b/src/components/ProductsList/ProductsList.tsx new file mode 100644 index 00000000000..e5ce24a4b1d --- /dev/null +++ b/src/components/ProductsList/ProductsList.tsx @@ -0,0 +1,17 @@ +import { Product } from '../../types/Product'; +import { ProductCard } from '../ProductCard/ProductCard'; +import styles from './ProductsList.module.scss'; + +interface Props { + products: Product[]; +} + +export const ProductsList = ({ products }: Props) => { + return ( +
+ {products.map(product => ( + + ))} +
+ ); +}; diff --git a/src/components/ProductsSlider/ProductsSlider.module.scss b/src/components/ProductsSlider/ProductsSlider.module.scss new file mode 100644 index 00000000000..4c13d93e55a --- /dev/null +++ b/src/components/ProductsSlider/ProductsSlider.module.scss @@ -0,0 +1,79 @@ +@import '../../styles/variables'; +@import '../../styles/mixins'; + +.slider { + margin-bottom: 80px; + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + } + + &__title { + font-size: 32px; + font-weight: 800; + color: $c-text-primary; + } + + &__buttons { + display: flex; + gap: 16px; + } + + &__btn { + width: 32px; + height: 32px; + background-color: #323542; + border: 1px solid $c-border; + border-radius: 0; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + transition: all 0.3s; + + img { + width: 16px; + height: 16px; + transition: opacity 0.3s; + } + + &:hover:not(:disabled) { + background-color: #4A4D58; + border-color: $c-border; + } + + &:disabled { + opacity: 0.3; + cursor: not-allowed; + border-color: $c-border; + background-color: transparent; + } + } + + &__track_container { + overflow: hidden; + } + + &__track { + display: flex; + gap: 16px; + overflow-x: auto; + scroll-behavior: smooth; + scroll-snap-type: x mandatory; + padding-bottom: 16px; + + &::-webkit-scrollbar { display: none; } + -ms-overflow-style: none; + scrollbar-width: none; + } + + &__item { + scroll-snap-align: start; + flex-shrink: 0; + width: 272px; + max-width: 100%; + } +} diff --git a/src/components/ProductsSlider/ProductsSlider.tsx b/src/components/ProductsSlider/ProductsSlider.tsx new file mode 100644 index 00000000000..0a2b30ba96f --- /dev/null +++ b/src/components/ProductsSlider/ProductsSlider.tsx @@ -0,0 +1,83 @@ +import { useRef, useState, useEffect } from 'react'; +import { Product } from '../../types/Product'; +import { ProductCard } from '../ProductCard/ProductCard'; +import styles from './ProductsSlider.module.scss'; + +interface Props { + title: string; + products: Product[]; + hideOldPrice?: boolean; +} + +export const ProductsSlider = ({ title, products, hideOldPrice }: Props) => { + const trackRef = useRef(null); + + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(true); + + const checkScroll = () => { + if (trackRef.current) { + const { scrollLeft, scrollWidth, clientWidth } = trackRef.current; + + setCanScrollLeft(scrollLeft > 0); + setCanScrollRight(Math.ceil(scrollLeft + clientWidth) < scrollWidth); + } + }; + + useEffect(() => { + checkScroll(); + window.addEventListener('resize', checkScroll); + + return () => window.removeEventListener('resize', checkScroll); + }, [products]); + + const scrollLeft = () => { + if (trackRef.current) { + trackRef.current.scrollBy({ left: -288 * 2, behavior: 'smooth' }); + } + }; + + const scrollRight = () => { + if (trackRef.current) { + trackRef.current.scrollBy({ left: 288 * 2, behavior: 'smooth' }); + } + }; + + return ( +
+
+

{title}

+
+ + +
+
+ +
+
+ {products.map(product => ( +
+ +
+ ))} +
+
+
+ ); +}; diff --git a/src/components/ShopByCategory/ShopByCategory.module.scss b/src/components/ShopByCategory/ShopByCategory.module.scss new file mode 100644 index 00000000000..ccb3053824d --- /dev/null +++ b/src/components/ShopByCategory/ShopByCategory.module.scss @@ -0,0 +1,68 @@ +@import '../../styles/variables'; +@import '../../styles/mixins'; + +.category { + margin-bottom: 80px; + + &__title { + font-size: 32px; + font-weight: 800; + color: $c-text-primary; + margin-bottom: 24px; + } + + &__grid { + display: grid; + grid-template-columns: 1fr; + gap: 16px; + + @include tablet { + grid-template-columns: repeat(3, 1fr); + } + } + + &__card { + text-decoration: none; + display: flex; + flex-direction: column; + gap: 24px; + transition: transform 0.3s; + + &:hover { + transform: scale(1.02); + } + } + + &__photo_wrap { + background-color: $c-bg-elements; + aspect-ratio: 1 / 1; + display: flex; + justify-content: center; + align-items: center; + overflow: hidden; + } + + &__photo { + width: 100%; + height: 100%; + object-fit: cover; + } + + &__info { + display: flex; + flex-direction: column; + gap: 4px; + } + + &__name { + font-size: 20px; + font-weight: 700; + color: $c-text-primary; + } + + &__count { + font-size: 14px; + font-weight: 600; + color: $c-text-secondary; + } +} diff --git a/src/components/ShopByCategory/ShopByCategory.tsx b/src/components/ShopByCategory/ShopByCategory.tsx new file mode 100644 index 00000000000..c92312a91dc --- /dev/null +++ b/src/components/ShopByCategory/ShopByCategory.tsx @@ -0,0 +1,54 @@ +import { Link } from 'react-router-dom'; +import styles from './ShopByCategory.module.scss'; + +export const ShopByCategory = () => { + return ( +
+

Shop by category

+ +
+ +
+ Mobile phones +
+
+

Mobile phones

+ 95 models +
+ + + +
+ Tablets +
+
+

Tablets

+ 24 models +
+ + + +
+ Accessories +
+
+

Accessories

+ 100 models +
+ +
+
+ ); +}; diff --git a/src/index.tsx b/src/index.tsx index 50470f1508d..950127f6449 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,17 @@ +import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { store } from './store'; import { App } from './App'; +import './App.scss'; -createRoot(document.getElementById('root') as HTMLElement).render(); +createRoot(document.getElementById('root') as HTMLElement).render( + + + + + + + , +); diff --git a/src/modules/CartPage/CartPage.module.scss b/src/modules/CartPage/CartPage.module.scss new file mode 100644 index 00000000000..bc4df9c0c84 --- /dev/null +++ b/src/modules/CartPage/CartPage.module.scss @@ -0,0 +1,236 @@ +@import '../../styles/variables'; +@import '../../styles/mixins'; + +.page { + max-width: 1200px; + margin: 0 auto; + padding: 24px 16px 64px; + + &__back { + background: transparent; + border: none; + color: #F1F2F9; + font-weight: 600; + cursor: pointer; + margin-bottom: 24px; + font-family: inherit; + + &:hover { color: #905BFF; } + } + + &__title { + font-size: 32px; + font-weight: 800; + color: $c-text-primary; + margin-bottom: 32px; + } + + &__content { + display: flex; + flex-direction: column; + gap: 32px; + + @include desktop { + flex-direction: row; + align-items: flex-start; + } + } + + &__list { + flex: 1; + display: flex; + flex-direction: column; + gap: 16px; + } + + &__empty { + font-size: 20px; + color: $c-text-secondary; + } +} + +.summary { + width: 100%; + border: 1px solid $c-border; + padding: 24px; + + @include desktop { + width: 368px; + } + + &__total { + text-align: center; + margin-bottom: 24px; + border-bottom: 1px solid $c-border; + padding-bottom: 24px; + } + + &__price { + font-size: 32px; + font-weight: 800; + margin-bottom: 8px; + display: block; + color: $c-text-primary; + } + + &__text { + color: $c-text-secondary; + font-weight: 600; + } + + &__button { + width: 100%; + height: 48px; + background-color: #905BFF; + color: #fff; + border: none; + font-weight: 700; + font-family: inherit; + cursor: pointer; + + &:hover { + background-color: #A378FF; + } + } +} + +.cart_item { + display: flex; + flex-direction: column; + gap: 16px; + background-color: #161827; + padding: 16px; + + @include tablet { + flex-direction: row; + align-items: center; + justify-content: space-between; + padding: 24px; + gap: 24px; + } + + &__info { + display: flex; + align-items: center; + gap: 16px; + + @include tablet { + flex: 1; + } + } + + &__remove { + width: 16px; + height: 16px; + background: transparent; + border: none; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + + img { + width: 16px; + height: 16px; + opacity: 0.5; + transition: opacity 0.3s; + } + + &:hover img { + opacity: 1; + } + } + + &__image_link { + width: 80px; + height: 80px; + flex-shrink: 0; + } + + &__image { + width: 100%; + height: 100%; + object-fit: contain; + } + + &__title { + color: $c-text-primary; + text-decoration: none; + font-weight: 600; + font-size: 14px; + line-height: 21px; + + &:hover { + text-decoration: underline; + } + } + + &__action { + display: flex; + align-items: center; + justify-content: space-between; + + @include tablet { + gap: 32px; + justify-content: flex-end; + } + } + + &__controls { + display: flex; + align-items: center; + gap: 16px; + } + + &__quantity { + color: $c-text-primary; + font-weight: 600; + font-size: 14px; + width: 16px; + text-align: center; + } + + &__btn { + width: 32px; + height: 32px; + background: #323542; + border: 1px solid $c-border; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + transition: border-color 0.3s; + + img { + width: 16px; + height: 16px; + opacity: 0.8; + transition: opacity 0.3s; + } + + &:hover:not(:disabled) { + border-color: $c-text-secondary; + + img { + opacity: 1; + } + } + + &:disabled { + opacity: 0.3; + cursor: not-allowed; + } + } + + &__price { + font-size: 22px; + font-weight: 800; + color: $c-text-primary; + + @include tablet { + width: 100px; + text-align: right; + } + } +} diff --git a/src/modules/CartPage/CartPage.tsx b/src/modules/CartPage/CartPage.tsx new file mode 100644 index 00000000000..f200abdd996 --- /dev/null +++ b/src/modules/CartPage/CartPage.tsx @@ -0,0 +1,148 @@ +import { useNavigate, Link } from 'react-router-dom'; +import { useAppSelector, useAppDispatch } from '../../store/hooks'; +import { + removeFromCart, + updateQuantity, + clearCart, +} from '../../store/cartSlice'; +import styles from './CartPage.module.scss'; + +export const CartPage = () => { + const navigate = useNavigate(); + const dispatch = useAppDispatch(); + const cartItems = useAppSelector(state => state.cart.items); + + const totalAmount = cartItems.reduce((sum, item) => { + const itemPrice = item.product.priceDiscount || item.product.price || 0; + + return sum + itemPrice * item.quantity; + }, 0); + + const totalItemsCount = cartItems.reduce( + (count, item) => count + item.quantity, + 0, + ); + + const handleCheckout = () => { + const isConfirmed = window.confirm( + 'Checkout is not implemented yet. Do you want to clear the Cart?', + ); + + if (isConfirmed) { + dispatch(clearCart()); + } + }; + + return ( +
+ + +

Cart

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

Your cart is empty

+ ) : ( +
+
+ {cartItems.map(item => { + const imageSrc = + item.product.image || + item.product.imageUrl || + item.product.images?.[0] || + ''; + const finalImage = imageSrc.startsWith('/') + ? imageSrc + : `/${imageSrc}`; + const itemPrice = + item.product.priceDiscount || item.product.price; + + return ( +
+
+ + + + {item.product.name} + + + + {item.product.name} + +
+ +
+
+ + + {item.quantity} + + +
+ + + ${itemPrice} + +
+
+ ); + })} +
+ +
+
+ ${totalAmount} + + Total for {totalItemsCount} items + +
+ +
+
+ )} +
+ ); +}; diff --git a/src/modules/CartPage/index.ts b/src/modules/CartPage/index.ts new file mode 100644 index 00000000000..90c010237a0 --- /dev/null +++ b/src/modules/CartPage/index.ts @@ -0,0 +1 @@ +export * from './CartPage'; diff --git a/src/modules/CatalogPage/CatalogPage.module.scss b/src/modules/CatalogPage/CatalogPage.module.scss new file mode 100644 index 00000000000..6902c3115f5 --- /dev/null +++ b/src/modules/CatalogPage/CatalogPage.module.scss @@ -0,0 +1,125 @@ +@import '../../styles/variables'; +@import '../../styles/mixins'; + +.page { + max-width: 1200px; + margin: 0 auto; + padding: 24px 16px 64px; + + &__breadcrumbs { + margin-bottom: 24px; + } + + &__title { + font-size: 32px; + font-weight: 800; + color: $c-text-primary; + margin-bottom: 8px; + text-transform: capitalize; + } + + &__count { + display: block; + font-size: 14px; + color: $c-text-secondary; + font-weight: 600; + margin-bottom: 32px; + } + + &__filters { + display: flex; + gap: 16px; + margin-bottom: 24px; + + & > div { + flex: 1; + + @include tablet { + flex: none; + width: 176px; + } + } + } + + &__filter { + display: flex; + flex-direction: column; + gap: 4px; + width: 100%; + } + + &__label { + font-size: 12px; + font-weight: 600; + color: $c-text-secondary; + } + + &__select { + height: 40px; + width: 100%; + padding: 0 16px; + background-color: #323542; + border: 1px solid $c-border; + color: $c-text-primary; + font-family: inherit; + font-weight: 600; + border-radius: 0; + cursor: pointer; + + option { + background-color: $c-bg-elements; + color: $c-text-primary; + } + + &:hover { + border-color: #905BFF; + } + } + + &__message { + text-align: center; + padding: 40px; + color: $c-text-secondary; + font-size: 18px; + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + } + + &__reload_btn { + padding: 8px 24px; + background-color: transparent; + border: 1px solid $c-border; + color: $c-text-primary; + font-family: inherit; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + + &:hover { + border-color: $c-text-primary; + background-color: rgba(255, 255, 255, 0.05); + } + } +} + +.grid { + display: grid; + grid-template-columns: 1fr; + gap: 40px 16px; + margin-bottom: 40px; + + @include tablet { + grid-template-columns: repeat(2, 1fr); + } + + @media (min-width: 768px) { + grid-template-columns: repeat(3, 1fr); + } + + @include desktop { + grid-template-columns: repeat(4, 1fr); + } +} diff --git a/src/modules/CatalogPage/CatalogPage.tsx b/src/modules/CatalogPage/CatalogPage.tsx new file mode 100644 index 00000000000..16ab50b8669 --- /dev/null +++ b/src/modules/CatalogPage/CatalogPage.tsx @@ -0,0 +1,175 @@ +import { useState, useEffect } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { Product } from '../../types/Product'; +import { getProductsByCategory } from '../../api/products'; +import { ProductsList } from '../../components/ProductsList/ProductsList'; +import styles from './CatalogPage.module.scss'; +import { Pagination } from '../../components/Pagination/Pagination'; +import { Breadcrumbs } from '../../components/Breadcrumbs/Breadcrumbs'; + +interface Props { + category: 'phones' | 'tablets' | 'accessories'; +} + +export const CatalogPage = ({ category }: Props) => { + const [products, setProducts] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isError, setIsError] = useState(false); + + const [searchParams, setSearchParams] = useSearchParams(); + + const sortBy = searchParams.get('sort') || 'age'; + const perPage = searchParams.get('perPage') || 'all'; + const currentPage = +(searchParams.get('page') || 1); + + useEffect(() => { + setIsLoading(true); + setIsError(false); + + getProductsByCategory(category) + .then(data => setProducts(data)) + .catch(() => setIsError(true)) + .finally(() => setIsLoading(false)); + }, [category]); + + const handleSortChange = (e: React.ChangeEvent) => { + const newSort = e.target.value; + const params = new URLSearchParams(searchParams); + + if (newSort === 'age') { + params.delete('sort'); + } else { + params.set('sort', newSort); + } + + setSearchParams(params); + }; + + const handlePerPageChange = (e: React.ChangeEvent) => { + const newPerPage = e.target.value; + const params = new URLSearchParams(searchParams); + + if (newPerPage === 'all') { + params.delete('perPage'); + } else { + params.set('perPage', newPerPage); + } + + params.delete('page'); + setSearchParams(params); + }; + + const sortedProducts = [...products]; + + sortedProducts.sort((a, b) => { + switch (sortBy) { + case 'age': + return (b.year || 0) - (a.year || 0); + case 'title': + return a.name.localeCompare(b.name); + case 'price': + const priceA = a.price || a.priceDiscount || 0; + const priceB = b.price || b.priceDiscount || 0; + + return priceA - priceB; + default: + return 0; + } + }); + + let visibleProducts = sortedProducts; + let itemsPerPageNum = sortedProducts.length; + + if (perPage !== 'all') { + itemsPerPageNum = Number(perPage); + const startIndex = (currentPage - 1) * itemsPerPageNum; + const endIndex = startIndex + itemsPerPageNum; + + visibleProducts = sortedProducts.slice(startIndex, endIndex); + } + + const handlePageChange = (newPage: number) => { + const params = new URLSearchParams(searchParams); + + params.set('page', newPage.toString()); + setSearchParams(params); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + return ( +
+ +

{category}

+ {products.length} models + + {!isLoading && !isError && products.length > 0 && ( +
+
+ + +
+ +
+ + +
+
+ )} + + {isLoading &&
Loading...
} + {isError && ( +
+ Something went wrong. + +
+ )} + {!isLoading && !isError && products.length === 0 && ( +
There are no {category} yet.
+ )} + + {!isLoading && !isError && visibleProducts.length > 0 && ( + <> + + + {perPage !== 'all' && ( + + )} + + )} +
+ ); +}; diff --git a/src/modules/CatalogPage/index.ts b/src/modules/CatalogPage/index.ts new file mode 100644 index 00000000000..1cad0ffbfe4 --- /dev/null +++ b/src/modules/CatalogPage/index.ts @@ -0,0 +1 @@ +export * from './CatalogPage'; diff --git a/src/modules/FavoritesPage/FavoritesPage.module.scss b/src/modules/FavoritesPage/FavoritesPage.module.scss new file mode 100644 index 00000000000..619caa073f9 --- /dev/null +++ b/src/modules/FavoritesPage/FavoritesPage.module.scss @@ -0,0 +1,32 @@ +@import '../../styles/variables'; +@import '../../styles/mixins'; + +.page { + max-width: 1200px; + margin: 0 auto; + padding: 24px 16px 64px; + + &__breadcrumbs { + margin-bottom: 24px; + } + + &__title { + font-size: 32px; + font-weight: 800; + margin-bottom: 8px; + color: $c-text-primary; + } + + &__count { + color: $c-text-secondary; + font-size: 14px; + margin-bottom: 32px; + display: block; + } + + &__empty { + font-size: 20px; + color: $c-text-secondary; + padding: 40px 0; + } +} diff --git a/src/modules/FavoritesPage/FavoritesPage.tsx b/src/modules/FavoritesPage/FavoritesPage.tsx new file mode 100644 index 00000000000..890107b108a --- /dev/null +++ b/src/modules/FavoritesPage/FavoritesPage.tsx @@ -0,0 +1,27 @@ +import { useAppSelector } from '../../store/hooks'; +import { Breadcrumbs } from '../../components/Breadcrumbs/Breadcrumbs'; +import { ProductsList } from '../../components/ProductsList/ProductsList'; +import styles from './FavoritesPage.module.scss'; + +export const FavoritesPage = () => { + const favorites = useAppSelector(state => state.favorites.items); + + return ( +
+
+ +
+ +

Favorites

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

+ You have not added any products to favorites yet. +

+ ) : ( + + )} +
+ ); +}; diff --git a/src/modules/FavoritesPage/index.ts b/src/modules/FavoritesPage/index.ts new file mode 100644 index 00000000000..b3a884b1889 --- /dev/null +++ b/src/modules/FavoritesPage/index.ts @@ -0,0 +1 @@ +export * from './FavoritesPage'; diff --git a/src/modules/HomePage/HomePage.tsx b/src/modules/HomePage/HomePage.tsx new file mode 100644 index 00000000000..367d6500bf9 --- /dev/null +++ b/src/modules/HomePage/HomePage.tsx @@ -0,0 +1,48 @@ +import { useState, useEffect } from 'react'; +import { Product } from '../../types/Product'; +import { getHotProducts, getBrandNewProducts } from '../../api/products'; +import { ProductsSlider } from '../../components/ProductsSlider/ProductsSlider'; +import { BannerSlider } from '../../components/BannerSlider/BannerSlider'; +import { ShopByCategory } from '../../components/ShopByCategory/ShopByCategory'; // ДОДАНО ІМПОРТ + +export const HomePage = () => { + const [hotProducts, setHotProducts] = useState([]); + const [newProducts, setNewProducts] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + setIsLoading(true); + Promise.all([getHotProducts(), getBrandNewProducts()]) + .then(([hot, brandNew]) => { + setHotProducts(hot); + setNewProducts(brandNew); + }) + // eslint-disable-next-line no-console + .catch(err => console.error('Error loading homepage data', err)) + .finally(() => setIsLoading(false)); + }, []); + + return ( +
+

Product Catalog

+ + + + {isLoading ? ( +
Loading...
+ ) : ( + <> + + + + + + + )} +
+ ); +}; diff --git a/src/modules/HomePage/index.ts b/src/modules/HomePage/index.ts new file mode 100644 index 00000000000..11e53da674c --- /dev/null +++ b/src/modules/HomePage/index.ts @@ -0,0 +1 @@ +export * from './HomePage'; diff --git a/src/modules/NotFoundPage/NotFoundPage.tsx b/src/modules/NotFoundPage/NotFoundPage.tsx new file mode 100644 index 00000000000..6eced6a4ee3 --- /dev/null +++ b/src/modules/NotFoundPage/NotFoundPage.tsx @@ -0,0 +1,15 @@ +import { Link } from 'react-router-dom'; + +export const NotFoundPage = () => { + return ( +
+

Page not found

+ + Go to Home page + +
+ ); +}; diff --git a/src/modules/NotFoundPage/indext.ts b/src/modules/NotFoundPage/indext.ts new file mode 100644 index 00000000000..6197aa75aa8 --- /dev/null +++ b/src/modules/NotFoundPage/indext.ts @@ -0,0 +1 @@ +export * from './NotFoundPage'; diff --git a/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss b/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss new file mode 100644 index 00000000000..22501688cba --- /dev/null +++ b/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss @@ -0,0 +1,370 @@ +@import '../../styles/variables'; +@import '../../styles/mixins'; + +.loader, .not_found { + text-align: center; + padding: 100px; + font-size: 24px; + color: $c-text-primary; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 24px 16px 64px; +} + +.back_button { + background: transparent; + border: none; + color: #F1F2F9; + font-family: inherit; + font-size: 14px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + margin: 24px 0; + padding: 0; + transition: color 0.3s; + + &:hover { + color: #905BFF; + } +} + +.title { + font-size: 32px; + font-weight: 800; + color: $c-text-primary; + margin-bottom: 40px; +} + +.main_grid { + display: flex; + flex-direction: column; + gap: 40px; + margin-bottom: 80px; + + @include tablet { + flex-direction: row; + justify-content: space-between; + gap: 32px; + } + + @include desktop { + gap: 64px; + } +} + +.gallery { + display: flex; + flex-direction: column-reverse; + gap: 16px; + width: 100%; + + @include tablet { + flex-direction: row; + width: auto; + flex: 1; + } +} + +.thumbnails { + display: flex; + gap: 16px; + overflow-x: auto; + + &::-webkit-scrollbar { display: none; } + -ms-overflow-style: none; + scrollbar-width: none; + + @include tablet { + flex-direction: column; + } +} + +.thumb { + width: 50px; + height: 50px; + flex-shrink: 0; + border: 1px solid $c-border; + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: border-color 0.3s; + + @include tablet { + width: 64px; + height: 64px; + } + + @include desktop { + width: 80px; + height: 80px; + } + + img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + } +} + +.thumb_active { + border-color: $c-text-primary; +} + +.main_image { + width: 100%; + aspect-ratio: 1 / 1; + max-width: 464px; + display: flex; + align-items: center; + justify-content: center; + + img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + } +} + +.controls { + width: 100%; + + @include tablet { + max-width: 320px; + flex-shrink: 0; + } +} + +.section { + margin-bottom: 24px; + padding-bottom: 24px; + border-bottom: 1px solid $c-border; +} + +.section_header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.section_label { + display: block; + font-size: 12px; + color: $c-text-secondary; + font-weight: 600; +} + +.item_id { + font-size: 12px; + font-weight: 600; + color: $c-text-secondary; +} + +.colors { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.color_circle { + width: 32px; + height: 32px; + border-radius: 50%; + border: 2px solid transparent; + cursor: pointer; + box-shadow: 0 0 0 1px $c-border inset; + + &_active { + border-color: $c-text-primary; + box-shadow: 0 0 0 2px $c-bg-dark inset; + } +} + +.capacities { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.cap_box { + border: 1px solid $c-border; + padding: 8px 16px; + font-size: 14px; + font-weight: 600; + color: $c-text-primary; + cursor: pointer; + transition: all 0.3s; + + &_active { + background-color: $c-text-primary; + color: $c-bg-dark; + border-color: $c-text-primary; + } +} + +.price_block { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; +} + +.price_discount { + font-size: 32px; + font-weight: 800; + color: $c-text-primary; +} + +.price_regular { + font-size: 22px; + color: $c-text-secondary; + text-decoration: line-through; +} + +.actions { + display: flex; + gap: 8px; + margin-bottom: 32px; + height: 48px; +} + +.add_to_cart { + flex: 1; + background-color: #905BFF; + color: #fff; + border: none; + font-weight: 700; + font-size: 14px; + font-family: inherit; + cursor: pointer; + transition: background-color 0.3s; + + &:not(.added_to_cart):hover { + background-color: #A378FF; + } +} + +.added_to_cart { + background-color: transparent; + border: 1px solid $c-border; + color: #fff; +} + +.fav_button { + width: 48px; + flex-shrink: 0; + background-color: transparent; + border: 1px solid $c-border; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: border-color 0.3s; + + &:hover { + border-color: $c-text-secondary; + } + + &_active { + border-color: $c-border; + } +} + +.fav_icon { + width: 16px; + height: 16px; +} + +.quick_specs { + display: flex; + flex-direction: column; + gap: 8px; +} + +.spec_item { + display: flex; + justify-content: space-between; + font-size: 12px; + + span:first-child { + color: $c-text-secondary; + } + + span:last-child { + color: $c-text-primary; + font-weight: 600; + } +} + +.details_grid { + display: grid; + grid-template-columns: 1fr; + gap: 64px; + margin-bottom: 80px; + + @include desktop { + grid-template-columns: 1fr 1fr; + } +} + +.section_title { + font-size: 24px; + font-weight: 800; + color: $c-text-primary; + margin-bottom: 32px; + padding-bottom: 16px; + border-bottom: 1px solid $c-border; +} + +.desc_block { + margin-bottom: 32px; + + h3 { + font-size: 20px; + font-weight: 700; + color: $c-text-primary; + margin-bottom: 16px; + } + + p { + font-size: 14px; + line-height: 1.5; + color: $c-text-secondary; + margin-bottom: 16px; + } +} + +.specs_table { + display: flex; + flex-direction: column; + gap: 8px; +} + +.spec_row { + display: flex; + justify-content: space-between; + font-size: 14px; + + span:first-child { + color: $c-text-secondary; + } + + span:last-child { + color: $c-text-primary; + font-weight: 600; + text-align: right; + max-width: 60%; + } +} + +.suggested { + margin-top: 80px; +} diff --git a/src/modules/ProductDetailsPage/ProductDetailsPage.tsx b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx new file mode 100644 index 00000000000..512896c223e --- /dev/null +++ b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx @@ -0,0 +1,276 @@ +import { useEffect, useState } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { ProductDetails, Product } from '../../types/Product'; +import { getProductDetails, getSuggestedProducts } from '../../api/products'; +import { Breadcrumbs } from '../../components/Breadcrumbs/Breadcrumbs'; +import { ProductsSlider } from '../../components/ProductsSlider/ProductsSlider'; +import { useAppDispatch, useAppSelector } from '../../store/hooks'; +import { addToCart } from '../../store/cartSlice'; +import { toggleFavorite } from '../../store/favoritesSlice'; +import cn from 'classnames'; +import styles from './ProductDetailsPage.module.scss'; + +const COLOR_HEX: Record = { + black: '#1F2020', + spacegray: '#4C4C4C', + 'space gray': '#4C4C4C', + silver: '#F0F0F0', + white: '#FBFBFB', + gold: '#F3D2B3', + yellow: '#FFE681', + green: '#AEE1CD', + purple: '#D1CDDA', + red: '#C92127', + midnightgreen: '#4E5851', +}; + +export const ProductDetailsPage = () => { + const { productId } = useParams(); + const navigate = useNavigate(); + const dispatch = useAppDispatch(); + + const [product, setProduct] = useState(null); + const [suggested, setSuggested] = useState([]); + const [mainImage, setMainImage] = useState(''); + const [isLoading, setIsLoading] = useState(true); + + const cartItems = useAppSelector(state => state.cart.items); + const favoritesItems = useAppSelector(state => state.favorites.items); + + const isAddedToCart = product + ? cartItems.some(item => item.id === product.id) + : false; + const isFavorite = product + ? favoritesItems.some(item => item.id === product.id) + : false; + + useEffect(() => { + if (!productId) { + return; + } + + setIsLoading(true); + window.scrollTo({ top: 0, behavior: 'smooth' }); + + Promise.all([getProductDetails(productId), getSuggestedProducts()]) + .then(([details, suggestedData]) => { + setProduct(details); + setSuggested(suggestedData); + setMainImage(details.images?.[0] || details.image || ''); + }) + .catch(() => setProduct(null)) + .finally(() => setIsLoading(false)); + }, [productId]); + + const handleColorChange = (newColor: string) => { + if (product && product.color !== newColor) { + const newId = product.id.replace( + product.color.replace(' ', '-'), + newColor.replace(' ', '-'), + ); + + navigate(`/${product.category}/${newId}`); + } + }; + + const handleCapacityChange = (newCapacity: string) => { + if (product && product.capacity !== newCapacity) { + const newId = product.id.replace( + product.capacity.toLowerCase(), + newCapacity.toLowerCase(), + ); + + navigate(`/${product.category}/${newId}`); + } + }; + + const handleAddToCart = () => { + if (product && !isAddedToCart) { + dispatch(addToCart(product as unknown as Product)); + } + }; + + if (isLoading) { + return
Loading...
; + } + + if (!product) { + return
Product not found
; + } + + return ( +
+ + + + +

{product.name}

+ +
+
+
+ {product.images?.map(img => ( +
setMainImage(img)} + > + thumb +
+ ))} +
+
+ {product.name} +
+
+ +
+
+
+ Available colors + + ID:{' '} + {product.namespaceId + ? Math.floor(Math.random() * 900000) + 100000 + : '802390'} + +
+ +
+ {product.colorsAvailable.map(color => ( +
handleColorChange(color)} + /> + ))} +
+
+ +
+ Select capacity +
+ {product.capacityAvailable.map(cap => ( +
handleCapacityChange(cap)} + > + {cap} +
+ ))} +
+
+ +
+ + ${product.priceDiscount || product.price} + + + ${product.priceRegular || product.fullPrice} + +
+ +
+ + +
+ +
+
+ Screen {product.screen} +
+
+ Resolution {product.resolution} +
+
+ Processor {product.processor} +
+
+ RAM {product.ram} +
+
+
+
+ +
+
+

About

+ {product.description?.map(desc => ( +
+

{desc.title}

+ {desc.text.map((p, index) => ( +

{p}

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

Tech specs

+
+
+ Screen {product.screen} +
+
+ Resolution {product.resolution} +
+
+ Processor {product.processor} +
+
+ RAM {product.ram} +
+
+ Capacity {product.capacity} +
+
+ Camera {product.camera} +
+
+ Zoom {product.zoom} +
+
+ Cell {product.cell?.join(', ') || 'N/A'} +
+
+
+
+ +
+ +
+
+ ); +}; diff --git a/src/modules/ProductDetailsPage/index.ts b/src/modules/ProductDetailsPage/index.ts new file mode 100644 index 00000000000..6615089e5ec --- /dev/null +++ b/src/modules/ProductDetailsPage/index.ts @@ -0,0 +1 @@ +export * from './ProductDetailsPage'; diff --git a/src/store/cartSlice.ts b/src/store/cartSlice.ts new file mode 100644 index 00000000000..48a4c23a9af --- /dev/null +++ b/src/store/cartSlice.ts @@ -0,0 +1,62 @@ +/* eslint-disable no-param-reassign */ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { CartItem } from '../types/CartItem'; +import { Product } from '../types/Product'; + +export interface CartState { + items: CartItem[]; +} + +const initialState: CartState = { + items: [], +}; + +const cartSlice = createSlice({ + name: 'cart', + initialState, + reducers: { + addToCart: (state, action: PayloadAction) => { + const product = action.payload; + const existingItem = state.items.find( + cartItem => cartItem.id === product.id, + ); + + if (existingItem) { + return; + } + + state.items.push({ + id: product.id, + quantity: 1, + product, + }); + }, + + removeFromCart: (state, action: PayloadAction) => { + state.items = state.items.filter( + cartItem => cartItem.id !== action.payload, + ); + }, + + updateQuantity: ( + state, + action: PayloadAction<{ id: string; quantity: number }>, + ) => { + const { id, quantity } = action.payload; + const itemToUpdate = state.items.find(cartItem => cartItem.id === id); + + if (itemToUpdate && quantity > 0) { + itemToUpdate.quantity = quantity; + } + }, + + clearCart: state => { + state.items = []; + }, + }, +}); + +export const { addToCart, removeFromCart, updateQuantity, clearCart } = + cartSlice.actions; + +export default cartSlice.reducer; diff --git a/src/store/favoritesSlice.ts b/src/store/favoritesSlice.ts new file mode 100644 index 00000000000..cd975207e49 --- /dev/null +++ b/src/store/favoritesSlice.ts @@ -0,0 +1,43 @@ +/* eslint-disable no-param-reassign */ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { Product } from '../types/Product'; + +export interface FavoritesState { + items: Product[]; +} + +const loadFavoritesFromStorage = (): Product[] => { + try { + const saved = localStorage.getItem('favorites'); + + return saved ? JSON.parse(saved) : []; + } catch (e) { + return []; + } +}; + +const initialState: FavoritesState = { + items: loadFavoritesFromStorage(), +}; + +const favoritesSlice = createSlice({ + name: 'favorites', + initialState, + reducers: { + toggleFavorite: (state, action: PayloadAction) => { + const product = action.payload; + const existingIndex = state.items.findIndex( + item => item.id === product.id, + ); + + if (existingIndex >= 0) { + state.items.splice(existingIndex, 1); + } else { + state.items.push(product); + } + }, + }, +}); + +export const { toggleFavorite } = favoritesSlice.actions; +export default favoritesSlice.reducer; diff --git a/src/store/hooks.ts b/src/store/hooks.ts new file mode 100644 index 00000000000..f92270e7bd9 --- /dev/null +++ b/src/store/hooks.ts @@ -0,0 +1,5 @@ +import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; +import type { RootState, AppDispatch } from './index'; + +export const useAppDispatch: () => AppDispatch = useDispatch; +export const useAppSelector: TypedUseSelectorHook = useSelector; diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 00000000000..4660861fed4 --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1,20 @@ +import { configureStore } from '@reduxjs/toolkit'; +import cartReducer from './cartSlice'; +import favoritesReducer from './favoritesSlice'; + +export const store = configureStore({ + reducer: { + cart: cartReducer, + favorites: favoritesReducer, + }, +}); + +store.subscribe(() => { + const state = store.getState(); + + localStorage.setItem('cart', JSON.stringify(state.cart.items)); + localStorage.setItem('favorites', JSON.stringify(state.favorites.items)); +}); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; diff --git a/src/styles/_mixins.scss b/src/styles/_mixins.scss new file mode 100644 index 00000000000..b07ea2873db --- /dev/null +++ b/src/styles/_mixins.scss @@ -0,0 +1,13 @@ +@import './variables'; + +@mixin tablet { + @media (min-width: $bp-tablet) { + @content; + } +} + +@mixin desktop { + @media (min-width: $bp-desktop) { + @content; + } +} diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss new file mode 100644 index 00000000000..af513095fbb --- /dev/null +++ b/src/styles/_variables.scss @@ -0,0 +1,19 @@ +$c-bg-dark: #0F1121; +$c-bg-card: #161827; +$c-bg-elements: #161618; +$c-text-primary: #ffffff; +$c-text-secondary: #75767f; +$c-accent-purple: #9056ec; +$c-accent-red: #eb5757; +$c-border: #323542; + +$c-primary: #905BFF; +$c-primary-hover: #A378FF; +$c-selected: #323542; +$c-border: #323542; + +$font-family: 'Mont', sans-serif; + +$bp-mobile: 320px; +$bp-tablet: 640px; +$bp-desktop: 1200px; diff --git a/src/types/CartItem.ts b/src/types/CartItem.ts new file mode 100644 index 00000000000..e22f60a5541 --- /dev/null +++ b/src/types/CartItem.ts @@ -0,0 +1,7 @@ +import { Product } from './Product'; + +export interface CartItem { + id: string; + quantity: number; + product: Product; +} diff --git a/src/types/Product.ts b/src/types/Product.ts new file mode 100644 index 00000000000..90032fd2c8b --- /dev/null +++ b/src/types/Product.ts @@ -0,0 +1,37 @@ +export interface Product { + id: string; + category: string; + phoneId?: string; + itemId?: string; + name: string; + fullPrice: number; + price: number; + priceRegular?: number; + priceDiscount?: number; + screen: string; + capacity: string; + color: string; + ram: string; + year: number; + image: string; + imageUrl?: string; + images?: string[]; +} + +export interface ProductDescription { + title: string; + text: string[]; +} + +export interface ProductDetails extends Product { + namespaceId: string; + capacityAvailable: string[]; + colorsAvailable: string[]; + description: ProductDescription[]; + resolution: string; + processor: string; + zoom?: string; + cell: string[]; + camera?: string; + images: string[]; +} From c5352bc0acb217ced444b4cfdb997ef7ef857b30 Mon Sep 17 00:00:00 2001 From: Andrii Tsopych Date: Mon, 18 May 2026 22:44:43 +0300 Subject: [PATCH 02/18] add task soultion --- src/App.scss | 8 +-- src/components/Footer/Footer.module.scss | 50 +++++++++---------- .../MobileMenu/MobileMenu.module.scss | 2 +- .../Pagination/Pagination.module.scss | 19 ++++--- .../ProductsSlider/ProductsSlider.module.scss | 12 +++-- src/modules/CartPage/CartPage.module.scss | 32 ++++++------ .../ProductDetailsPage.module.scss | 1 + src/styles/_variables.scss | 5 +- 8 files changed, 67 insertions(+), 62 deletions(-) diff --git a/src/App.scss b/src/App.scss index 6641eaf2c78..d72916b542d 100644 --- a/src/App.scss +++ b/src/App.scss @@ -1,17 +1,19 @@ @import './styles/variables'; @font-face { - font-family: 'Mont'; + font-family: Mont; src: url('/fonts/Mont-Regular.otf') format('opentype'); font-weight: 400; } + @font-face { - font-family: 'Mont'; + font-family: Mont; src: url('/fonts/Mont-SemiBold.otf') format('opentype'); font-weight: 600; } + @font-face { - font-family: 'Mont'; + font-family: Mont; src: url('/fonts/Mont-Bold.otf') format('opentype'); font-weight: 700; } diff --git a/src/components/Footer/Footer.module.scss b/src/components/Footer/Footer.module.scss index df1a5f8f650..d23be7c0370 100644 --- a/src/components/Footer/Footer.module.scss +++ b/src/components/Footer/Footer.module.scss @@ -60,31 +60,6 @@ } } -.back { - display: flex; - align-items: center; - gap: 16px; - cursor: pointer; - align-self: center; - - @include tablet { - align-self: auto; - } - - &:hover .back_btn { - background-color: #4A4D58; - border-color: $c-border; - - img { - opacity: 1; - } - } - - &:hover .back_text { - color: $c-text-primary; - } -} - .back_text { font-family: inherit; font-size: 12px; @@ -111,3 +86,28 @@ transition: opacity 0.3s ease; } } + +.back { + display: flex; + align-items: center; + gap: 16px; + cursor: pointer; + align-self: center; + + @include tablet { + align-self: auto; + } + + &:hover .back_btn { + background-color: #4A4D58; + border-color: $c-border; + + img { + opacity: 1; + } + } + + &:hover .back_text { + color: $c-text-primary; + } +} diff --git a/src/components/MobileMenu/MobileMenu.module.scss b/src/components/MobileMenu/MobileMenu.module.scss index 7cc8761eb47..873d5801849 100644 --- a/src/components/MobileMenu/MobileMenu.module.scss +++ b/src/components/MobileMenu/MobileMenu.module.scss @@ -76,7 +76,7 @@ } &:hover { - background-color: lighten($c-bg-dark, 5%); + background-color: $c-selected; } &--active { diff --git a/src/components/Pagination/Pagination.module.scss b/src/components/Pagination/Pagination.module.scss index d60b7ae7e78..69d93fc8bc7 100644 --- a/src/components/Pagination/Pagination.module.scss +++ b/src/components/Pagination/Pagination.module.scss @@ -28,6 +28,11 @@ cursor: pointer; transition: all 0.3s ease; + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + &:hover:not(:disabled) { border-color: $c-text-secondary; } @@ -38,10 +43,7 @@ border-color: $c-text-primary; } - &:disabled { - opacity: 0.5; - cursor: not-allowed; - } + } &__arrow { @@ -56,13 +58,14 @@ cursor: pointer; transition: border-color 0.3s; - &:hover:not(:disabled) { - border-color: $c-text-secondary; - } - &:disabled { opacity: 0.5; cursor: not-allowed; } + + &:hover:not(:disabled) { + border-color: $c-text-secondary; + } + } } diff --git a/src/components/ProductsSlider/ProductsSlider.module.scss b/src/components/ProductsSlider/ProductsSlider.module.scss index 4c13d93e55a..be71e828517 100644 --- a/src/components/ProductsSlider/ProductsSlider.module.scss +++ b/src/components/ProductsSlider/ProductsSlider.module.scss @@ -40,17 +40,18 @@ transition: opacity 0.3s; } - &:hover:not(:disabled) { - background-color: #4A4D58; - border-color: $c-border; - } - &:disabled { opacity: 0.3; cursor: not-allowed; border-color: $c-border; background-color: transparent; } + + &:hover:not(:disabled) { + background-color: #4A4D58; + border-color: $c-border; + } + } &__track_container { @@ -66,6 +67,7 @@ padding-bottom: 16px; &::-webkit-scrollbar { display: none; } + -ms-overflow-style: none; scrollbar-width: none; } diff --git a/src/modules/CartPage/CartPage.module.scss b/src/modules/CartPage/CartPage.module.scss index bc4df9c0c84..010f392f55e 100644 --- a/src/modules/CartPage/CartPage.module.scss +++ b/src/modules/CartPage/CartPage.module.scss @@ -136,10 +136,6 @@ opacity: 0.5; transition: opacity 0.3s; } - - &:hover img { - opacity: 1; - } } &__image_link { @@ -160,10 +156,6 @@ font-weight: 600; font-size: 14px; line-height: 21px; - - &:hover { - text-decoration: underline; - } } &__action { @@ -209,14 +201,6 @@ transition: opacity 0.3s; } - &:hover:not(:disabled) { - border-color: $c-text-secondary; - - img { - opacity: 1; - } - } - &:disabled { opacity: 0.3; cursor: not-allowed; @@ -233,4 +217,20 @@ text-align: right; } } + + &__remove:hover img { + opacity: 1; + } + + &__title:hover { + text-decoration: underline; + } + + &__btn:hover:not(:disabled) { + border-color: $c-text-secondary; + + img { + opacity: 1; + } + } } diff --git a/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss b/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss index 22501688cba..eda77e30858 100644 --- a/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss +++ b/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss @@ -77,6 +77,7 @@ overflow-x: auto; &::-webkit-scrollbar { display: none; } + -ms-overflow-style: none; scrollbar-width: none; diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss index af513095fbb..13e1d7f5d13 100644 --- a/src/styles/_variables.scss +++ b/src/styles/_variables.scss @@ -1,19 +1,16 @@ $c-bg-dark: #0F1121; $c-bg-card: #161827; $c-bg-elements: #161618; -$c-text-primary: #ffffff; +$c-text-primary: #fff; $c-text-secondary: #75767f; $c-accent-purple: #9056ec; $c-accent-red: #eb5757; $c-border: #323542; - $c-primary: #905BFF; $c-primary-hover: #A378FF; $c-selected: #323542; $c-border: #323542; - $font-family: 'Mont', sans-serif; - $bp-mobile: 320px; $bp-tablet: 640px; $bp-desktop: 1200px; From 8684fee04e73e982c47a5925771a13e8bbfedae0 Mon Sep 17 00:00:00 2001 From: Andrii Tsopych Date: Tue, 19 May 2026 10:07:00 +0300 Subject: [PATCH 03/18] add readme --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3e1213ef5f7..cae9bdae4c8 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # React Product Catalog - +[DEMO]: https://andriyts1234.github.io/react_phone-catalog/ Implement the catalog with a shopping cart and favorites page according to one of the next designs: - [Original](https://www.figma.com/file/T5ttF21UnT6RRmCQQaZc6L/Phone-catalog-(V2)-Original) @@ -141,3 +141,4 @@ Show `input:search` in the header when a page contains a `ProductList` to search 1. Save the `Search` value in the URL as a `?query=value` to apply on page load. 2. Show `There are no phones/tablets/accessories/products matching the query` instead of `ProductList` when needed. 3. Add `debounce` to the search field. + From aa77495f9357c47a6b8b42a6bb96d4363fc773f8 Mon Sep 17 00:00:00 2001 From: Andrii Tsopych Date: Tue, 19 May 2026 10:20:29 +0300 Subject: [PATCH 04/18] check if photos are commited --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cae9bdae4c8..df23d6a9056 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # React Product Catalog -[DEMO]: https://andriyts1234.github.io/react_phone-catalog/ +- [DEMO] (https://andriyts1234.github.io/react_phone-catalog/) + Implement the catalog with a shopping cart and favorites page according to one of the next designs: - [Original](https://www.figma.com/file/T5ttF21UnT6RRmCQQaZc6L/Phone-catalog-(V2)-Original) From 3d9059041cd14bb3f71f147c78ac2a85dddfb3e3 Mon Sep 17 00:00:00 2001 From: Andrii Tsopych Date: Tue, 19 May 2026 10:26:22 +0300 Subject: [PATCH 05/18] add base in vite config --- vite.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/vite.config.ts b/vite.config.ts index 5a33944a9b4..81d383148e5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,4 +4,5 @@ import react from '@vitejs/plugin-react' // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], + base: '/react_phone-catalog/' }) From 924e8db876f496036cde48ffacb42619279b4aca Mon Sep 17 00:00:00 2001 From: Andrii Tsopych Date: Tue, 19 May 2026 10:43:37 +0300 Subject: [PATCH 06/18] add HashRouter --- src/index.tsx | 6 +++--- vite.config.ts | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 950127f6449..68d4dda6346 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,6 +1,6 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; -import { BrowserRouter } from 'react-router-dom'; +import { HashRouter } from 'react-router-dom'; import { Provider } from 'react-redux'; import { store } from './store'; import { App } from './App'; @@ -9,9 +9,9 @@ import './App.scss'; createRoot(document.getElementById('root') as HTMLElement).render( - + - + , ); diff --git a/vite.config.ts b/vite.config.ts index 81d383148e5..5a33944a9b4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,5 +4,4 @@ import react from '@vitejs/plugin-react' // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], - base: '/react_phone-catalog/' }) From 28e33136d8d6f3ce76ceb130a4e25395e037fc66 Mon Sep 17 00:00:00 2001 From: Andrii Tsopych Date: Tue, 19 May 2026 10:53:55 +0300 Subject: [PATCH 07/18] add relative paths --- src/api/fetchClient.ts | 2 +- src/components/BannerSlider/BannerSlider.tsx | 10 +++++----- src/components/Breadcrumbs/Breadcrumbs.tsx | 6 +++--- src/components/Footer/Footer.tsx | 4 ++-- src/components/Header/Header.tsx | 11 +++++++---- src/components/MobileMenu/MobileMenu.tsx | 4 ++-- src/components/ProductCard/ProductCard.tsx | 2 +- src/components/ProductsSlider/ProductsSlider.tsx | 4 ++-- src/components/ShopByCategory/ShopByCategory.tsx | 6 +++--- src/modules/CartPage/CartPage.tsx | 6 +++--- src/modules/ProductDetailsPage/ProductDetailsPage.tsx | 2 +- vite.config.ts | 10 +++++++--- 12 files changed, 37 insertions(+), 30 deletions(-) diff --git a/src/api/fetchClient.ts b/src/api/fetchClient.ts index 3e09068669e..df84b838350 100644 --- a/src/api/fetchClient.ts +++ b/src/api/fetchClient.ts @@ -1,5 +1,5 @@ // src/api/fetchClient.ts -const BASE_URL = '/api'; +const BASE_URL = './api'; export const client = { // Дженерик означає, що функція поверне дані того типу, який ми попросимо diff --git a/src/components/BannerSlider/BannerSlider.tsx b/src/components/BannerSlider/BannerSlider.tsx index 01e648d89dc..7e9a4d0f3dc 100644 --- a/src/components/BannerSlider/BannerSlider.tsx +++ b/src/components/BannerSlider/BannerSlider.tsx @@ -3,9 +3,9 @@ import cn from 'classnames'; import styles from './BannerSlider.module.scss'; const BANNERS = [ - '/img/banner-phones.png', - '/img/banner-tablets.png', - '/img/banner-accessories.png', + './img/banner-phones.png', + './img/banner-tablets.png', + './img/banner-accessories.png', ]; export const BannerSlider = () => { @@ -29,7 +29,7 @@ export const BannerSlider = () => {
@@ -49,7 +49,7 @@ export const BannerSlider = () => {
diff --git a/src/components/Breadcrumbs/Breadcrumbs.tsx b/src/components/Breadcrumbs/Breadcrumbs.tsx index 9fa93f9179d..c411af73d67 100644 --- a/src/components/Breadcrumbs/Breadcrumbs.tsx +++ b/src/components/Breadcrumbs/Breadcrumbs.tsx @@ -19,7 +19,7 @@ export const Breadcrumbs = ({ category, productName }: Props) => {
diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index 54e60d2b460..7296fd456b0 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -50,7 +50,7 @@ export const Header = () => {
- Nice Gadgets + Nice Gadgets
diff --git a/src/components/ShopByCategory/ShopByCategory.tsx b/src/components/ShopByCategory/ShopByCategory.tsx index c92312a91dc..fec742af9e9 100644 --- a/src/components/ShopByCategory/ShopByCategory.tsx +++ b/src/components/ShopByCategory/ShopByCategory.tsx @@ -10,7 +10,7 @@ export const ShopByCategory = () => {
Mobile phones @@ -24,7 +24,7 @@ export const ShopByCategory = () => {
Tablets @@ -38,7 +38,7 @@ export const ShopByCategory = () => {
Accessories diff --git a/src/modules/CartPage/CartPage.tsx b/src/modules/CartPage/CartPage.tsx index f200abdd996..b7eac2873d5 100644 --- a/src/modules/CartPage/CartPage.tsx +++ b/src/modules/CartPage/CartPage.tsx @@ -65,7 +65,7 @@ export const CartPage = () => { className={styles.cart_item__remove} onClick={() => dispatch(removeFromCart(item.id))} > - Remove + Remove { ) } > - Minus + Minus {item.quantity} @@ -117,7 +117,7 @@ export const CartPage = () => { ) } > - Plus + Plus
diff --git a/src/modules/ProductDetailsPage/ProductDetailsPage.tsx b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx index 512896c223e..e62e4a0c07d 100644 --- a/src/modules/ProductDetailsPage/ProductDetailsPage.tsx +++ b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx @@ -200,7 +200,7 @@ export const ProductDetailsPage = () => { } > Favorite diff --git a/vite.config.ts b/vite.config.ts index 5a33944a9b4..4c1c856a931 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,6 +2,10 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' // https://vitejs.dev/config/ -export default defineConfig({ - plugins: [react()], -}) +export default defineConfig(({ command }) => { + return { + plugins: [react()], + // Якщо ти назвав репозиторій на гітхабі інакше — зміни назву між слешами + base: command === 'build' ? '/react_phone-catalog/' : '/', + }; +}); From 380429457d51914b061a32e34dd75424bfb27212 Mon Sep 17 00:00:00 2001 From: Andrii Tsopych Date: Tue, 19 May 2026 11:08:25 +0300 Subject: [PATCH 08/18] add paths changes --- src/components/ProductCard/ProductCard.tsx | 6 +++++- src/modules/CartPage/CartPage.tsx | 13 ++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/components/ProductCard/ProductCard.tsx b/src/components/ProductCard/ProductCard.tsx index e95d5ed7254..a3844e2a6a8 100644 --- a/src/components/ProductCard/ProductCard.tsx +++ b/src/components/ProductCard/ProductCard.tsx @@ -94,7 +94,11 @@ export const ProductCard = ({ product, hideOldPrice }: Props) => { onClick={() => dispatch(toggleFavorite(product))} > Favorite diff --git a/src/modules/CartPage/CartPage.tsx b/src/modules/CartPage/CartPage.tsx index b7eac2873d5..1cf96fb7c7b 100644 --- a/src/modules/CartPage/CartPage.tsx +++ b/src/modules/CartPage/CartPage.tsx @@ -52,9 +52,16 @@ export const CartPage = () => { item.product.imageUrl || item.product.images?.[0] || ''; - const finalImage = imageSrc.startsWith('/') - ? imageSrc - : `/${imageSrc}`; + const cleanImageSrc = imageSrc.startsWith('/') + ? imageSrc.slice(1) + : imageSrc; + const finalImage = `${import.meta.env.BASE_URL}${cleanImageSrc}`; + + {item.product.name}; const itemPrice = item.product.priceDiscount || item.product.price; From f464a95fb5241a177536ac6299c449d026bacf3a Mon Sep 17 00:00:00 2001 From: Andrii Tsopych Date: Tue, 19 May 2026 11:18:39 +0300 Subject: [PATCH 09/18] add paths change --- src/api/getImage.ts | 14 ++++++++++++++ src/modules/CartPage/CartPage.tsx | 5 +++-- 2 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 src/api/getImage.ts diff --git a/src/api/getImage.ts b/src/api/getImage.ts new file mode 100644 index 00000000000..a8363f8d98d --- /dev/null +++ b/src/api/getImage.ts @@ -0,0 +1,14 @@ +export const getImageUrl = (path: string) => { + if (!path) { + return ''; + } + + // Беремо базовий шлях + const baseUrl = import.meta.env.BASE_URL; + + // Гарантуємо, що baseUrl закінчується на слеш, а шлях картинки НЕ починається зі слеша + const cleanBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; + const cleanPath = path.startsWith('/') ? path.slice(1) : path; + + return `${cleanBase}${cleanPath}`; +}; diff --git a/src/modules/CartPage/CartPage.tsx b/src/modules/CartPage/CartPage.tsx index 1cf96fb7c7b..73cea06a843 100644 --- a/src/modules/CartPage/CartPage.tsx +++ b/src/modules/CartPage/CartPage.tsx @@ -5,6 +5,7 @@ import { updateQuantity, clearCart, } from '../../store/cartSlice'; +import { getImageUrl } from '../../api/getImage'; import styles from './CartPage.module.scss'; export const CartPage = () => { @@ -58,9 +59,9 @@ export const CartPage = () => { const finalImage = `${import.meta.env.BASE_URL}${cleanImageSrc}`; {item.product.name}; const itemPrice = item.product.priceDiscount || item.product.price; From 4a6fd7d3d5c0986b6b089746fc475262711b7150 Mon Sep 17 00:00:00 2001 From: Andrii Tsopych Date: Tue, 19 May 2026 11:27:42 +0300 Subject: [PATCH 10/18] =?UTF-8?q?add=20paths=20change=D1=962?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/getImage.ts | 7 +------ src/components/ProductCard/ProductCard.tsx | 14 +++++--------- src/modules/CartPage/CartPage.tsx | 14 +++----------- 3 files changed, 9 insertions(+), 26 deletions(-) diff --git a/src/api/getImage.ts b/src/api/getImage.ts index a8363f8d98d..3c37fe859c2 100644 --- a/src/api/getImage.ts +++ b/src/api/getImage.ts @@ -3,12 +3,7 @@ export const getImageUrl = (path: string) => { return ''; } - // Беремо базовий шлях - const baseUrl = import.meta.env.BASE_URL; - - // Гарантуємо, що baseUrl закінчується на слеш, а шлях картинки НЕ починається зі слеша - const cleanBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; const cleanPath = path.startsWith('/') ? path.slice(1) : path; - return `${cleanBase}${cleanPath}`; + return `/react_phone-catalog/${cleanPath}`; }; diff --git a/src/components/ProductCard/ProductCard.tsx b/src/components/ProductCard/ProductCard.tsx index a3844e2a6a8..7f318e282df 100644 --- a/src/components/ProductCard/ProductCard.tsx +++ b/src/components/ProductCard/ProductCard.tsx @@ -5,6 +5,7 @@ import { useAppDispatch, useAppSelector } from '../../store/hooks'; import { addToCart } from '../../store/cartSlice'; import { toggleFavorite } from '../../store/favoritesSlice'; import styles from './ProductCard.module.scss'; +import { getImageUrl } from '../../api/getImage'; interface Props { product: Product; @@ -31,10 +32,7 @@ export const ProductCard = ({ product, hideOldPrice }: Props) => { product.image || product.imageUrl || (product.images ? product.images[0] : ''); - - const imageSrc = currentImage.startsWith('/') - ? currentImage - : `/${currentImage}`; + const imageSrc = getImageUrl(currentImage); return (
@@ -94,11 +92,9 @@ export const ProductCard = ({ product, hideOldPrice }: Props) => { onClick={() => dispatch(toggleFavorite(product))} > Favorite diff --git a/src/modules/CartPage/CartPage.tsx b/src/modules/CartPage/CartPage.tsx index 73cea06a843..b7eac2873d5 100644 --- a/src/modules/CartPage/CartPage.tsx +++ b/src/modules/CartPage/CartPage.tsx @@ -5,7 +5,6 @@ import { updateQuantity, clearCart, } from '../../store/cartSlice'; -import { getImageUrl } from '../../api/getImage'; import styles from './CartPage.module.scss'; export const CartPage = () => { @@ -53,16 +52,9 @@ export const CartPage = () => { item.product.imageUrl || item.product.images?.[0] || ''; - const cleanImageSrc = imageSrc.startsWith('/') - ? imageSrc.slice(1) - : imageSrc; - const finalImage = `${import.meta.env.BASE_URL}${cleanImageSrc}`; - - {item.product.name}; + const finalImage = imageSrc.startsWith('/') + ? imageSrc + : `/${imageSrc}`; const itemPrice = item.product.priceDiscount || item.product.price; From 76c98bcb82e205dcca84d1b40d4ed2be85df3656 Mon Sep 17 00:00:00 2001 From: Andrii Tsopych Date: Tue, 19 May 2026 11:29:49 +0300 Subject: [PATCH 11/18] get back --- src/api/getImage.ts | 9 --------- src/components/ProductCard/ProductCard.tsx | 14 +++++++++----- 2 files changed, 9 insertions(+), 14 deletions(-) delete mode 100644 src/api/getImage.ts diff --git a/src/api/getImage.ts b/src/api/getImage.ts deleted file mode 100644 index 3c37fe859c2..00000000000 --- a/src/api/getImage.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const getImageUrl = (path: string) => { - if (!path) { - return ''; - } - - const cleanPath = path.startsWith('/') ? path.slice(1) : path; - - return `/react_phone-catalog/${cleanPath}`; -}; diff --git a/src/components/ProductCard/ProductCard.tsx b/src/components/ProductCard/ProductCard.tsx index 7f318e282df..a202fc35479 100644 --- a/src/components/ProductCard/ProductCard.tsx +++ b/src/components/ProductCard/ProductCard.tsx @@ -5,7 +5,6 @@ import { useAppDispatch, useAppSelector } from '../../store/hooks'; import { addToCart } from '../../store/cartSlice'; import { toggleFavorite } from '../../store/favoritesSlice'; import styles from './ProductCard.module.scss'; -import { getImageUrl } from '../../api/getImage'; interface Props { product: Product; @@ -32,7 +31,10 @@ export const ProductCard = ({ product, hideOldPrice }: Props) => { product.image || product.imageUrl || (product.images ? product.images[0] : ''); - const imageSrc = getImageUrl(currentImage); + + const imageSrc = currentImage.startsWith('/') + ? currentImage + : `/${currentImage}`; return (
@@ -92,9 +94,11 @@ export const ProductCard = ({ product, hideOldPrice }: Props) => { onClick={() => dispatch(toggleFavorite(product))} > Favorite From 564406cbd69dd6247d66da96e7547532e104390b Mon Sep 17 00:00:00 2001 From: Andrii Tsopych Date: Tue, 19 May 2026 11:40:51 +0300 Subject: [PATCH 12/18] check deploy --- src/components/ProductCard/ProductCard.tsx | 4 +--- src/modules/ProductDetailsPage/ProductDetailsPage.tsx | 7 +++++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/components/ProductCard/ProductCard.tsx b/src/components/ProductCard/ProductCard.tsx index a202fc35479..346ea8e1176 100644 --- a/src/components/ProductCard/ProductCard.tsx +++ b/src/components/ProductCard/ProductCard.tsx @@ -32,9 +32,7 @@ export const ProductCard = ({ product, hideOldPrice }: Props) => { product.imageUrl || (product.images ? product.images[0] : ''); - const imageSrc = currentImage.startsWith('/') - ? currentImage - : `/${currentImage}`; + const imageSrc = `${import.meta.env.BASE_URL}${currentImage.startsWith('/') ? currentImage.slice(1) : currentImage}`; return (
diff --git a/src/modules/ProductDetailsPage/ProductDetailsPage.tsx b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx index e62e4a0c07d..fe3bcfeaeec 100644 --- a/src/modules/ProductDetailsPage/ProductDetailsPage.tsx +++ b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx @@ -119,12 +119,15 @@ export const ProductDetailsPage = () => { })} onClick={() => setMainImage(img)} > - thumb + thumb
))}
- {product.name} + {product.name}
From facee29184465defe9e0c1274309b874543b2de4 Mon Sep 17 00:00:00 2001 From: Andrii Tsopych Date: Tue, 19 May 2026 12:01:28 +0300 Subject: [PATCH 13/18] add relative dot --- src/components/ProductCard/ProductCard.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/components/ProductCard/ProductCard.tsx b/src/components/ProductCard/ProductCard.tsx index 346ea8e1176..e99587072c3 100644 --- a/src/components/ProductCard/ProductCard.tsx +++ b/src/components/ProductCard/ProductCard.tsx @@ -32,7 +32,7 @@ export const ProductCard = ({ product, hideOldPrice }: Props) => { product.imageUrl || (product.images ? product.images[0] : ''); - const imageSrc = `${import.meta.env.BASE_URL}${currentImage.startsWith('/') ? currentImage.slice(1) : currentImage}`; + const imageSrc = `./${currentImage.startsWith('/') ? currentImage.slice(1) : currentImage}`; return (
@@ -92,11 +92,7 @@ export const ProductCard = ({ product, hideOldPrice }: Props) => { onClick={() => dispatch(toggleFavorite(product))} > Favorite From 4142f5b0b55587dfb52b79a362619e86bae1920e Mon Sep 17 00:00:00 2001 From: Andrii Tsopych Date: Tue, 19 May 2026 12:08:13 +0300 Subject: [PATCH 14/18] add relative dot2 --- vite.config.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/vite.config.ts b/vite.config.ts index 4c1c856a931..986624c535b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,7 +5,6 @@ import react from '@vitejs/plugin-react' export default defineConfig(({ command }) => { return { plugins: [react()], - // Якщо ти назвав репозиторій на гітхабі інакше — зміни назву між слешами - base: command === 'build' ? '/react_phone-catalog/' : '/', + // base: command === 'build' ? '/react_phone-catalog/' : '/', }; }); From 6e8970d7327b4b621d6ddd6e044611e5b8ee5a71 Mon Sep 17 00:00:00 2001 From: Andrii Tsopych Date: Tue, 19 May 2026 12:29:28 +0300 Subject: [PATCH 15/18] push and pray --- src/modules/CartPage/CartPage.tsx | 4 +--- src/modules/ProductDetailsPage/ProductDetailsPage.tsx | 7 +++++-- vite.config.ts | 3 ++- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/modules/CartPage/CartPage.tsx b/src/modules/CartPage/CartPage.tsx index b7eac2873d5..1eeed684e03 100644 --- a/src/modules/CartPage/CartPage.tsx +++ b/src/modules/CartPage/CartPage.tsx @@ -52,9 +52,7 @@ export const CartPage = () => { item.product.imageUrl || item.product.images?.[0] || ''; - const finalImage = imageSrc.startsWith('/') - ? imageSrc - : `/${imageSrc}`; + const finalImage = `./${imageSrc.startsWith('/') ? imageSrc.slice(1) : imageSrc}`; const itemPrice = item.product.priceDiscount || item.product.price; diff --git a/src/modules/ProductDetailsPage/ProductDetailsPage.tsx b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx index fe3bcfeaeec..5d3551f5575 100644 --- a/src/modules/ProductDetailsPage/ProductDetailsPage.tsx +++ b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx @@ -119,13 +119,16 @@ export const ProductDetailsPage = () => { })} onClick={() => setMainImage(img)} > - thumb + thumb
))}
{product.name}
diff --git a/vite.config.ts b/vite.config.ts index 986624c535b..4c1c856a931 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,6 +5,7 @@ import react from '@vitejs/plugin-react' export default defineConfig(({ command }) => { return { plugins: [react()], - // base: command === 'build' ? '/react_phone-catalog/' : '/', + // Якщо ти назвав репозиторій на гітхабі інакше — зміни назву між слешами + base: command === 'build' ? '/react_phone-catalog/' : '/', }; }); From 5244ebd8d1dc2e69ad8c438c57856b1ae64d5db2 Mon Sep 17 00:00:00 2001 From: Andrii Tsopych Date: Tue, 19 May 2026 12:39:37 +0300 Subject: [PATCH 16/18] push and pray again --- src/modules/ProductDetailsPage/ProductDetailsPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/ProductDetailsPage/ProductDetailsPage.tsx b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx index 5d3551f5575..a432a7d24ed 100644 --- a/src/modules/ProductDetailsPage/ProductDetailsPage.tsx +++ b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx @@ -120,7 +120,7 @@ export const ProductDetailsPage = () => { onClick={() => setMainImage(img)} > thumb
@@ -128,7 +128,7 @@ export const ProductDetailsPage = () => {
{product.name}
From 9b9005b7735abf388598040bdc7cdf4514154641 Mon Sep 17 00:00:00 2001 From: Andrii Tsopych Date: Tue, 19 May 2026 12:44:02 +0300 Subject: [PATCH 17/18] push and pray again2 --- src/api/getImageUrl.ts | 10 ++++++++++ .../ProductDetailsPage/ProductDetailsPage.tsx | 19 ++++++++++--------- 2 files changed, 20 insertions(+), 9 deletions(-) create mode 100644 src/api/getImageUrl.ts diff --git a/src/api/getImageUrl.ts b/src/api/getImageUrl.ts new file mode 100644 index 00000000000..313f2850c38 --- /dev/null +++ b/src/api/getImageUrl.ts @@ -0,0 +1,10 @@ +export const getImageUrl = (path: string) => { + if (!path) { + return ''; + } + + const cleanPath = path.startsWith('/') ? path.slice(1) : path; + + // Встав сюди назву свого репозиторію + return `/react_phone-catalog/${cleanPath}`; +}; diff --git a/src/modules/ProductDetailsPage/ProductDetailsPage.tsx b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx index a432a7d24ed..8869b10883f 100644 --- a/src/modules/ProductDetailsPage/ProductDetailsPage.tsx +++ b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx @@ -9,6 +9,8 @@ import { addToCart } from '../../store/cartSlice'; import { toggleFavorite } from '../../store/favoritesSlice'; import cn from 'classnames'; import styles from './ProductDetailsPage.module.scss'; +// Додаємо імпорт нашої функції (перевір шлях, якщо він відрізняється) +import { getImageUrl } from '../../api/getImageUrl'; const COLOR_HEX: Record = { black: '#1F2020', @@ -119,18 +121,14 @@ export const ProductDetailsPage = () => { })} onClick={() => setMainImage(img)} > - thumb + {/* ВИКОРИСТОВУЄМО getImageUrl */} + thumb ))}
- {product.name} + {/* ВИКОРИСТОВУЄМО getImageUrl */} + {product.name}
@@ -205,8 +203,11 @@ export const ProductDetailsPage = () => { dispatch(toggleFavorite(product as unknown as Product)) } > + {/* ВИКОРИСТОВУЄМО getImageUrl */} Favorite From 8b7bcf3f650d9ae4ba9a1d2e0a7c4783929a7ae2 Mon Sep 17 00:00:00 2001 From: Andrii Tsopych Date: Wed, 20 May 2026 13:08:13 +0300 Subject: [PATCH 18/18] add needed changes --- index.html | 4 +++- src/App.tsx | 5 ++++ .../Pagination/Pagination.module.scss | 7 ++++-- .../CatalogPage/CatalogPage.module.scss | 10 ++++++++ src/modules/CatalogPage/CatalogPage.tsx | 6 ++--- .../ContactsPage/ContactsPage.module.scss | 23 +++++++++++++++++++ src/modules/ContactsPage/ContactsPage.tsx | 14 +++++++++++ .../ProductDetailsPage/ProductDetailsPage.tsx | 6 +++-- src/modules/RightsPage/RightsPage.module.scss | 23 +++++++++++++++++++ src/modules/RightsPage/RightsPage.tsx | 14 +++++++++++ 10 files changed, 104 insertions(+), 8 deletions(-) create mode 100644 src/modules/ContactsPage/ContactsPage.module.scss create mode 100644 src/modules/ContactsPage/ContactsPage.tsx create mode 100644 src/modules/RightsPage/RightsPage.module.scss create mode 100644 src/modules/RightsPage/RightsPage.tsx diff --git a/index.html b/index.html index 095fb3a4537..32f4b0b90af 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,9 @@ - Vite + React + TS + Nice Gadgets + +
diff --git a/src/App.tsx b/src/App.tsx index 101f332a3d6..2d037989157 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,8 @@ import { NotFoundPage } from './modules/NotFoundPage/NotFoundPage'; import './App.scss'; import { Header } from './components/Header/Header'; import { Footer } from './components/Footer/Footer'; +import { ContactsPage } from './modules/ContactsPage/ContactsPage'; +import { RightsPage } from './modules/RightsPage/RightsPage'; export const App = () => { return ( @@ -37,6 +39,9 @@ export const App = () => { } /> } /> + } /> + } /> + } /> diff --git a/src/components/Pagination/Pagination.module.scss b/src/components/Pagination/Pagination.module.scss index 69d93fc8bc7..c3a0ec4c545 100644 --- a/src/components/Pagination/Pagination.module.scss +++ b/src/components/Pagination/Pagination.module.scss @@ -5,7 +5,10 @@ justify-content: center; align-items: center; gap: 16px; - margin-top: 40px; + // margin-top: 40px; + + margin-top: auto; + padding-top: 40px; &__list { display: flex; @@ -62,7 +65,7 @@ opacity: 0.5; cursor: not-allowed; } - + &:hover:not(:disabled) { border-color: $c-text-secondary; } diff --git a/src/modules/CatalogPage/CatalogPage.module.scss b/src/modules/CatalogPage/CatalogPage.module.scss index 6902c3115f5..88ccf4f84fb 100644 --- a/src/modules/CatalogPage/CatalogPage.module.scss +++ b/src/modules/CatalogPage/CatalogPage.module.scss @@ -6,9 +6,19 @@ margin: 0 auto; padding: 24px 16px 64px; + display: flex; + flex-direction: column; + min-height: 80vh; + &__breadcrumbs { margin-bottom: 24px; } + + &__list_container { + flex: 1; + display: flex; + flex-direction: column; + } &__title { font-size: 32px; diff --git a/src/modules/CatalogPage/CatalogPage.tsx b/src/modules/CatalogPage/CatalogPage.tsx index 16ab50b8669..5e39ab904ab 100644 --- a/src/modules/CatalogPage/CatalogPage.tsx +++ b/src/modules/CatalogPage/CatalogPage.tsx @@ -93,7 +93,7 @@ export const CatalogPage = ({ category }: Props) => { params.set('page', newPage.toString()); setSearchParams(params); - window.scrollTo({ top: 0, behavior: 'smooth' }); + // window.scrollTo({ top: 0, behavior: 'smooth' }); }; return ( @@ -157,7 +157,7 @@ export const CatalogPage = ({ category }: Props) => { )} {!isLoading && !isError && visibleProducts.length > 0 && ( - <> +
{perPage !== 'all' && ( @@ -168,7 +168,7 @@ export const CatalogPage = ({ category }: Props) => { onPageChange={handlePageChange} /> )} - +
)} ); diff --git a/src/modules/ContactsPage/ContactsPage.module.scss b/src/modules/ContactsPage/ContactsPage.module.scss new file mode 100644 index 00000000000..30fe610f81c --- /dev/null +++ b/src/modules/ContactsPage/ContactsPage.module.scss @@ -0,0 +1,23 @@ +@import '../../styles/variables'; + +.page { + max-width: 1200px; + margin: 0 auto; + padding: 48px 16px; + text-align: center; + flex: 1; + + &__title { + font-size: 32px; + font-weight: 800; + color: $c-text-primary; + margin-bottom: 24px; + } + + &__text { + font-size: 16px; + line-height: 1.5; + color: $c-text-secondary; + font-weight: 600; + } +} diff --git a/src/modules/ContactsPage/ContactsPage.tsx b/src/modules/ContactsPage/ContactsPage.tsx new file mode 100644 index 00000000000..704f797e3f6 --- /dev/null +++ b/src/modules/ContactsPage/ContactsPage.tsx @@ -0,0 +1,14 @@ +import styles from './ContactsPage.module.scss'; + +export const ContactsPage = () => { + return ( +
+

Contacts

+

+ This is a placeholder for the contacts page. +
+ Feel free to reach out to our support team! +

+
+ ); +}; diff --git a/src/modules/ProductDetailsPage/ProductDetailsPage.tsx b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx index 8869b10883f..1de43bb52e4 100644 --- a/src/modules/ProductDetailsPage/ProductDetailsPage.tsx +++ b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx @@ -9,7 +9,6 @@ import { addToCart } from '../../store/cartSlice'; import { toggleFavorite } from '../../store/favoritesSlice'; import cn from 'classnames'; import styles from './ProductDetailsPage.module.scss'; -// Додаємо імпорт нашої функції (перевір шлях, якщо він відрізняється) import { getImageUrl } from '../../api/getImageUrl'; const COLOR_HEX: Record = { @@ -104,7 +103,10 @@ export const ProductDetailsPage = () => {
- diff --git a/src/modules/RightsPage/RightsPage.module.scss b/src/modules/RightsPage/RightsPage.module.scss new file mode 100644 index 00000000000..30fe610f81c --- /dev/null +++ b/src/modules/RightsPage/RightsPage.module.scss @@ -0,0 +1,23 @@ +@import '../../styles/variables'; + +.page { + max-width: 1200px; + margin: 0 auto; + padding: 48px 16px; + text-align: center; + flex: 1; + + &__title { + font-size: 32px; + font-weight: 800; + color: $c-text-primary; + margin-bottom: 24px; + } + + &__text { + font-size: 16px; + line-height: 1.5; + color: $c-text-secondary; + font-weight: 600; + } +} diff --git a/src/modules/RightsPage/RightsPage.tsx b/src/modules/RightsPage/RightsPage.tsx new file mode 100644 index 00000000000..e4148444a74 --- /dev/null +++ b/src/modules/RightsPage/RightsPage.tsx @@ -0,0 +1,14 @@ +import styles from './RightsPage.module.scss'; + +export const RightsPage = () => { + return ( +
+

Rights

+

+ All rights reserved © Nice Gadgets 2026. +
+ Terms and conditions apply. +

+
+ ); +};