diff --git a/.stylelintrc.js b/.stylelintrc.js
index f3a4e74272a..7942cce0712 100644
--- a/.stylelintrc.js
+++ b/.stylelintrc.js
@@ -1,4 +1,6 @@
module.exports = {
extends: "@mate-academy/stylelint-config",
- rules: {}
+ rules: {
+ "scss/at-mixin-pattern": null,
+ }
};
diff --git a/index.html b/index.html
index 095fb3a4537..186d6184833 100644
--- a/index.html
+++ b/index.html
@@ -3,7 +3,8 @@
- Vite + React + TS
+ Nice Gadgets Store
+
diff --git a/package-lock.json b/package-lock.json
index 836b9e63b46..6cebd392cd6 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-redux": "^9.2.0",
"react-router-dom": "^6.25.1",
"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,6 +1878,32 @@
"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",
@@ -2126,6 +2155,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 +2257,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 +2299,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",
@@ -5936,6 +5983,16 @@
"node": ">= 4"
}
},
+ "node_modules/immer": {
+ "version": "11.1.3",
+ "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz",
+ "integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==",
+ "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 +8791,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",
@@ -8893,6 +8973,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 +9071,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 +10539,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..62864f805ea 100644
--- a/package.json
+++ b/package.json
@@ -1,22 +1,23 @@
{
"name": "react_phone-catalog",
- "homepage": "react_phone-catalog",
"version": "0.1.0",
"keywords": [],
"author": "Mate Academy",
"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-redux": "^9.2.0",
"react-router-dom": "^6.25.1",
"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",
@@ -55,7 +56,7 @@
"format": "prettier --write './src/**/*.{ts,tsx}'",
"lint": "npm run style-format && npm run format && npm run lint-js && npm run lint-css",
"update": "mate-scripts update",
- "postinstall": "npm run update && cypress verify",
+ "postinstall": "npm run update",
"predeploy": "npm run build",
"deploy": "mate-scripts deploy"
},
diff --git a/src/App.scss b/src/App.scss
index 71bc413aade..aced522fd01 100644
--- a/src/App.scss
+++ b/src/App.scss
@@ -1 +1,12 @@
-// not empty
+// not empty
+.App {
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+
+}
+
+.main {
+ flex: 1;
+ padding-inline: 32px;
+}
diff --git a/src/App.tsx b/src/App.tsx
index 372e4b42066..d4ad883927c 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,7 +1,32 @@
+import { Route, Routes } from 'react-router-dom';
import './App.scss';
+import { Footer } from './components/Footer';
+import { Header } from './components/Header';
+import { HomePage } from './modules/HomePage';
+import { ProductsPage } from './modules/ProductsPage';
+import { ProductDetailsPage } from './modules/ProductDetailsPage';
+import { FavoritesPage } from './modules/FavoritesPage';
+import { CartPage } from './modules/CartPage';
+import { NotFoundPage } from './modules/NotFoundPage';
export const App = () => (
-
Product Catalog
+
+
+
+ } />
+ } />
+ } />
+ }
+ />
+ } />
+ } />
+ } />
+ } />
+
+
+
);
diff --git a/src/api/phone.ts b/src/api/phone.ts
new file mode 100644
index 00000000000..a9490b735fb
--- /dev/null
+++ b/src/api/phone.ts
@@ -0,0 +1,8 @@
+import { PhoneType } from '../features/types/phoneType';
+import { getData } from '../features/utils/client';
+
+export async function getPhones(): Promise {
+ const phones = await getData('/api/phones.json');
+
+ return phones;
+}
diff --git a/src/api/products.ts b/src/api/products.ts
new file mode 100644
index 00000000000..2494a60547b
--- /dev/null
+++ b/src/api/products.ts
@@ -0,0 +1,35 @@
+import { ProductDetails } from '../features/types/productDetailsType';
+import { Product, ProductCategory } from '../features/types/productType';
+import { getData } from '../features/utils/client';
+
+const DETAILS_FILE_BY_CATEGORY: Record = {
+ phones: '/api/phones.json',
+ tablets: '/api/tablets.json',
+ accessories: '/api/accessories.json',
+};
+
+export function getProducts(): Promise {
+ return getData('/api/products.json');
+}
+
+export async function getProductDetails(
+ category: ProductCategory,
+ productId: string,
+): Promise {
+ const url = DETAILS_FILE_BY_CATEGORY[category];
+
+ const res = await fetch(url);
+
+ if (!res.ok) {
+ throw new Error('Failed to load details');
+ }
+
+ const all: ProductDetails[] = await res.json();
+ const found = all.find(p => p.id === productId);
+
+ if (!found) {
+ throw new Error('Product not found');
+ }
+
+ return found;
+}
diff --git a/src/app/hooks.ts b/src/app/hooks.ts
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/src/app/store.ts b/src/app/store.ts
new file mode 100644
index 00000000000..90f416ea1a2
--- /dev/null
+++ b/src/app/store.ts
@@ -0,0 +1,26 @@
+import { configureStore, Middleware } from '@reduxjs/toolkit';
+import favoritesReducer from '../features/slices/favorites/favoritesSlice';
+import cartListReducer from '../features/slices/cartSlice/cartSlice';
+
+const localStorageMiddleware: Middleware = store => next => action => {
+ const result = next(action);
+
+ const state = store.getState();
+
+ localStorage.setItem('favorites', JSON.stringify(state.favorites.items));
+ localStorage.setItem('cartList', JSON.stringify(state.cartList.items));
+
+ return result;
+};
+
+export const store = configureStore({
+ reducer: {
+ favorites: favoritesReducer,
+ cartList: cartListReducer,
+ },
+ middleware: getDefaultMiddleware =>
+ getDefaultMiddleware().concat(localStorageMiddleware),
+});
+
+export type RootState = ReturnType;
+export type AppDispatch = typeof store.dispatch;
diff --git a/src/assets/icons/Favourites.svg b/src/assets/icons/Favourites.svg
new file mode 100644
index 00000000000..ca57cfedd8a
--- /dev/null
+++ b/src/assets/icons/Favourites.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/assets/icons/Home.svg b/src/assets/icons/Home.svg
new file mode 100644
index 00000000000..474476cb027
--- /dev/null
+++ b/src/assets/icons/Home.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/assets/icons/Logo.svg b/src/assets/icons/Logo.svg
new file mode 100644
index 00000000000..fc41060097b
--- /dev/null
+++ b/src/assets/icons/Logo.svg
@@ -0,0 +1,25 @@
+
diff --git a/src/assets/icons/ShoppingBag.svg b/src/assets/icons/ShoppingBag.svg
new file mode 100644
index 00000000000..6030970f2e9
--- /dev/null
+++ b/src/assets/icons/ShoppingBag.svg
@@ -0,0 +1,5 @@
+
diff --git a/src/assets/icons/Union.svg b/src/assets/icons/Union.svg
new file mode 100644
index 00000000000..0a33fa8d98e
--- /dev/null
+++ b/src/assets/icons/Union.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/components/BannerSlider/BannerSlider.module.scss b/src/components/BannerSlider/BannerSlider.module.scss
new file mode 100644
index 00000000000..2ffa8a626de
--- /dev/null
+++ b/src/components/BannerSlider/BannerSlider.module.scss
@@ -0,0 +1,137 @@
+@import '../../styles/global';
+
+$arrow-size: 32px;
+$slider-min: 189px;
+$slider-max: 400px;
+
+.slider {
+ grid-column: 1 / -1;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+
+
+ &__frame {
+ width: 100%;
+ aspect-ratio: 1 / 1;
+
+ @include onTablet {
+ height: clamp($slider-min, 35vw, $slider-max);
+ aspect-ratio: auto;
+
+ @include innerGrid;
+ }
+ }
+
+ &__viewport {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ overflow: auto hidden;
+ background-color: $colors-primary;
+ scrollbar-width: none;
+ scroll-snap-type: x mandatory;
+
+ @include onTablet {
+ grid-column: 2 / -2;
+ overflow: hidden;
+ border-radius: 16px;
+ scroll-snap-type: none;
+ }
+ }
+
+ &__viewport::-webkit-scrollbar {
+ display: none;
+ }
+
+ &__track {
+ display: flex;
+ width: 100%;
+ height: 100%;
+
+ @include onTablet {
+ transition: transform 0.6s ease;
+ will-change: transform;
+ }
+ }
+
+ &__slide {
+ position: relative;
+ min-width: 100%;
+ height: 100%;
+ overflow: hidden;
+ scroll-snap-align: start;
+ }
+
+ &__image {
+ display: block;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ object-position: center;
+ }
+
+ &__arrow {
+ display: none;
+
+ @include onTablet {
+ width: 100%;
+ height: 100%;
+ padding: 0;
+ border: 1px solid $colors-icons;
+ background: none;
+ display: grid;
+ place-items: center;
+ place-self: stretch stretch;
+ stroke: $colors-primary;
+ cursor: pointer;
+ transition:
+ border-color 0.3s ease,
+ transform 0.3s ease;
+
+ &:hover {
+ border-color: $colors-primary;
+ }
+
+ &:active {
+ transform: scale(0.98);
+ }
+
+ &:first-child {
+ grid-column: 1 / 2;
+ }
+
+ &:last-child {
+ grid-column: -2 / -1;
+ }
+ }
+ }
+
+ &__dots {
+ display: flex;
+ justify-content: center;
+ gap: 8px;
+ }
+
+ &__dot {
+ width: 18px;
+ height: 4px;
+ padding: 0;
+ border: none;
+ border-radius: 999px;
+ background: $colors-elements;
+ cursor: pointer;
+ transition:
+ background-color 0.3s ease,
+ transform 0.3s ease;
+
+ &:hover {
+ background: $colors-secondary;
+ }
+ }
+
+ &__dotActive {
+ background: $colors-primary;
+ transform: scaleX(1.15);
+ }
+}
diff --git a/src/components/BannerSlider/BannerSlider.tsx b/src/components/BannerSlider/BannerSlider.tsx
new file mode 100644
index 00000000000..b2bf107428c
--- /dev/null
+++ b/src/components/BannerSlider/BannerSlider.tsx
@@ -0,0 +1,148 @@
+import { useEffect, useRef, useState } from 'react';
+import styles from './BannerSlider.module.scss';
+import { Chevron } from '../icons/Chevron';
+
+const banners = [
+ {
+ id: 0,
+ img: '/img/banner-accessories.png',
+ title: 'Top accessories',
+ },
+ {
+ id: 1,
+ img: '/img/banner-phones.png',
+ title: 'Latest smartphones',
+ },
+ {
+ id: 2,
+ img: '/img/banner-tablets.png',
+ title: 'Tablets for work and play',
+ },
+];
+
+export const BannerSlider = () => {
+ const [index, setIndex] = useState(0);
+ const [isMobile, setIsMobile] = useState(window.innerWidth < 640);
+ const viewportRef = useRef(null);
+
+ useEffect(() => {
+ const handleResize = () => {
+ setIsMobile(window.innerWidth < 640);
+ };
+
+ window.addEventListener('resize', handleResize);
+
+ return () => {
+ window.removeEventListener('resize', handleResize);
+ };
+ }, []);
+
+ useEffect(() => {
+ const id = window.setInterval(() => {
+ setIndex(current => (current + 1) % banners.length);
+ }, 5000);
+
+ return () => window.clearInterval(id);
+ }, []);
+
+ useEffect(() => {
+ const viewport = viewportRef.current;
+
+ if (!viewport) {
+ return;
+ }
+
+ if (isMobile) {
+ viewport.scrollTo({
+ left: viewport.clientWidth * index,
+ behavior: 'smooth',
+ });
+ }
+ }, [index, isMobile]);
+
+ const prev = () =>
+ setIndex(current => (current - 1 + banners.length) % banners.length);
+
+ const next = () => setIndex(current => (current + 1) % banners.length);
+
+ const handleDotClick = (nextIndex: number) => {
+ setIndex(nextIndex);
+ };
+
+ const handleMobileScroll = () => {
+ const viewport = viewportRef.current;
+
+ if (!viewport || window.innerWidth >= 640) {
+ return;
+ }
+
+ const nextIndex = Math.round(viewport.scrollLeft / viewport.clientWidth);
+
+ if (nextIndex !== index) {
+ setIndex(nextIndex);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ {banners.map(banner => (
+
+
+
+ ))}
+
+
+
+
+
+
+
+ {banners.map((banner, i) => (
+
+
+ );
+};
diff --git a/src/components/BannerSlider/index.ts b/src/components/BannerSlider/index.ts
new file mode 100644
index 00000000000..c29cbd6c894
--- /dev/null
+++ b/src/components/BannerSlider/index.ts
@@ -0,0 +1 @@
+export * from './BannerSlider';
diff --git a/src/components/Breadcrumbs/Breadcrumbs.module.scss b/src/components/Breadcrumbs/Breadcrumbs.module.scss
new file mode 100644
index 00000000000..a5d56a5722f
--- /dev/null
+++ b/src/components/Breadcrumbs/Breadcrumbs.module.scss
@@ -0,0 +1,56 @@
+@import '../../styles/global';
+
+.breadcrumbs {
+ grid-column: 1 / -1;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-top: 24px;
+ min-height: 16px;
+
+ &__home,
+ &__chevron,
+ &__category,
+ &__product {
+ display: flex;
+ align-items: center;
+ text-decoration: none;
+ }
+
+ &__homeImg {
+ display: block;
+ width: 16px;
+ height: 16px;
+ transform: translateY(-0.5px);
+ }
+
+ &__chevron {
+ stroke: $colors-secondary;
+ }
+
+ &__category,
+ &__product {
+ font-weight: 600;
+ font-size: 12px;
+ text-decoration: none;
+ line-height: 1;
+ margin: 0;
+ transform: translateY(1px);
+ }
+
+ &__categoryActive {
+ color: $colors-primary;
+ }
+
+ &__categoryMuted {
+ color: $colors-secondary;
+ }
+
+ &__product {
+ max-width: 380px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: $colors-secondary;
+ }
+}
diff --git a/src/components/Breadcrumbs/Breadcrumbs.tsx b/src/components/Breadcrumbs/Breadcrumbs.tsx
new file mode 100644
index 00000000000..4cf6f8f8c9b
--- /dev/null
+++ b/src/components/Breadcrumbs/Breadcrumbs.tsx
@@ -0,0 +1,61 @@
+import { NavLink, useLocation } from 'react-router-dom';
+import classNames from 'classnames';
+import styles from './Breadcrumbs.module.scss';
+import home from '../../assets/icons/Home.svg';
+import { Chevron } from '../icons/Chevron';
+
+type Props = {
+ productName?: string;
+};
+
+export const Breadcrumbs: React.FC = ({ productName }) => {
+ const location = useLocation();
+ const parts = location.pathname.split('/').filter(Boolean);
+
+ const [category, productSlug] = parts;
+
+ const formattedCategory = category
+ ? category.replace(/\b\w/g, char => char.toUpperCase())
+ : '';
+
+ if (!category) {
+ return null;
+ }
+
+ return (
+
+ );
+};
diff --git a/src/components/Breadcrumbs/index.ts b/src/components/Breadcrumbs/index.ts
new file mode 100644
index 00000000000..ce977548b14
--- /dev/null
+++ b/src/components/Breadcrumbs/index.ts
@@ -0,0 +1 @@
+export * from './Breadcrumbs';
diff --git a/src/components/CartItemCard/CartItemCard.module.scss b/src/components/CartItemCard/CartItemCard.module.scss
new file mode 100644
index 00000000000..d9f4c6d9ac0
--- /dev/null
+++ b/src/components/CartItemCard/CartItemCard.module.scss
@@ -0,0 +1,129 @@
+@import '../../styles/global';
+
+.cartItem {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding: 24px;
+ border: 1px solid $colors-elements;
+ justify-content: space-between;
+
+ @include onTablet {
+ flex-direction: row;
+ align-items: center;
+ gap: 0;
+ }
+
+ &__part {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+
+
+ &:first-child {
+ flex: 1;
+ min-width: 0;
+ }
+
+ &:nth-child(2) {
+ justify-content: space-between;
+
+ @include onTablet {
+ justify-content: flex-start;
+ }
+ }
+
+ @include onDesktop {
+ gap: 24px;
+ }
+ }
+
+ &__removeButton {
+ border: none;
+ background: none;
+ color: $colors-icons;
+ transition: all 0.3s ease;
+
+ &:hover {
+ cursor: pointer;
+ color: $colors-primary;
+ transform: scale(1.1);
+ }
+ }
+
+ &__link {
+ display: flex;
+ flex-direction: row;
+ gap: 24px;
+ text-decoration: none;
+ align-items: center;
+ min-width: 0;
+ transition: transform 0.3s ease;
+
+ &:hover {
+ transform: scale(1.01);
+ }
+ }
+
+ &__imageWrapper {
+ width: 66px;
+ height: 66px;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+
+ @include onDesktop {
+ height: 80px;
+ width: 80px;
+ }
+ }
+
+ &__image {
+ max-width: 100%;
+ max-height: 100%;
+ width: auto;
+ height: auto;
+ object-fit: contain;
+ }
+
+ &__name {
+ color: $colors-primary;
+ min-width: 128px;
+ max-width: 336px;
+
+ @include body-text;
+
+ @include onTablet {
+ width: 176px;
+ }
+
+ @include onDesktop {
+ width: 336px;
+ }
+ }
+
+ &__quantity {
+ display: flex;
+ flex-direction: row;
+ gap: 14px;
+ align-items: center;
+ }
+
+ &__quantityValue {
+ min-width: 2ch;
+ text-align: center;
+
+ @include body-text;
+ }
+
+ &__price {
+ color: $colors-primary;
+ min-width: 80px;
+ text-align: right;
+ flex-shrink: 0;
+
+ @include h3-text;
+ }
+}
diff --git a/src/components/CartItemCard/CartItemCard.tsx b/src/components/CartItemCard/CartItemCard.tsx
new file mode 100644
index 00000000000..16a1460a911
--- /dev/null
+++ b/src/components/CartItemCard/CartItemCard.tsx
@@ -0,0 +1,72 @@
+import styles from './CartItemCard.module.scss';
+import React from 'react';
+import { Product } from '../../features/types/productType';
+import { Link } from 'react-router-dom';
+import { SecondaryButton } from '../SecondaryButton';
+
+type Props = {
+ product: Product;
+ quantity: number;
+ onRemove: () => void;
+ onDecrease: () => void;
+ onIncrease: () => void;
+};
+
+export const CartItemCard: React.FC = ({
+ product,
+ quantity,
+ onRemove,
+ onDecrease,
+ onIncrease,
+}) => {
+ return (
+
+
+
+
+
+
+

+
+
+
{product.name}
+
+
+
+
+
+
+ -
+
+
+
{quantity}
+
+
+
+
+
+
${product.price}
+
+
+ );
+};
diff --git a/src/components/CartItemCard/index.ts b/src/components/CartItemCard/index.ts
new file mode 100644
index 00000000000..7488f63aac8
--- /dev/null
+++ b/src/components/CartItemCard/index.ts
@@ -0,0 +1 @@
+export * from './CartItemCard';
diff --git a/src/components/CheckoutModal/CheckoutModal.module.scss b/src/components/CheckoutModal/CheckoutModal.module.scss
new file mode 100644
index 00000000000..6722e98412c
--- /dev/null
+++ b/src/components/CheckoutModal/CheckoutModal.module.scss
@@ -0,0 +1,62 @@
+@import '../../styles/global';
+
+.modal {
+ position: fixed;
+ inset: 0;
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 16px;
+ background-color: rgba(0, 0, 0, 0.45);
+
+ &__content {
+ width: 100%;
+ max-width: 420px;
+ padding: 24px;
+ border: 1px solid $colors-elements;
+ background-color: $colors-white;
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
+ }
+
+ &__title {
+ margin: 0 0 12px;
+ color: $colors-primary;
+
+ @include h3-text;
+ }
+
+ &__text {
+ margin: 0 0 24px;
+ color: $colors-secondary;
+
+ @include body-text;
+ }
+
+ &__actions {
+ display: flex;
+ gap: 12px;
+ justify-content: flex-end;
+ }
+
+ &__buttonSecondary,
+ &__buttonPrimary {
+ height: 40px;
+ padding: 0 40px;
+ border: none;
+ cursor: pointer;
+
+ @include button-text;
+ }
+
+ &__buttonSecondary {
+ background-color: $colors-white;
+ border: 1px solid $colors-elements;
+ color: $colors-primary;
+ }
+
+ &__buttonPrimary {
+ background-color: $colors-primary;
+ color: $colors-white;
+ }
+}
diff --git a/src/components/CheckoutModal/CheckoutModal.tsx b/src/components/CheckoutModal/CheckoutModal.tsx
new file mode 100644
index 00000000000..a0bc666c8b2
--- /dev/null
+++ b/src/components/CheckoutModal/CheckoutModal.tsx
@@ -0,0 +1,39 @@
+import styles from './CheckoutModal.module.scss';
+
+type Props = {
+ onClose: () => void;
+ onConfirm: () => void;
+};
+
+export const CheckoutModal: React.FC = ({ onClose, onConfirm }) => {
+ return (
+
+
event.stopPropagation()}
+ >
+
Checkout is not implemented yet
+
+
Do you want to clear the Cart?
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/CheckoutModal/index.ts b/src/components/CheckoutModal/index.ts
new file mode 100644
index 00000000000..df7c4cd8c2f
--- /dev/null
+++ b/src/components/CheckoutModal/index.ts
@@ -0,0 +1 @@
+export * from './CheckoutModal';
diff --git a/src/components/DropDown/DropDown.module.scss b/src/components/DropDown/DropDown.module.scss
new file mode 100644
index 00000000000..fbf1ca0c5b8
--- /dev/null
+++ b/src/components/DropDown/DropDown.module.scss
@@ -0,0 +1,102 @@
+@import '../../styles/global';
+
+.dropdown {
+ position: relative;
+ width: 100%;
+ box-sizing: border-box;
+
+ &__title {
+ margin: 0 0 4px;
+
+ @include small-text;
+
+ color: $colors-secondary;
+ }
+
+ &__button {
+ display: flex;
+ align-items: center;
+ position: relative;
+ width: 100%;
+ height: 40px;
+ border: 1px solid $colors-icons;
+ background: none;
+
+ padding: 0 40px 0 12px;
+
+ @include button-text;
+
+ color: $colors-primary;
+
+ &:hover {
+ cursor: pointer;
+ border: 1px solid $colors-secondary;
+ }
+
+ }
+
+ &__buttonActive {
+ border: 1px solid $colors-primary;
+ }
+
+ &__value {
+ display: block;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ &__chevron {
+ position: absolute;
+ stroke: $colors-icons;
+ right: 12px;
+ top: 50%;
+ transform: translateY(-50%);
+ }
+
+ &__content {
+ position: absolute;
+ top: calc(100% + 6px);
+ left: 0;
+ width: 100%;
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+
+ border: 1px solid $colors-elements;
+ background: $colors-white;
+ box-shadow: 0 2px 15px rgba(0, 0, 0, 0.05);
+
+ transform-origin: top;
+ transition:
+ opacity 160ms ease,
+ transform 160ms ease;
+ }
+
+ &__option {
+ padding: 8px 12px;
+ color: $colors-secondary;
+ transition: all 0.3s ease;
+
+ &:hover {
+ cursor: pointer;
+ color: $colors-primary;
+ background-color: $colors-hover-bg;
+ }
+ }
+
+}
+
+
+.open {
+ opacity: 1;
+ transform: translateY(0) scaleY(1);
+ pointer-events: auto;
+}
+
+.closed {
+ opacity: 0;
+ transform: translateY(-6px) scaleY(0.98);
+ pointer-events: none;
+}
diff --git a/src/components/DropDown/DropDown.tsx b/src/components/DropDown/DropDown.tsx
new file mode 100644
index 00000000000..9ed813a06a8
--- /dev/null
+++ b/src/components/DropDown/DropDown.tsx
@@ -0,0 +1,112 @@
+import { useEffect, useRef, useState } from 'react';
+import styles from './DropDown.module.scss';
+import classNames from 'classnames';
+import { Chevron } from '../icons/Chevron';
+
+const ANIMATION_MS = 160;
+
+export type DropdownOption = {
+ value: T;
+ label: string;
+};
+
+type Props = {
+ title: string;
+ value: T;
+ options: readonly DropdownOption[];
+ onChange: (value: T) => void;
+};
+
+export function Dropdown({
+ title,
+ value,
+ options,
+ onChange,
+}: Props) {
+ const [isOpen, setIsOpen] = useState(false);
+ const [isRender, setIsRender] = useState(false);
+
+ const dropdownRef = useRef(null);
+
+ useEffect(() => {
+ if (isOpen) {
+ setIsRender(true);
+
+ return;
+ }
+
+ const t = window.setTimeout(() => setIsRender(false), ANIMATION_MS);
+
+ return () => window.clearTimeout(t);
+ }, [isOpen]);
+
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (!dropdownRef.current) {
+ return;
+ }
+
+ if (!dropdownRef.current.contains(event.target as Node)) {
+ setIsOpen(false);
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, []);
+
+ const selected = options.find(o => o.value === value);
+ const buttonLabel = selected ? selected.label : value;
+ const listboxId = `${title.replace(/\s+/g, '-').toLowerCase()}-listbox`;
+
+ const handleSelect = (nextValue: T) => {
+ onChange(nextValue);
+ setIsOpen(false);
+ };
+
+ return (
+
+
{title}
+
+ {isRender && (
+
+ {options.map(option => (
+ - handleSelect(option.value)}
+ className={styles.dropdown__option}
+ >
+ {option.label}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/components/DropDown/index.ts b/src/components/DropDown/index.ts
new file mode 100644
index 00000000000..69092242f78
--- /dev/null
+++ b/src/components/DropDown/index.ts
@@ -0,0 +1 @@
+export * from './DropDown';
diff --git a/src/components/Footer/Footer.module.scss b/src/components/Footer/Footer.module.scss
new file mode 100644
index 00000000000..b96a9784ec2
--- /dev/null
+++ b/src/components/Footer/Footer.module.scss
@@ -0,0 +1,87 @@
+@import '../../styles/global';
+
+.footer {
+ background-color: $colors-white;
+ border-top: 1px solid $colors-elements;
+ margin-top: 64px;
+ min-height: 257px;
+
+ display: flex;
+ align-items: stretch;
+
+ @include onTablet {
+ min-height: 96px;
+ }
+
+ @include onDesktop {
+ margin-top: 80px;
+ }
+}
+
+.container {
+ min-height: inherit;
+ width: 100%;
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 0 32px;
+
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 32px;
+
+ @include onTablet {
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+ }
+}
+
+.logo {
+ display: inline-flex;
+ transition: transform 0.3s ease;
+}
+
+.logo:hover {
+ transform: scale(1.1);
+}
+
+.mainLinks {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+
+ @include onTablet {
+ flex-flow: row wrap;
+ gap: clamp(24px, 6vw, 106px);
+ }
+
+ &__link {
+ @include uppercase-text;
+
+ text-decoration: none;
+ color: $colors-secondary;
+
+ transition: transform 0.3s ease;
+
+ &:hover {
+ color: #6c6c6c;
+ transform: translateY(-1px) scale(1.01);
+ }
+ }
+}
+
+.backToTop {
+ display: flex;
+ flex-direction: row;
+ gap: 16px;
+ align-self: center;
+ align-items: center;
+
+ &__text {
+ @include small-text;
+
+ color: $colors-secondary;
+ }
+
+}
diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx
new file mode 100644
index 00000000000..afc48f0e056
--- /dev/null
+++ b/src/components/Footer/Footer.tsx
@@ -0,0 +1,60 @@
+import { Link } from 'react-router-dom';
+import styles from './Footer.module.scss';
+import logo from '../../assets/icons/Logo.svg';
+import { Chevron } from '../icons/Chevron';
+import { SecondaryButton } from '../SecondaryButton';
+
+export const Footer = () => {
+ const handleBackToTop = () => {
+ window.scrollTo({
+ top: 0,
+ behavior: 'smooth',
+ });
+ };
+
+ return (
+
+ );
+};
diff --git a/src/components/Footer/index.ts b/src/components/Footer/index.ts
new file mode 100644
index 00000000000..ddcc5a9cd18
--- /dev/null
+++ b/src/components/Footer/index.ts
@@ -0,0 +1 @@
+export * from './Footer';
diff --git a/src/components/Header/Header.module.scss b/src/components/Header/Header.module.scss
new file mode 100644
index 00000000000..cd427ac740c
--- /dev/null
+++ b/src/components/Header/Header.module.scss
@@ -0,0 +1,230 @@
+@import '../../styles/global';
+
+.header {
+ position: sticky;
+ top: 0;
+ z-index: 1100;
+ height: var(--header-height);
+ background-color: $colors-white;
+ border-bottom: 1px solid $colors-elements;
+
+ @include onTablet {
+ height: var(--header-height);
+ }
+
+ &__container {
+ margin: 0 auto;
+ padding: 0 24px;
+
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+
+ height: 100%;
+ padding-right: 0;
+ }
+
+ &__left {
+ display: flex;
+ align-items: center;
+ height: 100%;
+ gap: 48px;
+
+ @include onTablet {
+ gap: 32px;
+ }
+ }
+
+ &__logo {
+ display: flex;
+ transition: transform 0.3s ease;
+ width: 80px;
+ height: auto;
+
+ @include onTablet {
+ width: 64px;
+ }
+
+ &:hover {
+ transform: scale(1.1);
+ }
+
+ img {
+ width: 100%;
+ height: auto;
+ display: block;
+ }
+ }
+
+ &__nav {
+ display: none;
+ height: 100%;
+
+ @include onTablet {
+ display: flex;
+ gap: 32px;
+ }
+
+ @include onDesktop {
+ display: flex;
+ gap: 64px;
+ }
+ }
+
+ &__navLink {
+ position: relative;
+ height: 100%;
+ display: flex;
+ align-items: center;
+
+ @include uppercase-text;
+
+ text-decoration: none;
+ color: $colors-secondary;
+
+ transition: color 0.3s ease;
+
+ &::after {
+ content: '';
+ position: absolute;
+ left: -2px;
+ bottom: 0;
+
+ width: 110%;
+ height: 3px;
+ background-color: $colors-primary;
+
+ transform: scaleX(0);
+ transform-origin: left;
+ transition: transform 0.3s ease;
+ }
+
+ &:hover {
+ color: $colors-primary;
+ }
+
+ &:hover::after {
+ transform: scaleX(1);
+ }
+ }
+
+ &__navLinkActive {
+ color: $colors-primary;
+ text-decoration: none;
+
+ &::after {
+ transform: scaleX(1);
+ }
+ }
+
+ &__right {
+ display: none;
+ align-items: center;
+
+ @include onTablet {
+ display: flex;
+ }
+ }
+
+ &__iconLink {
+ position: relative;
+ width: var(--header-height);
+ height: var(--header-height);
+
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ border-left: 1px solid $colors-elements;
+ text-decoration: none;
+
+
+ &::after {
+ content: '';
+ position: absolute;
+ left: 0;
+ bottom: 0;
+
+ width: 100%;
+ height: 3px;
+ background-color: $colors-primary;
+
+ transform: scaleX(0);
+ transform-origin: left;
+ transition: transform 0.3s ease;
+ }
+
+ &>* {
+ transform: scale(1.05);
+ }
+
+ &:hover>* {
+ transform: scale(1.1);
+ }
+
+ &:hover::after {
+ transform: scaleX(1);
+ }
+ }
+
+ &__iconLinkActive {
+ &::after {
+ transform: scaleX(1);
+ }
+ }
+
+ &__burgerContainer {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ width: auto;
+ height: 100%;
+ aspect-ratio: 1 / 1;
+ box-shadow: -1px 0 0 $colors-elements;
+
+ @include onTablet {
+ display: none;
+ }
+
+ @include onDesktop {
+ display: none;
+ }
+ }
+
+ &__burger {
+ width: 16px;
+ height: 14px;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ padding: 0;
+ background: none;
+ border: none;
+ cursor: pointer;
+
+ span {
+ height: 2px;
+ width: 100%;
+ background: $colors-primary;
+ border-radius: 2px;
+
+ transition: transform 0.3s ease, opacity 0.3s ease;
+ }
+
+ }
+
+ &__burgerOpen {
+ span:nth-child(1) {
+ transform: translateY(6px) rotate(45deg);
+ }
+
+ span:nth-child(2) {
+ opacity: 0;
+ }
+
+ span:nth-child(3) {
+ transform: translateY(-6px) rotate(-45deg);
+ }
+ }
+}
diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx
new file mode 100644
index 00000000000..5a725938185
--- /dev/null
+++ b/src/components/Header/Header.tsx
@@ -0,0 +1,95 @@
+import { Link, NavLink } from 'react-router-dom';
+import styles from './Header.module.scss';
+import logo from '../../assets/icons/Logo.svg';
+import favorites from '../../assets/icons/Favourites.svg';
+import shoppingBag from '../../assets/icons/ShoppingBag.svg';
+import { useState } from 'react';
+import { MobileMenu } from './MobileMenu/MobileMenu';
+import { useSelector } from 'react-redux';
+import { RootState } from '../../app/store';
+import { IconWithBadge } from '../icons/IconWithBadge';
+import classNames from 'classnames';
+import { cartItemsCount } from '../../features/utils/selectors';
+
+export const Header = () => {
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+
+ const getNavClass = ({ isActive }: { isActive: boolean }) =>
+ classNames(styles.header__navLink, {
+ [styles.header__navLinkActive]: isActive,
+ });
+
+ const getIconClass = ({ isActive }: { isActive: boolean }) =>
+ classNames(styles.header__iconLink, {
+ [styles.header__iconLinkActive]: isActive,
+ });
+
+ const favoriteItems = useSelector(
+ (state: RootState) => state.favorites.items,
+ );
+
+ const cartItems = useSelector(cartItemsCount);
+
+ return (
+ <>
+
+ setIsMenuOpen(false)} />
+ >
+ );
+};
diff --git a/src/components/Header/MobileMenu/MobileMenu.module.scss b/src/components/Header/MobileMenu/MobileMenu.module.scss
new file mode 100644
index 00000000000..883e9a4e5a9
--- /dev/null
+++ b/src/components/Header/MobileMenu/MobileMenu.module.scss
@@ -0,0 +1,76 @@
+@import '../../../styles/global';
+
+.mobileMenu {
+ position: fixed;
+ inset: var(--header-height) 0 0;
+ z-index: 1000;
+ background: $colors-white;
+ display: flex;
+ flex-direction: column;
+
+ &__nav {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 32px;
+ margin-top: 24px;
+ }
+
+ &__bottom {
+ margin-top: auto;
+ display: flex;
+ }
+
+ &__navLink {
+ text-decoration: none;
+ color: $colors-secondary;
+
+ @include uppercase-text;
+ }
+
+ &__navLinkActive {
+ position: relative;
+ color: $colors-primary;
+
+ &::after {
+ content: '';
+ position: absolute;
+ left: 0;
+ bottom: -11px;
+ width: 100%;
+ height: 2px;
+ background: $colors-primary;
+ }
+ }
+
+ &__iconLink {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 50%;
+ height: 64px;
+ text-decoration: none;
+ border-top: 1px solid $colors-elements;
+
+ &:not(:first-child) {
+ border-left: 1px solid $colors-elements;
+ }
+ }
+
+ &__iconLinkActive {
+ &>* {
+ transform: scale(1.05);
+ }
+
+ &::after {
+ content: '';
+ position: absolute;
+ left: 0;
+ bottom: 0;
+ width: 100%;
+ height: 2px;
+ background: $colors-primary;
+ }
+ }
+}
diff --git a/src/components/Header/MobileMenu/MobileMenu.tsx b/src/components/Header/MobileMenu/MobileMenu.tsx
new file mode 100644
index 00000000000..837c949f7b7
--- /dev/null
+++ b/src/components/Header/MobileMenu/MobileMenu.tsx
@@ -0,0 +1,87 @@
+import styles from './MobileMenu.module.scss';
+import favorites from '../../../assets/icons/Favourites.svg';
+import shoppingBag from '../../../assets/icons/ShoppingBag.svg';
+import { NavLink } from 'react-router-dom';
+import React, { useEffect } from 'react';
+import { useSelector } from 'react-redux';
+import { RootState } from '../../../app/store';
+import { IconWithBadge } from '../../icons/IconWithBadge';
+import classNames from 'classnames';
+import { cartItemsCount } from '../../../features/utils/selectors';
+
+type Props = {
+ isOpen: boolean;
+ onClose: () => void;
+};
+
+export const MobileMenu: React.FC = ({ isOpen, onClose }) => {
+ const favoriteItems = useSelector(
+ (state: RootState) => state.favorites.items,
+ );
+
+ const cartItems = useSelector(cartItemsCount);
+
+ const getNavClass = ({ isActive }: { isActive: boolean }) =>
+ classNames(styles.mobileMenu__navLink, {
+ [styles.mobileMenu__navLinkActive]: isActive,
+ });
+
+ const getIconClass = ({ isActive }: { isActive: boolean }) =>
+ classNames(styles.mobileMenu__iconLink, {
+ [styles.mobileMenu__iconLinkActive]: isActive,
+ });
+
+ useEffect(() => {
+ if (!isOpen) {
+ return;
+ }
+
+ const prev = document.body.style.overflow;
+
+ document.body.style.overflow = 'hidden';
+
+ return () => {
+ document.body.style.overflow = prev;
+ };
+ }, [isOpen]);
+
+ if (!isOpen) {
+ return null;
+ }
+
+ return (
+
+ );
+};
diff --git a/src/components/Header/index.ts b/src/components/Header/index.ts
new file mode 100644
index 00000000000..266dec8a1bc
--- /dev/null
+++ b/src/components/Header/index.ts
@@ -0,0 +1 @@
+export * from './Header';
diff --git a/src/components/Loader/Loader.module.scss b/src/components/Loader/Loader.module.scss
new file mode 100644
index 00000000000..040c96200b3
--- /dev/null
+++ b/src/components/Loader/Loader.module.scss
@@ -0,0 +1,25 @@
+.Loader {
+ display: flex;
+ width: 100%;
+ justify-content: center;
+ align-items: center;
+
+ &__content {
+ border-radius: 50%;
+ width: 2em;
+ height: 2em;
+ margin: 1em auto;
+ border: 0.3em solid #ddd;
+ border-left-color: #000;
+ animation: load8 1.2s infinite linear;
+ }
+}
+
+@keyframes load8 {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
diff --git a/src/components/Loader/Loader.tsx b/src/components/Loader/Loader.tsx
new file mode 100644
index 00000000000..a35e5763fd3
--- /dev/null
+++ b/src/components/Loader/Loader.tsx
@@ -0,0 +1,7 @@
+import styles from './Loader.module.scss';
+
+export const Loader = () => (
+
+);
diff --git a/src/components/Loader/index.tsx b/src/components/Loader/index.tsx
new file mode 100644
index 00000000000..d5ce981151f
--- /dev/null
+++ b/src/components/Loader/index.tsx
@@ -0,0 +1 @@
+export * from './Loader';
diff --git a/src/components/ProductActions/ProductActions.module.scss b/src/components/ProductActions/ProductActions.module.scss
new file mode 100644
index 00000000000..7a5eb95a55d
--- /dev/null
+++ b/src/components/ProductActions/ProductActions.module.scss
@@ -0,0 +1,73 @@
+@import '../../styles/global';
+
+.productActions {
+ display: flex;
+ flex-direction: row;
+ gap: 8px;
+
+ &__add {
+ padding: 0;
+ flex: 1;
+ height: 40px;
+ background-color: #313237;
+ border: none;
+ color: #fff;
+ transition: all 0.3s ease;
+ font: inherit;
+ font-weight: 600;
+ font-size: 14px;
+ line-height: 21px;
+
+ &:hover {
+ box-shadow: 0 3px 13px rgba(0, 0, 0, 0.4);
+ cursor: pointer;
+ }
+
+ &:disabled {
+ background: #fff;
+ border: 1px solid #E2E6E9;
+ color: $colors-green;
+ cursor: default;
+ box-shadow: none;
+ transform: none;
+ }
+ }
+
+ .iconImg {
+ width: 15px;
+ height: auto;
+ display: block;
+ transition: transform 120ms ease;
+ transform: translateZ(0);
+ will-change: transform;
+ }
+
+
+ .iconPop {
+ animation: pop 550ms cubic-bezier(0.34, 1.56, 0.64, 1);
+ }
+
+ @keyframes pop {
+ 0% {
+ transform: scale(1) rotate(0deg);
+ }
+
+ 30% {
+ transform: scale(1.25) rotate(-8deg);
+ }
+
+ 55% {
+ transform: scale(0.95) rotate(4deg);
+ }
+
+ 75% {
+ transform: scale(1.08) rotate(-2deg);
+ }
+
+ 100% {
+ transform: scale(1) rotate(0deg);
+ }
+ }
+
+
+}
diff --git a/src/components/ProductActions/ProductActions.tsx b/src/components/ProductActions/ProductActions.tsx
new file mode 100644
index 00000000000..a28bf2b2ece
--- /dev/null
+++ b/src/components/ProductActions/ProductActions.tsx
@@ -0,0 +1,58 @@
+import styles from './ProductActions.module.scss';
+import React, { useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { AppDispatch, RootState } from '../../app/store';
+import { addInCart } from '../../features/slices/cartSlice/cartSlice';
+import { toggleFavorite } from '../../features/slices/favorites/favoritesSlice';
+import union from '../../assets/icons/Union.svg';
+import favorites from '../../assets/icons/Favourites.svg';
+import { SecondaryButton } from '../SecondaryButton';
+type Props = {
+ itemId: string;
+};
+
+export const ProductActions: React.FC = ({ itemId }) => {
+ const [pop, setPop] = useState(false);
+
+ const dispatch = useDispatch();
+ const favoriteItems = useSelector(
+ (state: RootState) => state.favorites.items,
+ );
+ const cartItems = useSelector((state: RootState) => state.cartList.items);
+
+ const isFavorite = favoriteItems.includes(itemId);
+ const isInCart = cartItems.some(item => item.itemId === itemId);
+
+ const handleToggleCart = () => {
+ dispatch(addInCart(itemId));
+ };
+
+ const handleToggleFavorite = () => {
+ dispatch(toggleFavorite(itemId));
+ setPop(false);
+ requestAnimationFrame(() => setPop(true));
+ };
+
+ return (
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/ProductActions/index.ts b/src/components/ProductActions/index.ts
new file mode 100644
index 00000000000..4ae950d1697
--- /dev/null
+++ b/src/components/ProductActions/index.ts
@@ -0,0 +1 @@
+export * from './ProductActions';
diff --git a/src/components/ProductCard/ProductCard.module.scss b/src/components/ProductCard/ProductCard.module.scss
new file mode 100644
index 00000000000..666fc26f9b7
--- /dev/null
+++ b/src/components/ProductCard/ProductCard.module.scss
@@ -0,0 +1,188 @@
+@import '../../styles/global';
+
+.productCard {
+ box-sizing: border-box;
+ border: 1px solid $colors-elements;
+ padding: 32px;
+ background: $colors-white;
+ display: flex;
+ flex-direction: column;
+ transition: all 0.3s ease;
+ gap: 8px;
+ min-width: 0;
+ min-height: 440px;
+ justify-content: space-between;
+
+ @include onTablet {
+ min-height: 512px;
+ max-width: 288px;
+ }
+
+ @include onDesktop {
+ min-height: 506px;
+ max-width: 272px;
+ }
+
+ &:hover {
+ cursor: pointer;
+ box-shadow: 0 2px 16px rgba(0, 0, 0, 0.1);
+ }
+
+ &__link {
+ display: flex;
+ flex-direction: column;
+ text-decoration: none;
+ color: $colors-primary;
+ justify-content: space-between;
+ height: 100%;
+ }
+
+ &__image {
+ width: 100%;
+ height: 196px;
+ object-fit: contain;
+ display: block;
+ }
+
+ &__title {
+ @include body-text;
+
+ margin-top: 24px;
+ color: $colors-primary;
+
+ min-height: calc(21px * 2);
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ line-clamp: 2;
+ -webkit-line-clamp: 2;
+ overflow: hidden;
+ }
+
+ &__price {
+ display: flex;
+ flex-direction: row;
+ gap: 8px;
+ }
+
+ &__priceCurrent {
+ color: $colors-primary;
+ margin-top: 0;
+
+ @include h3-text;
+ }
+
+ &__priceOld {
+ font-weight: 600;
+ font-size: 22px;
+ line-height: 1.4;
+ color: $colors-secondary;
+ text-decoration-line: line-through;
+ padding-left: 0;
+ text-decoration-color: $colors-secondary;
+ text-decoration-thickness: 2px;
+ }
+
+ &__specs {
+ border-top: 1px solid $colors-elements;
+ display: flex;
+ padding-top: 16px;
+ flex-direction: column;
+ gap: 8px;
+
+ div {
+ display: flex;
+ justify-content: space-between;
+ font-size: 12px;
+
+ span {
+ color: $colors-secondary;
+ }
+ }
+ }
+
+ &__actions {
+ display: flex;
+ flex-direction: row;
+ gap: 8px;
+ }
+
+ &__add {
+ padding: 0;
+ flex: 1;
+ height: 40px;
+ background-color: $colors-primary;
+ border: none;
+ color: $colors-white;
+ transition: all 0.3s ease;
+
+ @include button-text;
+
+ &:hover {
+ box-shadow: 0 3px 13px rgba(0, 0, 0, 0.4);
+ cursor: pointer;
+ }
+
+ &:disabled {
+ background: #fff;
+ border: 1px solid #E2E6E9;
+ color: $colors-green;
+ cursor: default;
+ box-shadow: none;
+ transform: none;
+ }
+ }
+
+
+ .iconImg {
+ width: 15px;
+ height: auto;
+ display: block;
+ transition: transform 120ms ease;
+ transform: translateZ(0);
+ will-change: transform;
+ }
+
+
+ .iconPop {
+ animation: pop 550ms cubic-bezier(0.34, 1.56, 0.64, 1);
+ }
+
+ @keyframes pop {
+ 0% {
+ transform: scale(1) rotate(0deg);
+ }
+
+ 30% {
+ transform: scale(1.25) rotate(-8deg);
+ }
+
+ 55% {
+ transform: scale(0.95) rotate(4deg);
+ }
+
+ 75% {
+ transform: scale(1.08) rotate(-2deg);
+ }
+
+ 100% {
+ transform: scale(1) rotate(0deg);
+ }
+ }
+
+ &__fav {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ width: 40px;
+ height: 40px;
+ background: none;
+ border: 1px solid #B4BDC3;
+ transition: border 0.3s ease;
+
+ &:hover {
+ border: 1px solid #313237;
+ cursor: pointer;
+ }
+ }
+}
diff --git a/src/components/ProductCard/ProductCard.tsx b/src/components/ProductCard/ProductCard.tsx
new file mode 100644
index 00000000000..be57b003bb7
--- /dev/null
+++ b/src/components/ProductCard/ProductCard.tsx
@@ -0,0 +1,57 @@
+import { Product } from '../../features/types/productType';
+import styles from './ProductCard.module.scss';
+import { Link } from 'react-router-dom';
+import { ProductActions } from '../ProductActions';
+
+type Props = {
+ product: Product;
+ showDiscount?: boolean;
+};
+
+export const ProductCard: React.FC = ({
+ product,
+ showDiscount = false,
+}) => {
+ const { name, fullPrice, price, image, screen, capacity, ram } = product;
+
+ const imgSrc = image.startsWith('/') ? image : `/${image}`;
+
+ return (
+
+
+
+
+ {name}
+
+
+
+ ${showDiscount ? price : fullPrice}
+
+
+ {showDiscount && (
+ ${fullPrice}
+ )}
+
+
+
+
+ Screen
+ {screen}
+
+
+ Capacity
+ {capacity}
+
+
+ RAM
+ {ram}
+
+
+
+
+
+ );
+};
diff --git a/src/components/ProductCard/index.tsx b/src/components/ProductCard/index.tsx
new file mode 100644
index 00000000000..7ce031c3820
--- /dev/null
+++ b/src/components/ProductCard/index.tsx
@@ -0,0 +1 @@
+export * from './ProductCard';
diff --git a/src/components/ProductGallery/ProductGallery.module.scss b/src/components/ProductGallery/ProductGallery.module.scss
new file mode 100644
index 00000000000..d4b6dafb3a3
--- /dev/null
+++ b/src/components/ProductGallery/ProductGallery.module.scss
@@ -0,0 +1,84 @@
+@import '../../styles/global';
+
+.gallery {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+
+ @include onTablet {
+ flex-direction: row;
+ align-items: flex-start;
+ }
+
+ &__thumbnails {
+ display: flex;
+ flex-direction: row;
+ gap: 16px;
+ order: 2;
+
+ overflow: auto hidden;
+ max-width: 100%;
+
+ @include onTablet {
+ flex-direction: column;
+ order: -1;
+
+ overflow: hidden auto;
+ }
+ }
+
+ &__thumbnailButton {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ aspect-ratio: 1 / 1;
+ padding: 0;
+ border: 1px solid $colors-elements;
+ background: none;
+ cursor: pointer;
+ transition: border-color 0.3s ease;
+
+ &:hover {
+ cursor: pointer;
+ border-color: $colors-secondary;
+ }
+
+ @include onTablet {
+ width: clamp(35px, 4vw + 10px, 80px);
+ height: clamp(35px, 4vw + 10px, 80px);
+ }
+
+ @include onDesktop {
+ width: 80px;
+ height: 80px;
+ }
+ }
+
+ &__thumbnailButtonActive {
+ border-color: $colors-primary;
+ }
+
+
+ &__thumbnailImage {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ }
+
+ &__main {
+ flex: 1;
+
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ aspect-ratio: 1 / 1;
+ }
+
+ &__mainImage {
+ width: 100%;
+ height: 100%;
+ aspect-ratio: 1 / 1;
+ object-fit: contain;
+ }
+}
diff --git a/src/components/ProductGallery/ProductGallery.tsx b/src/components/ProductGallery/ProductGallery.tsx
new file mode 100644
index 00000000000..e398cafa568
--- /dev/null
+++ b/src/components/ProductGallery/ProductGallery.tsx
@@ -0,0 +1,51 @@
+import styles from './ProductGallery.module.scss';
+import classNames from 'classnames';
+
+type Props = {
+ images: string[];
+ productName: string;
+ mainImage: string;
+ onImageSelect: (image: string) => void;
+};
+
+export const ProductGallery: React.FC = ({
+ images,
+ productName,
+ mainImage,
+ onImageSelect,
+}) => {
+ return (
+
+
+ {images.map(img => {
+ const imageSrc = `/${img}`;
+ const isActive = mainImage === imageSrc;
+
+ return (
+
+ );
+ })}
+
+
+

+
+
+ );
+};
diff --git a/src/components/ProductGallery/index.ts b/src/components/ProductGallery/index.ts
new file mode 100644
index 00000000000..b74eb50e7e5
--- /dev/null
+++ b/src/components/ProductGallery/index.ts
@@ -0,0 +1 @@
+export * from './ProductGallery';
diff --git a/src/components/ProductOptions/ProductOptions.module.scss b/src/components/ProductOptions/ProductOptions.module.scss
new file mode 100644
index 00000000000..4dd941a8489
--- /dev/null
+++ b/src/components/ProductOptions/ProductOptions.module.scss
@@ -0,0 +1,96 @@
+@import '../../styles/global';
+
+.options {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+
+ &__group {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ }
+
+ &__colors {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ }
+
+ &__radio {
+ position: absolute;
+ opacity: 0;
+ pointer-events: none;
+ }
+
+ &__colorLabel {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ padding: 2px;
+ border: 1px solid $colors-elements;
+ border-radius: 50%;
+ cursor: pointer;
+ transition: border-color 0.3s ease;
+
+ &:hover {
+ border-color: $colors-secondary;
+ }
+
+ &:has(input:checked) {
+ border-color: $colors-primary;
+ }
+ }
+
+ &__colorCircle {
+ width: 100%;
+ height: 100%;
+ border-radius: 50%;
+ }
+
+ &__capacities {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ }
+
+ &__capacityLabel {
+ display: inline-flex;
+ }
+
+ &__capacityValue {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 48px;
+ padding: 8px 12px;
+ border: 1px solid $colors-secondary;
+ color: $colors-primary;
+
+ @include body-text;
+
+ transition:
+ background-color 0.3s ease,
+ color 0.3s ease,
+ border-color 0.3s ease;
+
+ &:hover {
+ cursor: pointer;
+ border-color: $colors-primary;
+ }
+ }
+
+ &__capacityLabel:has(input:checked) &__capacityValue {
+ background-color: $colors-primary;
+ border-color: $colors-primary;
+ color: $colors-white;
+ }
+
+ &__divider {
+ width: 100%;
+ height: 1px;
+ background-color: $colors-elements;
+ }
+}
diff --git a/src/components/ProductOptions/ProductOptions.tsx b/src/components/ProductOptions/ProductOptions.tsx
new file mode 100644
index 00000000000..ce4c291ce2e
--- /dev/null
+++ b/src/components/ProductOptions/ProductOptions.tsx
@@ -0,0 +1,80 @@
+import { getProductColor } from '../../features/constants/productColors';
+import styles from './ProductOptions.module.scss';
+
+type Props = {
+ colors: string[];
+ selectedColor: string;
+ capacities: string[];
+ selectedCapacity: string;
+ onColorChange: (color: string) => void;
+ onCapacityChange: (capacity: string) => void;
+};
+
+export const ProductOptions: React.FC = ({
+ colors,
+ selectedColor,
+ capacities,
+ selectedCapacity,
+ onColorChange,
+ onCapacityChange,
+}) => {
+ return (
+
+
+
+ {colors.map(color => {
+ const isChecked = selectedColor === color;
+
+ return (
+ // eslint-disable-next-line jsx-a11y/label-has-associated-control
+
+ );
+ })}
+
+
+
+
+
+ {capacities.map(capacity => {
+ const isChecked = selectedCapacity === capacity;
+
+ return (
+
+ );
+ })}
+
+
+
+
+
+ );
+};
diff --git a/src/components/ProductOptions/index.ts b/src/components/ProductOptions/index.ts
new file mode 100644
index 00000000000..14e8c012fb7
--- /dev/null
+++ b/src/components/ProductOptions/index.ts
@@ -0,0 +1 @@
+export * from './ProductOptions';
diff --git a/src/components/ProductsCarousel/ProductsCarousel.module.scss b/src/components/ProductsCarousel/ProductsCarousel.module.scss
new file mode 100644
index 00000000000..8bd8b3779b3
--- /dev/null
+++ b/src/components/ProductsCarousel/ProductsCarousel.module.scss
@@ -0,0 +1,42 @@
+@import '../../styles/global';
+
+.carousel {
+ grid-column: 1 / -1;
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+
+ &__header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+
+ &__title {
+ margin: 0;
+ color: $colors-primary;
+
+ @include h2-text;
+ }
+
+ &__actions {
+ display: flex;
+ gap: 16px;
+ }
+
+ &__track {
+ display: flex;
+ overflow: auto hidden;
+ gap: 16px;
+ scroll-snap-type: x mandatory;
+ }
+
+ &__item {
+ flex: 0 0 229px;
+ scroll-snap-align: start;
+
+ @include onDesktop {
+ flex: 0 0 calc((100% - 48px) / 4);
+ }
+ }
+}
diff --git a/src/components/ProductsCarousel/ProductsCarousel.tsx b/src/components/ProductsCarousel/ProductsCarousel.tsx
new file mode 100644
index 00000000000..c97c03d3737
--- /dev/null
+++ b/src/components/ProductsCarousel/ProductsCarousel.tsx
@@ -0,0 +1,105 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { Product } from '../../features/types/productType';
+import styles from './ProductsCarousel.module.scss';
+import { Chevron } from '../icons/Chevron';
+import { ProductCard } from '../ProductCard';
+import { SecondaryButton } from '../SecondaryButton';
+
+type Props = {
+ title: string;
+ products: Product[];
+ showDiscount?: boolean;
+};
+
+export const ProductsCarousel: React.FC = ({
+ title,
+ products,
+ showDiscount = false,
+}) => {
+ const carouselRef = useRef(null);
+ const [canScrollLeft, setCanScrollLeft] = useState(false);
+ const [canScrollRight, setCanScrollRight] = useState(false);
+
+ const updateArrows = useCallback(() => {
+ const el = carouselRef.current;
+
+ if (!el) {
+ return;
+ }
+
+ const left = el.scrollLeft;
+ const maxScrollLeft = el.scrollWidth - el.clientWidth;
+
+ setCanScrollLeft(left > 1);
+ setCanScrollRight(left < maxScrollLeft - 1);
+ }, []);
+
+ useEffect(() => {
+ updateArrows();
+ }, [products, updateArrows]);
+
+ useEffect(() => {
+ const el = carouselRef.current;
+
+ if (!el) {
+ return;
+ }
+
+ const onScroll = () => updateArrows();
+ const onResize = () => updateArrows();
+
+ el.addEventListener('scroll', onScroll, { passive: true });
+ window.addEventListener('resize', onResize);
+
+ return () => {
+ el.removeEventListener('scroll', onScroll);
+ window.removeEventListener('resize', onResize);
+ };
+ }, [updateArrows]);
+
+ const scrollByStep = (direction: 'left' | 'right') => {
+ const el = carouselRef.current;
+
+ if (!el) {
+ return;
+ }
+
+ const step = el.clientWidth;
+
+ el.scrollBy({
+ left: direction === 'left' ? -step : step,
+ behavior: 'smooth',
+ });
+ };
+
+ return (
+
+
+
{title}
+
+ scrollByStep('left')}
+ disabled={!canScrollLeft}
+ aria-label="Scroll left"
+ >
+
+
+ scrollByStep('right')}
+ disabled={!canScrollRight}
+ aria-label="Scroll right"
+ >
+
+
+
+
+
+ {products.map(product => (
+
+ ))}
+
+
+ );
+};
diff --git a/src/components/ProductsCarousel/index.ts b/src/components/ProductsCarousel/index.ts
new file mode 100644
index 00000000000..3689b5b3a56
--- /dev/null
+++ b/src/components/ProductsCarousel/index.ts
@@ -0,0 +1 @@
+export * from './ProductsCarousel';
diff --git a/src/components/ProductsList/ProductsList.module.scss b/src/components/ProductsList/ProductsList.module.scss
new file mode 100644
index 00000000000..9b8a0dc8844
--- /dev/null
+++ b/src/components/ProductsList/ProductsList.module.scss
@@ -0,0 +1,11 @@
+@import '../../styles/global';
+
+.productList {
+ display: grid;
+ gap: 40px 16px;
+ grid-template-columns: repeat(auto-fill, minmax(229px, 1fr));
+
+ @include onDesktop {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ }
+}
diff --git a/src/components/ProductsList/ProductsList.tsx b/src/components/ProductsList/ProductsList.tsx
new file mode 100644
index 00000000000..0f338f99786
--- /dev/null
+++ b/src/components/ProductsList/ProductsList.tsx
@@ -0,0 +1,17 @@
+import { Product } from '../../features/types/productType';
+import { ProductCard } from '../ProductCard';
+import styles from './ProductsList.module.scss';
+
+type Props = {
+ products: Product[];
+};
+
+export const ProductList: React.FC = ({ products }) => {
+ return (
+
+ {products.map(product => (
+
+ ))}
+
+ );
+};
diff --git a/src/components/ProductsList/index.ts b/src/components/ProductsList/index.ts
new file mode 100644
index 00000000000..09f9887f278
--- /dev/null
+++ b/src/components/ProductsList/index.ts
@@ -0,0 +1 @@
+export * from './ProductsList';
diff --git a/src/components/SecondaryButton/SecondaryButton.module.scss b/src/components/SecondaryButton/SecondaryButton.module.scss
new file mode 100644
index 00000000000..43daacf6100
--- /dev/null
+++ b/src/components/SecondaryButton/SecondaryButton.module.scss
@@ -0,0 +1,45 @@
+@import '../../styles/global';
+
+.button {
+ background: none;
+ border: 1px solid $colors-icons;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: $colors-primary;
+ stroke: currentColor;
+ padding: 0;
+
+ &:disabled {
+ color: $colors-icons;
+ border-color: $colors-elements;
+ cursor: auto;
+ stroke: currentColor;
+ }
+
+ &:hover:not(:disabled, .active) {
+ border: 1px solid $colors-primary;
+ cursor: pointer;
+ }
+}
+
+.active {
+ color: $colors-white;
+ background-color: $colors-primary;
+ border-color: $colors-primary;
+}
+
+.s {
+ width: 32px;
+ height: 32px;
+}
+
+.m {
+ width: 40px;
+ height: 40px;
+}
+
+.l {
+ width: 48px;
+ height: 48px;
+}
diff --git a/src/components/SecondaryButton/SecondaryButton.tsx b/src/components/SecondaryButton/SecondaryButton.tsx
new file mode 100644
index 00000000000..311b5d00544
--- /dev/null
+++ b/src/components/SecondaryButton/SecondaryButton.tsx
@@ -0,0 +1,38 @@
+import React, { ButtonHTMLAttributes, ReactNode } from 'react';
+import styles from './SecondaryButton.module.scss';
+import classNames from 'classnames';
+
+type ButtonSize = 's' | 'm' | 'l';
+
+type Props = {
+ children: ReactNode;
+ className?: string;
+ isActive?: boolean;
+ size?: ButtonSize;
+} & ButtonHTMLAttributes;
+
+export const SecondaryButton: React.FC = ({
+ children,
+ className = '',
+ isActive = false,
+ size = 's',
+ type = 'button',
+ ...rest
+}) => {
+ return (
+
+ );
+};
diff --git a/src/components/SecondaryButton/index.ts b/src/components/SecondaryButton/index.ts
new file mode 100644
index 00000000000..af0b58cd868
--- /dev/null
+++ b/src/components/SecondaryButton/index.ts
@@ -0,0 +1 @@
+export * from './SecondaryButton';
diff --git a/src/components/categoryCard/CategoryCard.module.scss b/src/components/categoryCard/CategoryCard.module.scss
new file mode 100644
index 00000000000..e100d7c8327
--- /dev/null
+++ b/src/components/categoryCard/CategoryCard.module.scss
@@ -0,0 +1,99 @@
+@import '../../styles/global';
+
+.categoryCard {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ text-decoration: none;
+ color: inherit;
+ gap: 24px;
+
+ transition: transform 0.3s ease;
+
+ @include onTablet {
+ width: auto;
+ flex: 1 1 0;
+ min-width: 0;
+ }
+
+ &__square {
+ width: 100%;
+ aspect-ratio: 1;
+ max-width: none;
+ overflow: hidden;
+ position: relative;
+ transition: box-shadow 0.3s ease;
+ }
+
+ &__image {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ width: auto;
+ display: block;
+ pointer-events: none;
+ max-width: none;
+ }
+
+ &__textBlock {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ }
+
+ &__title {
+ color: $colors-primary;
+ margin: 0;
+
+ @include h4-text;
+ }
+
+ &__count {
+ margin: 0;
+
+ @include body-text;
+
+ color: $colors-secondary;
+ }
+}
+
+.phones {
+ .categoryCard__square {
+ background-color: #6D6474;
+ }
+
+ .categoryCard__image {
+ height: 100%;
+ transform: translate(20%, 10%);
+ }
+}
+
+.tablets {
+ .categoryCard__square {
+ background-color: #8D8D92;
+ }
+
+ .categoryCard__image {
+ height: 140%;
+ transform: translate(40%, 30%);
+ }
+}
+
+.accessories {
+ .categoryCard__square {
+ background-color: #973D5F;
+ }
+
+ .categoryCard__image {
+ height: 85%;
+ transform: translate(50%, 0%);
+ }
+}
+
+.categoryCard:hover {
+ transform: translateY(-2px) scale(1.01);
+}
+
+.categoryCard:hover .categoryCard__square {
+ box-shadow: 0 2px 16px rgba(0, 0, 0, 0.2);
+}
diff --git a/src/components/categoryCard/CategoryCard.tsx b/src/components/categoryCard/CategoryCard.tsx
new file mode 100644
index 00000000000..ecb84eaa149
--- /dev/null
+++ b/src/components/categoryCard/CategoryCard.tsx
@@ -0,0 +1,25 @@
+import { Link } from 'react-router-dom';
+import { CategoryConfig } from '../../features/types/category';
+import styles from './CategoryCard.module.scss';
+import classNames from 'classnames';
+
+type Props = {
+ category: CategoryConfig;
+ count: number;
+};
+
+export const CategoryCard = ({ category, count }: Props) => {
+ const { key, title, link, image } = category;
+
+ return (
+
+
+

+
+
+
{title}
+
{count} models
+
+
+ );
+};
diff --git a/src/components/categoryCard/index.ts b/src/components/categoryCard/index.ts
new file mode 100644
index 00000000000..a72d92435c8
--- /dev/null
+++ b/src/components/categoryCard/index.ts
@@ -0,0 +1 @@
+export * from './CategoryCard';
diff --git a/src/components/icons/Chevron/Chevron.module.scss b/src/components/icons/Chevron/Chevron.module.scss
new file mode 100644
index 00000000000..110fc8cb6c5
--- /dev/null
+++ b/src/components/icons/Chevron/Chevron.module.scss
@@ -0,0 +1,28 @@
+.icon {
+ width: 6px;
+ height: 10px;
+
+ path {
+ fill: none;
+ stroke: inherit;
+ stroke-width: 1.5;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ }
+}
+
+.right {
+ transform: rotate(0deg);
+}
+
+.left {
+ transform: rotate(180deg);
+}
+
+.up {
+ transform: rotate(-90deg);
+}
+
+.down {
+ transform: rotate(90deg);
+}
diff --git a/src/components/icons/Chevron/Chevron.tsx b/src/components/icons/Chevron/Chevron.tsx
new file mode 100644
index 00000000000..d080d0b5197
--- /dev/null
+++ b/src/components/icons/Chevron/Chevron.tsx
@@ -0,0 +1,19 @@
+import styles from './Chevron.module.scss';
+
+type Props = {
+ direction: 'left' | 'right' | 'up' | 'down';
+ className?: string;
+};
+
+export const Chevron = ({ direction, className }: Props) => {
+ return (
+
+ );
+};
diff --git a/src/components/icons/Chevron/index.ts b/src/components/icons/Chevron/index.ts
new file mode 100644
index 00000000000..fae5d54c933
--- /dev/null
+++ b/src/components/icons/Chevron/index.ts
@@ -0,0 +1 @@
+export * from './Chevron';
diff --git a/src/components/icons/IconWithBadge/IconWithBadge.module.scss b/src/components/icons/IconWithBadge/IconWithBadge.module.scss
new file mode 100644
index 00000000000..45158f8176f
--- /dev/null
+++ b/src/components/icons/IconWithBadge/IconWithBadge.module.scss
@@ -0,0 +1,35 @@
+@import '../../../styles/global';
+
+.wrapper {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ transition: transform 0.3s ease;
+}
+
+.icon {
+ display: block;
+}
+
+.badge {
+ position: absolute;
+ top: -6px;
+ right: -6px;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background-color: $colors-red;
+ border: 1px solid $colors-white;
+
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ font-family: Mont, sans-serif;
+ font-weight: 700;
+ font-size: 9px;
+ line-height: 1;
+ color: $colors-white;
+ z-index: 1;
+}
diff --git a/src/components/icons/IconWithBadge/IconWithBadge.tsx b/src/components/icons/IconWithBadge/IconWithBadge.tsx
new file mode 100644
index 00000000000..a3aff1a396e
--- /dev/null
+++ b/src/components/icons/IconWithBadge/IconWithBadge.tsx
@@ -0,0 +1,22 @@
+import styles from './IconWithBadge.module.scss';
+import React from 'react';
+
+type Props = {
+ icon: string;
+ alt: string;
+ badgeCount?: number;
+};
+
+export const IconWithBadge: React.FC = ({
+ icon,
+ alt,
+ badgeCount = 0,
+}) => {
+ return (
+
+

+
+ {badgeCount > 0 &&
{badgeCount}}
+
+ );
+};
diff --git a/src/components/icons/IconWithBadge/index.ts b/src/components/icons/IconWithBadge/index.ts
new file mode 100644
index 00000000000..aa66ca0bf79
--- /dev/null
+++ b/src/components/icons/IconWithBadge/index.ts
@@ -0,0 +1 @@
+export * from './IconWithBadge';
diff --git a/src/features/constants/categories.ts b/src/features/constants/categories.ts
new file mode 100644
index 00000000000..bca02ee7f23
--- /dev/null
+++ b/src/features/constants/categories.ts
@@ -0,0 +1,31 @@
+import { CategoryConfig } from '../types/category';
+
+export const categories: CategoryConfig[] = [
+ {
+ key: 'phones',
+ title: 'Mobile phones',
+ link: '/phones',
+ color: '#6D6474',
+ image: '/img/category-phones.webp',
+ height: '100%',
+ transform: 'translate(20%, 10%)',
+ },
+ {
+ key: 'tablets',
+ title: 'Tablets',
+ link: '/tablets',
+ color: '#8D8D92',
+ image: '/img/category-tablets.png',
+ height: '140%',
+ transform: 'translate(40%, 30%)',
+ },
+ {
+ key: 'accessories',
+ title: 'Accessories',
+ link: '/accessories',
+ color: '#D53C51',
+ image: '/img/category-accessories.png',
+ height: '60%',
+ transform: 'translate(40%, 30%)',
+ },
+];
diff --git a/src/features/constants/productColors.ts b/src/features/constants/productColors.ts
new file mode 100644
index 00000000000..2f6b8ea0838
--- /dev/null
+++ b/src/features/constants/productColors.ts
@@ -0,0 +1,27 @@
+export const productColors: Record = {
+ black: '#1f2020',
+ white: '#f5f5f0',
+ yellow: '#f4d06f',
+ green: '#5f8f72',
+ purple: '#b8afe6',
+ red: '#c91c1c',
+ gold: '#f3d6a3',
+ silver: '#e3e5e8',
+ spacegray: '#4c4c4c',
+ rosegold: '#f6c7b5',
+ midnightgreen: '#4e5f58',
+ coral: '#ff7f6e',
+ midnight: '#171e27',
+ starlight: '#f5f1e6',
+ blue: '#4f7cae',
+ pink: '#f5b8c8',
+ graphite: '#4a4a4d',
+ sierrablue: '#9bb5ce',
+ skyblue: '#b7d7ee',
+};
+
+export const getProductColor = (color: string) => {
+ const normalizedColor = color.toLowerCase().replace(/[\s-]/g, '');
+
+ return productColors[normalizedColor] || color;
+};
diff --git a/src/features/slices/cartSlice/cartSlice.ts b/src/features/slices/cartSlice/cartSlice.ts
new file mode 100644
index 00000000000..8e3dcfc9854
--- /dev/null
+++ b/src/features/slices/cartSlice/cartSlice.ts
@@ -0,0 +1,83 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+
+export type CartItem = {
+ itemId: string;
+ quantity: number;
+};
+
+export interface CartListState {
+ items: CartItem[];
+}
+
+const initialState: CartListState = {
+ items: JSON.parse(localStorage.getItem('cartList') || '[]'),
+};
+
+const cartListSlice = createSlice({
+ name: 'cartList',
+ initialState,
+ reducers: {
+ addInCart: (state, action: PayloadAction) => {
+ const itemId = action.payload;
+
+ const existingItem = state.items.find(item => item.itemId === itemId);
+
+ if (!existingItem) {
+ state.items.push({
+ itemId,
+ quantity: 1,
+ });
+ }
+ },
+ increaseQuantity: (state, action: PayloadAction) => {
+ const itemId = action.payload;
+
+ const cartItem = state.items.find(item => item.itemId === itemId);
+
+ if (cartItem) {
+ cartItem.quantity += 1;
+ }
+ },
+ minusQuantity: (state, action: PayloadAction) => {
+ const itemId = action.payload;
+
+ const cartItem = state.items.find(item => item.itemId === itemId);
+
+ if (!cartItem) {
+ return;
+ }
+
+ if (cartItem.quantity > 1) {
+ cartItem.quantity -= 1;
+ } else {
+ const index = state.items.findIndex(item => item.itemId === itemId);
+
+ if (index !== -1) {
+ state.items.splice(index, 1);
+ }
+ }
+ },
+ removeInCart: (state, action: PayloadAction) => {
+ const itemId = action.payload;
+
+ const index = state.items.findIndex(item => item.itemId === itemId);
+
+ if (index !== -1) {
+ state.items.splice(index, 1);
+ }
+ },
+ clearCart: () => {
+ return { items: [] };
+ },
+ },
+});
+
+export const {
+ addInCart,
+ increaseQuantity,
+ minusQuantity,
+ removeInCart,
+ clearCart,
+} = cartListSlice.actions;
+
+export default cartListSlice.reducer;
diff --git a/src/features/slices/favorites/favoritesSlice.ts b/src/features/slices/favorites/favoritesSlice.ts
new file mode 100644
index 00000000000..d3eaa9d0c99
--- /dev/null
+++ b/src/features/slices/favorites/favoritesSlice.ts
@@ -0,0 +1,29 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+
+export interface FavoritesState {
+ items: string[];
+}
+
+const initialState: FavoritesState = {
+ items: JSON.parse(localStorage.getItem('favorites') || '[]'),
+};
+
+const favoritesSlice = createSlice({
+ name: 'favorites',
+ initialState,
+ reducers: {
+ toggleFavorite: (state, action: PayloadAction) => {
+ const itemId = action.payload;
+ const index = state.items.indexOf(itemId);
+
+ if (index !== -1) {
+ state.items.splice(index, 1);
+ } else {
+ state.items.push(itemId);
+ }
+ },
+ },
+});
+
+export const { toggleFavorite } = favoritesSlice.actions;
+export default favoritesSlice.reducer;
diff --git a/src/features/types/category.ts b/src/features/types/category.ts
new file mode 100644
index 00000000000..bd440ade0cb
--- /dev/null
+++ b/src/features/types/category.ts
@@ -0,0 +1,11 @@
+export type CategoryKey = 'phones' | 'tablets' | 'accessories';
+
+export type CategoryConfig = {
+ key: CategoryKey;
+ title: string;
+ link: string;
+ image: string;
+ color: string;
+ height: string;
+ transform: string;
+};
diff --git a/src/features/types/phoneType.ts b/src/features/types/phoneType.ts
new file mode 100644
index 00000000000..891ef68eeef
--- /dev/null
+++ b/src/features/types/phoneType.ts
@@ -0,0 +1,10 @@
+export type PhoneType = {
+ id: string;
+ name: string;
+ priceRegular: number;
+ priceDiscount: number | null;
+ images: string[];
+ screen: string;
+ capacity: string;
+ ram: string;
+};
diff --git a/src/features/types/productDetailsType.ts b/src/features/types/productDetailsType.ts
new file mode 100644
index 00000000000..08a31c091eb
--- /dev/null
+++ b/src/features/types/productDetailsType.ts
@@ -0,0 +1,29 @@
+import { ProductCategory } from './productType';
+
+export type ProductDetails = {
+ id: string;
+ category: ProductCategory;
+ namespaceId: string;
+ name: string;
+
+ capacityAvailable: string[];
+ capacity: string;
+
+ priceRegular: number;
+ priceDiscount: number;
+
+ colorsAvailable: string[];
+ color: string;
+
+ images: string[];
+
+ description: { title: string; text: string[] }[];
+
+ screen: string;
+ resolution: string;
+ processor: string;
+ ram: string;
+ camera: string;
+ zoom: string;
+ cell: string[];
+};
diff --git a/src/features/types/productType.ts b/src/features/types/productType.ts
new file mode 100644
index 00000000000..d136c4ea7b1
--- /dev/null
+++ b/src/features/types/productType.ts
@@ -0,0 +1,16 @@
+export type ProductCategory = 'phones' | 'tablets' | 'accessories';
+
+export type Product = {
+ id: number;
+ category: ProductCategory;
+ itemId: string;
+ name: string;
+ fullPrice: number;
+ price: number;
+ screen: string;
+ capacity: string;
+ color: string;
+ ram: string;
+ year: number;
+ image: string;
+};
diff --git a/src/features/utils/client.ts b/src/features/utils/client.ts
new file mode 100644
index 00000000000..51530d0685a
--- /dev/null
+++ b/src/features/utils/client.ts
@@ -0,0 +1,9 @@
+export async function getData(url: string): Promise {
+ const res = await fetch(url);
+
+ if (!res.ok) {
+ throw new Error(`Request failed: ${res.status} ${res.statusText}`);
+ }
+
+ return res.json() as Promise;
+}
diff --git a/src/features/utils/selectors.ts b/src/features/utils/selectors.ts
new file mode 100644
index 00000000000..21f8e702af4
--- /dev/null
+++ b/src/features/utils/selectors.ts
@@ -0,0 +1,4 @@
+import { RootState } from '../../app/store';
+
+export const cartItemsCount = (state: RootState) =>
+ state.cartList.items.reduce((acc, item) => acc + item.quantity, 0);
diff --git a/src/index.tsx b/src/index.tsx
index 50470f1508d..e0d2056a2cb 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,4 +1,14 @@
import { createRoot } from 'react-dom/client';
import { App } from './App';
+import { BrowserRouter } from 'react-router-dom';
+import './styles/global.scss';
+import { Provider } from 'react-redux';
+import { store } from './app/store';
-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..7e6bef5b428
--- /dev/null
+++ b/src/modules/CartPage/CartPage.module.scss
@@ -0,0 +1,150 @@
+@import '../../styles/global';
+
+.cart {
+ @include pageGrid;
+
+ &__header {
+ margin-top: 40px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ grid-column: 1 / -1;
+ margin-bottom: 32px;
+ }
+
+ &__back {
+ display: inline-flex;
+ width: fit-content;
+ flex-direction: row;
+ gap: 9px;
+ align-items: baseline;
+ padding: 0;
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ }
+
+ &__backIcon {
+ stroke: $colors-primary;
+ }
+
+ &__title {
+ margin: 0;
+ color: $colors-primary;
+
+ @include h1-text;
+ }
+
+ &__products {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+
+ grid-column: 1 / -1;
+
+ @include onDesktop {
+ grid-column: 1 / 17;
+
+ }
+ }
+
+ &__summary {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+
+ grid-column: 1 / -1;
+ padding: 24px;
+ border: 1px solid $colors-elements;
+ align-self: start;
+ box-sizing: border-box;
+ margin-top: 32px;
+
+ @include onDesktop {
+ grid-column: 17 / -1;
+ margin-top: 0;
+ }
+ }
+
+ &__summaryPrice {
+ color: $colors-primary;
+ margin: 0;
+
+ @include h2-text;
+ }
+
+ &__summaryItem {
+ color: $colors-secondary;
+
+ @include body-text;
+ }
+
+ &__summaryLine {
+ width: 100%;
+ height: 1px;
+ background-color: $colors-elements;
+ margin-top: 25px;
+ margin-bottom: 25px;
+ }
+
+ &__checkoutButton {
+ padding: 0;
+ height: 40px;
+ background-color: $colors-primary;
+ border: none;
+ color: $colors-white;
+ transition: box-shadow 0.3s ease, background-color 0.3s ease, color 0.3s ease;
+
+ @include button-text;
+
+ width: 100%;
+
+ &:hover {
+ box-shadow: 0 3px 13px rgba(0, 0, 0, 0.4);
+ cursor: pointer;
+ }
+
+ &:disabled {
+ background: $colors-white;
+ border: 1px solid $colors-elements;
+ color: $colors-green;
+ cursor: default;
+ box-shadow: none;
+ transform: none;
+ }
+ }
+}
+
+.cartEmpty {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ align-items: center;
+ justify-content: center;
+ margin-top: 150px;
+
+ &__img {
+ width: 200px;
+ height: auto;
+ }
+
+ &__title {
+ color: $colors-primary;
+ margin: 0;
+
+ @include h2-text;
+ }
+
+ &__link {
+ color: $colors-secondary;
+ margin: 0;
+
+ @include body-text;
+
+ transition: color 0.3s ease;
+
+ &:hover {
+ color: $colors-primary;
+ }
+ }
+}
diff --git a/src/modules/CartPage/CartPage.tsx b/src/modules/CartPage/CartPage.tsx
new file mode 100644
index 00000000000..16cddd76f80
--- /dev/null
+++ b/src/modules/CartPage/CartPage.tsx
@@ -0,0 +1,130 @@
+import { useEffect, useMemo, useState } from 'react';
+import { Product } from '../../features/types/productType';
+import { getProducts } from '../../api/products';
+import { useDispatch, useSelector } from 'react-redux';
+import { AppDispatch, RootState } from '../../app/store';
+import { Loader } from '../../components/Loader';
+import styles from './CartPage.module.scss';
+import {
+ clearCart,
+ increaseQuantity,
+ minusQuantity,
+ removeInCart,
+} from '../../features/slices/cartSlice/cartSlice';
+import { NavLink, useNavigate } from 'react-router-dom';
+import { Chevron } from '../../components/icons/Chevron';
+import { CartItemCard } from '../../components/CartItemCard';
+import { CheckoutModal } from '../../components/CheckoutModal';
+import { cartItemsCount } from '../../features/utils/selectors';
+
+export const CartPage = () => {
+ const [products, setProducts] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [, setError] = useState(false);
+ const [isCheckoutModalOpen, setIsCheckoutModalOpen] = useState(false);
+
+ const navigate = useNavigate();
+ const dispatch = useDispatch();
+ const cartListItems = useSelector((state: RootState) => state.cartList.items);
+
+ const cartItemsMap = useMemo(() => {
+ return Object.fromEntries(
+ cartListItems.map(item => [item.itemId, item.quantity]),
+ ) as Record;
+ }, [cartListItems]);
+
+ const cartProducts = useMemo(() => {
+ return products.filter(product => cartItemsMap[product.itemId]);
+ }, [products, cartItemsMap]);
+
+ const totalSum = useMemo(() => {
+ return cartProducts.reduce((acc, product) => {
+ return acc + product.price * cartItemsMap[product.itemId];
+ }, 0);
+ }, [cartProducts, cartItemsMap]);
+
+ const cartItems = useSelector(cartItemsCount);
+
+ useEffect(() => {
+ setLoading(true);
+
+ getProducts()
+ .then(setProducts)
+ .catch(() => setError(true))
+ .finally(() => setLoading(false));
+ }, []);
+
+ if (loading) {
+ return ;
+ }
+
+ if (!cartItems) {
+ return (
+
+

+
Your cart is empty
+
+ Back to Home
+
+
+ );
+ }
+
+ return (
+
+
+
+
Cart
+
+
+
+ {cartProducts.map(product => (
+ dispatch(removeInCart(product.itemId))}
+ onDecrease={() => dispatch(minusQuantity(product.itemId))}
+ onIncrease={() => dispatch(increaseQuantity(product.itemId))}
+ />
+ ))}
+
+
+
+
${totalSum}
+
Total for {cartItems} items
+
+
+
+ {isCheckoutModalOpen && (
+
setIsCheckoutModalOpen(false)}
+ onConfirm={() => {
+ dispatch(clearCart());
+ setIsCheckoutModalOpen(false);
+ }}
+ />
+ )}
+
+ );
+};
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/FavoritesPage/FavoritesPage.module.scss b/src/modules/FavoritesPage/FavoritesPage.module.scss
new file mode 100644
index 00000000000..955596497c0
--- /dev/null
+++ b/src/modules/FavoritesPage/FavoritesPage.module.scss
@@ -0,0 +1,33 @@
+@import '../../styles/global';
+
+.favoritesPage {
+ margin-top: 24px;
+
+ @include pageGrid;
+
+ &__wrapper {
+ grid-column: 1 / -1;
+ display: flex;
+ flex-direction: column;
+ gap: 40px;
+ }
+
+ &__textBlock {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ &__title {
+ margin: 0;
+ color: $colors-primary;
+
+ @include h1-text;
+ }
+
+ &__count {
+ @include body-text;
+
+ color: $colors-secondary;
+ }
+}
diff --git a/src/modules/FavoritesPage/FavoritesPage.tsx b/src/modules/FavoritesPage/FavoritesPage.tsx
new file mode 100644
index 00000000000..40ff93d001d
--- /dev/null
+++ b/src/modules/FavoritesPage/FavoritesPage.tsx
@@ -0,0 +1,60 @@
+import { useEffect, useState } from 'react';
+import { useSelector } from 'react-redux';
+import { RootState } from '../../app/store';
+import { Product } from '../../features/types/productType';
+import { getProducts } from '../../api/products';
+import styles from './FavoritesPage.module.scss';
+import { Breadcrumbs } from '../../components/Breadcrumbs';
+import { Loader } from '../../components/Loader';
+import { ProductList } from '../../components/ProductsList';
+
+export const FavoritesPage = () => {
+ const [products, setProducts] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+
+ const favoriteItems = useSelector(
+ (state: RootState) => state.favorites.items,
+ );
+
+ useEffect(() => {
+ setLoading(true);
+
+ getProducts()
+ .then(setProducts)
+ .catch(() => setError('Failed to load products'))
+ .finally(() => setLoading(false));
+ }, []);
+
+ const favoriteProducts = products.filter(product =>
+ favoriteItems.includes(product.itemId),
+ );
+
+ if (loading) {
+ return ;
+ }
+
+ if (error) {
+ return {error}
;
+ }
+
+ return (
+
+
+
+
+
Favorites
+
+ {favoriteItems.length} items
+
+
+
+ {favoriteProducts.length === 0 ? (
+
Nothing here 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.module.scss b/src/modules/HomePage/HomePage.module.scss
new file mode 100644
index 00000000000..cc6f8fc85d0
--- /dev/null
+++ b/src/modules/HomePage/HomePage.module.scss
@@ -0,0 +1,67 @@
+@import '../../styles/global';
+
+.home {
+ margin-top: 56px;
+
+ @include pageGrid;
+
+ &__titleHiden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+ margin: 0;
+ }
+
+ &__title {
+ grid-column: 1 / -1;
+ color: $colors-primary;
+
+ @include h1-text;
+ }
+
+ &__content {
+ grid-column: 1 / -1;
+ display: flex;
+ flex-direction: column;
+ gap: 56px;
+
+ @include onTablet {
+ gap: 64px;
+ }
+
+ @include onDesktop {
+ gap: 80px;
+ }
+ }
+
+ &__banner {
+ @include pageGrid;
+ }
+
+ &__categories {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ }
+
+ &__categoriesTitle {
+ margin: 0;
+ color: $colors-primary;
+
+ @include h2-text;
+ }
+
+ &__categoriesList {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+
+ @include onTablet {
+ flex-direction: row;
+ align-items: stretch;
+ }
+ }
+}
diff --git a/src/modules/HomePage/HomePage.tsx b/src/modules/HomePage/HomePage.tsx
new file mode 100644
index 00000000000..b49587273b6
--- /dev/null
+++ b/src/modules/HomePage/HomePage.tsx
@@ -0,0 +1,108 @@
+import styles from './HomePage.module.scss';
+import { BannerSlider } from '../../components/BannerSlider';
+import { useEffect, useMemo, useState } from 'react';
+import { Product } from '../../features/types/productType';
+import { getProducts } from '../../api/products';
+import { Loader } from '../../components/Loader';
+import { ProductsCarousel } from '../../components/ProductsCarousel';
+import { CategoryCard } from '../../components/categoryCard';
+import { categories } from '../../features/constants/categories';
+
+export const HomePage = () => {
+ const [products, setProducts] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(false);
+
+ const loadProducts = () => {
+ setLoading(true);
+ setError(false);
+
+ getProducts()
+ .then(setProducts)
+ .catch(() => setError(true))
+ .finally(() => setLoading(false));
+ };
+
+ useEffect(() => {
+ loadProducts();
+ }, []);
+
+ const categoryCounts = useMemo(() => {
+ return products.reduce(
+ (acc, p) => ({
+ ...acc,
+ [p.category]: acc[p.category] + 1,
+ }),
+ { phones: 0, tablets: 0, accessories: 0 },
+ );
+ }, [products]);
+
+ const newestProducts = useMemo(() => {
+ return [...products].sort((a, b) => b.year - a.year).slice(0, 30);
+ }, [products]);
+
+ const bestDeals = useMemo(() => {
+ return [...products]
+ .sort((a, b) => {
+ const discountA = a.fullPrice - a.price;
+ const discountB = b.fullPrice - b.price;
+
+ return discountB - discountA;
+ })
+ .slice(0, 30);
+ }, [products]);
+
+ return (
+
+
Product Catalog
+
Welcome to Nice Gadgets store!
+
+
+ {loading &&
}
+
+ {error && !loading && (
+
+
Something went wrong
+
+
+ )}
+
+ {!loading && !error && (
+ <>
+
+
+
+ Shop by category
+
+ {categories.map(category => (
+
+ ))}
+
+
+
+
+ >
+ )}
+
+
+ );
+};
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.module.scss b/src/modules/NotFoundPage/NotFoundPage.module.scss
new file mode 100644
index 00000000000..c398da655b8
--- /dev/null
+++ b/src/modules/NotFoundPage/NotFoundPage.module.scss
@@ -0,0 +1,35 @@
+@import '../../styles/global';
+
+.page {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ align-items: center;
+ justify-content: center;
+ margin-top: 150px;
+
+ &__img {
+ width: 250px;
+ height: auto;
+ }
+
+ &__title {
+ color: $colors-primary;
+ margin: 0;
+
+ @include h2-text;
+ }
+
+ &__link {
+ color: $colors-secondary;
+ margin: 0;
+
+ @include body-text;
+
+ transition: color 0.3s ease;
+
+ &:hover {
+ color: $colors-primary;
+ }
+ }
+}
diff --git a/src/modules/NotFoundPage/NotFoundPage.tsx b/src/modules/NotFoundPage/NotFoundPage.tsx
new file mode 100644
index 00000000000..1bcb1d97e25
--- /dev/null
+++ b/src/modules/NotFoundPage/NotFoundPage.tsx
@@ -0,0 +1,18 @@
+import styles from './NotFoundPage.module.scss';
+import { NavLink } from 'react-router-dom';
+
+export const NotFoundPage = () => {
+ return (
+
+

+
Page not found
+
+ Back to Home page
+
+
+ );
+};
diff --git a/src/modules/NotFoundPage/index.ts b/src/modules/NotFoundPage/index.ts
new file mode 100644
index 00000000000..6197aa75aa8
--- /dev/null
+++ b/src/modules/NotFoundPage/index.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..9f7d7ea576f
--- /dev/null
+++ b/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss
@@ -0,0 +1,292 @@
+@import '../../styles/global';
+
+.productDetails {
+ margin-top: 24px;
+ row-gap: 40px;
+
+ @include pageGrid;
+
+ &__wrapper {
+ grid-column: 1 / -1;
+ display: flex;
+ flex-direction: column;
+ gap: 56px;
+
+ @include onTablet {
+ gap: 64px;
+ }
+
+ @include onDesktop {
+ gap: 80px;
+ }
+ }
+
+ &__header {
+ grid-column: 1 / -1;
+ display: flex;
+ flex-direction: column;
+ gap: 40px;
+ }
+
+ &__headerText {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ }
+
+ &__back {
+ display: inline-flex;
+ align-items: center;
+ gap: 9px;
+ width: fit-content;
+ padding: 0;
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ }
+
+ &__backIcon {
+ display: inline-flex;
+ stroke: $colors-primary;
+ }
+
+ &__backText {
+ margin: 0;
+
+ @include body-text;
+
+ color: $colors-primary;
+ }
+
+ &__title {
+ margin: 0;
+ color: $colors-primary;
+
+ @include h1-text;
+ }
+
+ &__hero {
+ row-gap: 16px;
+
+ @include pageGrid;
+ }
+
+ &__gallery {
+ min-width: 0;
+ grid-column: 1 / -1;
+
+
+ @include onTablet {
+ grid-column: 1 / 7;
+ }
+
+ @include onDesktop {
+ grid-column: 1 / 12;
+ }
+ }
+
+ &__sidebar {
+ min-width: 0;
+ grid-column:1 / -1;
+
+ @include onTablet {
+ grid-column: 8 / -1;
+ }
+
+ @include onDesktop {
+ grid-column: 14 / 20;
+ }
+ }
+
+ &__options {
+ display: flex;
+ flex-direction: column;
+ gap: 32px;
+ }
+
+ &__summary {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ }
+
+ &__priceBlock {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ &__priceDiscount {
+ color: $colors-primary;
+
+ @include h3-text;
+ }
+
+ &__priceRegular {
+ color: $colors-secondary;
+ font-weight: 600;
+ font-size: 22px;
+ line-height: 1.4;
+ text-decoration: line-through;
+ text-decoration-color: $colors-secondary;
+ text-decoration-thickness: 2px;
+ }
+
+ &__shortSpecs {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ &__shortSpec {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ }
+
+ &__shortSpecLabel {
+ margin: 0;
+ color: $colors-secondary;
+
+ @include body-text;
+ }
+
+ &__shortSpecValue {
+ margin: 0;
+ color: $colors-primary;
+ text-align: right;
+
+ @include body-text;
+ }
+
+ &__content {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 40px;
+
+ @include onDesktop {
+ grid-template-columns: minmax(0, 1fr) minmax(320px, 1fr);
+ gap: 64px;
+ }
+ }
+
+ &__about,
+ &__tech {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ }
+
+ &__divider {
+ width: 100%;
+ height: 1px;
+ background-color: $colors-elements;
+ }
+
+ &__sectionTitle {
+ margin: 0;
+ color: $colors-primary;
+
+ @include h2-text;
+ }
+
+ &__descriptionBlock {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ }
+
+ &__descriptionTitle {
+ margin: 0;
+ color: $colors-primary;
+
+ @include h3-text;
+ }
+
+ &__descriptionText {
+ margin: 0;
+ color: $colors-secondary;
+
+ @include body-text;
+ }
+
+ &__techList {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ &__techRow {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ }
+
+ &__techLabel {
+ margin: 0;
+ color: $colors-secondary;
+
+ @include body-text;
+ }
+
+ &__techValue {
+ margin: 0;
+ color: $colors-primary;
+ text-align: right;
+
+ @include body-text;
+ }
+
+ &__recommended {
+ display: flex;
+ flex-direction: column;
+ }
+
+ &__state {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ min-height: 240px;
+ }
+
+ &__stateText {
+ margin: 0;
+ color: $colors-primary;
+
+ @include h3-text;
+ }
+}
+
+.notFound {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ align-items: center;
+ justify-content: center;
+ margin-top: 150px;
+
+ &__img {
+ width: 200px;
+ height: auto;
+ }
+
+ &__title {
+ color: $colors-primary;
+ margin: 0;
+
+ @include h2-text;
+ }
+
+ &__link {
+ color: $colors-secondary;
+ margin: 0;
+
+ @include body-text;
+
+ transition: color 0.3s ease;
+
+ &:hover {
+ color: $colors-primary;
+ }
+ }
+}
diff --git a/src/modules/ProductDetailsPage/ProductDetailsPage.tsx b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx
new file mode 100644
index 00000000000..821688021e0
--- /dev/null
+++ b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx
@@ -0,0 +1,295 @@
+import { NavLink, useNavigate, useParams } from 'react-router-dom';
+import { Product, ProductCategory } from '../../features/types/productType';
+import { useEffect, useMemo, useState } from 'react';
+import { ProductDetails } from '../../features/types/productDetailsType';
+import { getProductDetails, getProducts } from '../../api/products';
+import { Loader } from '../../components/Loader';
+import styles from './ProductDetailsPage.module.scss';
+import { ProductGallery } from '../../components/ProductGallery';
+import { ProductsCarousel } from '../../components/ProductsCarousel';
+import { Breadcrumbs } from '../../components/Breadcrumbs';
+import { Chevron } from '../../components/icons/Chevron';
+import { ProductActions } from '../../components/ProductActions';
+import { ProductOptions } from '../../components/ProductOptions';
+
+const isProductCategory = (value: string): value is ProductCategory =>
+ value === 'phones' || value === 'tablets' || value === 'accessories';
+
+const normalizeColorForUrl = (color: string) =>
+ color.toLowerCase().replace(/\s+/g, '-');
+
+export const ProductDetailsPage = () => {
+ const { category, productId } = useParams<{
+ category: string;
+ productId: string;
+ }>();
+
+ const [products, setProducts] = useState([]);
+ const [product, setProduct] = useState(null);
+
+ const [detailsLoading, setDetailsLoading] = useState(true);
+ const [recommendedLoading, setRecommendedLoading] = useState(true);
+
+ const [detailsError, setDetailsError] = useState('');
+ const [recommendedError, setRecommendedError] = useState('');
+
+ const [mainImage, setMainImage] = useState('');
+ const [isFirstRender, setIsFirstRender] = useState(true);
+
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ if (!category || !productId) {
+ setDetailsLoading(false);
+
+ return;
+ }
+
+ if (!isProductCategory(category)) {
+ setDetailsError('Unknown category');
+ setDetailsLoading(false);
+
+ return;
+ }
+
+ setDetailsLoading(true);
+ setDetailsError('');
+
+ getProductDetails(category, productId)
+ .then(data => {
+ setProduct(data);
+ setMainImage(data.images[0] ? `/${data.images[0]}` : '');
+ })
+ .catch(error => {
+ if (error instanceof Error && error.message === 'Product not found') {
+ setProduct(null);
+
+ return;
+ }
+
+ setDetailsError('Something went wrong');
+ })
+ .finally(() => setDetailsLoading(false));
+ }, [category, productId]);
+
+ useEffect(() => {
+ setRecommendedLoading(true);
+ setRecommendedError('');
+
+ getProducts()
+ .then(setProducts)
+ .catch(() => setRecommendedError('Failed to load recommended products'))
+ .finally(() => setRecommendedLoading(false));
+ }, []);
+
+ useEffect(() => {
+ if (isFirstRender) {
+ setIsFirstRender(false);
+
+ return;
+ }
+
+ window.scrollTo({
+ top: 0,
+ behavior: 'smooth',
+ });
+ }, [productId, isFirstRender]);
+
+ const recommended = useMemo(() => {
+ if (!category || !product) {
+ return [];
+ }
+
+ return products.filter(
+ item =>
+ item.category === category &&
+ item.itemId !== product.id &&
+ item.name !== product.name,
+ );
+ }, [products, category, product]);
+
+ if (detailsLoading && !product) {
+ return ;
+ }
+
+ if (detailsError) {
+ return (
+
+ );
+ }
+
+ if (!product || !category) {
+ return (
+
+

+
Product not found
+
+ Back to Home
+
+
+ );
+ }
+
+ const specs = [
+ { label: 'Screen', value: product.screen, isShort: true },
+ { label: 'Resolution', value: product.resolution, isShort: true },
+ { label: 'Processor', value: product.processor, isShort: true },
+ { label: 'RAM', value: product.ram, isShort: true },
+ { label: 'Camera', value: product.camera, isShort: false },
+ { label: 'Zoom', value: product.zoom, isShort: false },
+ { label: 'Cell', value: product.cell.join(', '), isShort: false },
+ ];
+
+ const shortSpecs = specs.filter(spec => spec.isShort);
+
+ const buildVariantLink = ({
+ color = product.color,
+ capacity = product.capacity,
+ }: {
+ color?: string;
+ capacity?: string;
+ }) => {
+ return `/${category}/${product.namespaceId}-${capacity.toLowerCase()}-${normalizeColorForUrl(color)}`;
+ };
+
+ return (
+
+
+
+
+
+
+
+
{product.name}
+
+
+
+
+
+
+
+
+
+
+
+
About
+
+
+ {product.description.map(section => (
+
+
+ {section.title}
+
+
+ {section.text}
+
+
+ ))}
+
+
+
+
Tech specs
+
+
+
+ {specs.map(spec => (
+
+
+ {spec.label}
+
+
+ {spec.value}
+
+
+ ))}
+
+
+
+
+ {!recommendedLoading && !recommendedError && recommended.length > 0 && (
+
+ )}
+
+
+ );
+};
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/modules/ProductsPage/ProductsPage.module.scss b/src/modules/ProductsPage/ProductsPage.module.scss
new file mode 100644
index 00000000000..68817129abd
--- /dev/null
+++ b/src/modules/ProductsPage/ProductsPage.module.scss
@@ -0,0 +1,88 @@
+@import '../../styles/global';
+
+.productPage {
+ margin-top: 24px;
+ min-height: 100vh;
+
+ @include pageGrid;
+
+ &__wrapper {
+ grid-column: 1 / -1;
+ display: flex;
+ flex-direction: column;
+ gap: 40px;
+ flex: 1;
+ }
+
+ &__textBlock {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ &__title {
+ margin: 0;
+
+ @include h1-text;
+ }
+
+ &__count {
+ margin: 0;
+
+ @include body-text;
+
+ color: $colors-secondary;
+ }
+
+ &__main {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ flex: 1;
+ }
+
+ &__controls {
+ @include innerGrid;
+ }
+
+ &__controlsSort {
+ grid-column: span 2;
+
+
+ @include onTablet {
+ grid-column: span 4;
+ }
+ }
+
+ &__controlsPerPage {
+ grid-column: span 2;
+
+ @include onTablet {
+ grid-column: span 3;
+ }
+ }
+
+ &__pagination {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+ gap: 16px;
+ }
+
+ &__paginationButtons {
+ display: flex;
+ flex-direction: row;
+ gap: 8px;
+ min-width: 0;
+ }
+
+ &__dots {
+ width: 20px;
+ height: 32px;
+ display: flex;
+ align-items: flex-end;
+ justify-content: center;
+ font-size: 12px;
+ }
+}
diff --git a/src/modules/ProductsPage/ProductsPage.tsx b/src/modules/ProductsPage/ProductsPage.tsx
new file mode 100644
index 00000000000..b1c0428363a
--- /dev/null
+++ b/src/modules/ProductsPage/ProductsPage.tsx
@@ -0,0 +1,276 @@
+import { useEffect, useMemo, useState } from 'react';
+import { Product, ProductCategory } from '../../features/types/productType';
+import { getProducts } from '../../api/products';
+import styles from './ProductsPage.module.scss';
+import { useSearchParams } from 'react-router-dom';
+import { Chevron } from '../../components/icons/Chevron';
+import { Dropdown } from '../../components/DropDown';
+import { Breadcrumbs } from '../../components/Breadcrumbs';
+import { SecondaryButton } from '../../components/SecondaryButton';
+import { Loader } from '../../components/Loader';
+import { ProductList } from '../../components/ProductsList';
+
+type Props = {
+ category: ProductCategory;
+};
+
+type Sort = 'age' | 'title' | 'price';
+
+type PerPage = '4' | '8' | '16' | 'all';
+
+const DEFAULT_PER_PAGE: PerPage = 'all';
+
+const categoryTitles: Record = {
+ phones: 'Mobile Phones',
+ tablets: 'Tablets',
+ accessories: 'Accessories',
+};
+
+const emptyMessages: Record = {
+ phones: 'There are no phones yet',
+ tablets: 'There are no tablets yet',
+ accessories: 'There are no accessories yet',
+};
+
+const SORT_OPTIONS = [
+ { value: 'age', label: 'Newest' },
+ { value: 'title', label: 'Alphabetically' },
+ { value: 'price', label: 'Cheapest' },
+] as const;
+
+const PER_PAGE_OPTIONS = [
+ { value: '4', label: '4' },
+ { value: '8', label: '8' },
+ { value: '16', label: '16' },
+ { value: 'all', label: 'all' },
+] as const;
+
+export const ProductsPage: React.FC = ({ category }) => {
+ const [products, setProducts] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ const perPage = (searchParams.get('perPage') as PerPage) || DEFAULT_PER_PAGE;
+ const page = Number(searchParams.get('page') || '1');
+
+ const handlePerPageChange = (value: PerPage) => {
+ const next = new URLSearchParams(searchParams);
+
+ if (value === 'all') {
+ next.delete('perPage');
+ } else {
+ next.set('perPage', value);
+ }
+
+ next.delete('page');
+
+ setSearchParams(next);
+ };
+
+ const sort = (searchParams.get('sort') as Sort) || 'age';
+
+ const handleSortChange = (value: Sort) => {
+ const next = new URLSearchParams(searchParams);
+
+ next.set('sort', value);
+ next.delete('page');
+
+ setSearchParams(next);
+ };
+
+ const loadProducts = () => {
+ setLoading(true);
+ setError('');
+
+ getProducts()
+ .then(data => setProducts(data))
+ .catch(() => setError('Something went wrong'))
+ .finally(() => setLoading(false));
+ };
+
+ useEffect(() => {
+ loadProducts();
+ }, []);
+
+ const categoryProducts = useMemo(() => {
+ return [...products].filter(p => p.category === category);
+ }, [products, category]);
+
+ const title = categoryTitles[category];
+
+ const sortedProducts = useMemo(() => {
+ const list = [...categoryProducts];
+
+ switch (sort) {
+ case 'title':
+ return list.sort((a, b) => a.name.localeCompare(b.name));
+ case 'price':
+ return list.sort((a, b) => a.price - b.price);
+ case 'age':
+ default:
+ return list.sort((a, b) => b.year - a.year);
+ }
+ }, [categoryProducts, sort]);
+
+ const totalItems = sortedProducts.length;
+
+ const perPageNumber = perPage === 'all' ? totalItems : Number(perPage);
+ const totalPages =
+ perPage === 'all' ? 1 : Math.max(1, Math.ceil(totalItems / perPageNumber));
+
+ const safePage = Math.min(Math.max(page, 1), totalPages);
+
+ const visibleProducts = useMemo(() => {
+ if (perPage === 'all') {
+ return sortedProducts;
+ }
+
+ const start = (safePage - 1) * perPageNumber;
+
+ return sortedProducts.slice(start, start + perPageNumber);
+ }, [sortedProducts, perPage, perPageNumber, safePage]);
+
+ const setPage = (nextPage: number) => {
+ const next = new URLSearchParams(searchParams);
+
+ if (nextPage === 1) {
+ next.delete('page');
+ } else {
+ next.set('page', String(nextPage));
+ }
+
+ setSearchParams(next);
+ };
+
+ const visiblePages = useMemo<(number | string)[]>(() => {
+ if (totalPages <= 5) {
+ return Array.from({ length: totalPages }, (_, i) => i + 1);
+ }
+
+ if (safePage <= 2) {
+ return [1, 2, 3, '...', totalPages];
+ }
+
+ if (safePage >= totalPages - 1) {
+ return [1, '...', totalPages - 2, totalPages - 1, totalPages];
+ }
+
+ return [1, '...', safePage - 1, safePage, safePage + 1, '...', totalPages];
+ }, [safePage, totalPages]);
+
+ const showPagination = perPage !== 'all' && totalPages > 1;
+
+ const count = categoryProducts.length;
+
+ if (loading) {
+ return ;
+ }
+
+ if (error) {
+ return (
+
+
Something went wrong
+
+
+ );
+ }
+
+ if (!categoryProducts.length) {
+ return (
+
+
+
+
+
+
+ {emptyMessages[category]}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
{title}
+
{count} models
+
+
+
+
+ {showPagination && (
+
+
setPage(safePage - 1)}
+ disabled={safePage === 1}
+ >
+
+
+
+
+ {visiblePages.map((item, index) =>
+ typeof item === 'string' ? (
+
+ ...
+
+ ) : (
+ setPage(item)}
+ isActive={item === safePage}
+ >
+ {item}
+
+ ),
+ )}
+
+
+
setPage(safePage + 1)}
+ disabled={safePage === totalPages}
+ >
+
+
+
+ )}
+
+
+ );
+};
diff --git a/src/modules/ProductsPage/index.ts b/src/modules/ProductsPage/index.ts
new file mode 100644
index 00000000000..8e350f20bf9
--- /dev/null
+++ b/src/modules/ProductsPage/index.ts
@@ -0,0 +1 @@
+export * from './ProductsPage';
diff --git a/src/styles/fonts.scss b/src/styles/fonts.scss
new file mode 100644
index 00000000000..90e5c7b3481
--- /dev/null
+++ b/src/styles/fonts.scss
@@ -0,0 +1,31 @@
+@font-face {
+ font-family: Mont;
+ src: url('/fonts/Mont-Regular.otf') format('opentype');
+ font-weight: 500;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: Mont;
+ src: url('/fonts/Mont-SemiBold.otf') format('opentype');
+ font-weight: 600;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: Mont;
+ src: url('/fonts/Mont-Bold.otf') format('opentype');
+ font-weight: 700;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: Mont;
+ src: url('/fonts/Mont-Bold.otf') format('opentype');
+ font-weight: 800;
+ font-style: normal;
+ font-display: swap;
+}
diff --git a/src/styles/global.scss b/src/styles/global.scss
new file mode 100644
index 00000000000..9b2c2044f6f
--- /dev/null
+++ b/src/styles/global.scss
@@ -0,0 +1,13 @@
+@import './fonts';
+@import './utils/vars';
+@import './utils/mixins';
+@import './utils/typography';
+
+html, body {
+ height: 100%;
+}
+
+body {
+ font-family: Mont, sans-serif;
+ margin: 0;
+}
diff --git a/src/styles/utils/_mixins.scss b/src/styles/utils/_mixins.scss
new file mode 100644
index 00000000000..5944c1ebe6f
--- /dev/null
+++ b/src/styles/utils/_mixins.scss
@@ -0,0 +1,41 @@
+@mixin pageGrid {
+ display: grid;
+ column-gap: 16px;
+ grid-template-columns: repeat(4, 1fr);
+
+ @include onTablet {
+ grid-template-columns: repeat(12, 1fr);
+ }
+
+ @include onDesktop {
+ grid-template-columns: repeat(24, 1fr);
+ max-width: 1200px;
+ margin: 0 auto;
+ }
+}
+
+@mixin innerGrid {
+ display: grid;
+ column-gap: 16px;
+ grid-template-columns: repeat(4, 1fr);
+
+ @include onTablet {
+ grid-template-columns: repeat(12, 1fr);
+ }
+
+ @include onDesktop {
+ grid-template-columns: repeat(24, 1fr);
+ }
+}
+
+@mixin onTablet {
+ @media (min-width: $tablet-min-width) {
+ @content;
+ }
+}
+
+@mixin onDesktop {
+ @media (min-width: $desktop-min-width) {
+ @content;
+ }
+}
diff --git a/src/styles/utils/typography.scss b/src/styles/utils/typography.scss
new file mode 100644
index 00000000000..09e6f6852aa
--- /dev/null
+++ b/src/styles/utils/typography.scss
@@ -0,0 +1,88 @@
+@import './mixins';
+
+@mixin h1-text {
+ font-family: Mont, sans-serif;
+ font-weight: 800;
+ font-size: 32px;
+ line-height: 41px;
+ letter-spacing: -0.01em;
+
+ @include onTablet {
+ font-size: 48px;
+ line-height: 56px;
+ }
+}
+
+@mixin h2-text {
+ font-family: Mont, sans-serif;
+ font-weight: 800;
+ font-size: 22px;
+ line-height: 31px;
+ letter-spacing: 0;
+
+ @include onTablet {
+ font-size: 32px;
+ line-height: 41px;
+ letter-spacing: -0.01em;
+ }
+}
+
+@mixin h3-text {
+ font-family: Mont, sans-serif;
+ font-weight: 700;
+ font-size: 20px;
+ line-height: 26px;
+ letter-spacing: 0;
+
+ @include onTablet {
+ font-weight: 800;
+ font-size: 22px;
+ line-height: 31px;
+ }
+}
+
+@mixin h4-text {
+ font-family: Mont, sans-serif;
+ font-weight: 600;
+ font-size: 16px;
+ line-height: 20px;
+ letter-spacing: 0;
+
+ @include onTablet {
+ font-size: 20px;
+ line-height: 26px;
+ }
+}
+
+@mixin uppercase-text {
+ font-family: Mont, sans-serif;
+ font-weight: 800;
+ font-size: 12px;
+ line-height: 11px;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+@mixin button-text {
+ font-family: Mont, sans-serif;
+ font-weight: 600;
+ font-size: 14px;
+ line-height: 21px;
+ letter-spacing: 0;
+}
+
+@mixin body-text {
+ font-family: Mont, sans-serif;
+ font-weight: 500;
+ font-size: 14px;
+ line-height: 21px;
+ letter-spacing: 0;
+}
+
+@mixin small-text {
+ font-family: Mont, sans-serif;
+ font-weight: 700;
+ font-size: 12px;
+ line-height: 15px;
+ letter-spacing: 0;
+}
diff --git a/src/styles/utils/vars.scss b/src/styles/utils/vars.scss
new file mode 100644
index 00000000000..c59722c973b
--- /dev/null
+++ b/src/styles/utils/vars.scss
@@ -0,0 +1,22 @@
+@import './mixins';
+
+$tablet-min-width: 640px;
+$desktop-min-width: 1199px;
+$colors-primary: #313237;
+$colors-secondary: #89939A;
+$colors-icons: #B4BDC3;
+$colors-elements: #E2E6E9;
+$colors-hover-bg: #FAFBFC;
+$colors-white: #fff;
+$colors-green: #27AE60;
+$colors-red: #EB5757;
+
+:root {
+ --header-height: 48px;
+}
+
+@include onDesktop {
+ :root {
+ --header-height: 64px;
+ }
+}
diff --git a/vercel.json b/vercel.json
new file mode 100644
index 00000000000..1323cdac34c
--- /dev/null
+++ b/vercel.json
@@ -0,0 +1,8 @@
+{
+ "rewrites": [
+ {
+ "source": "/(.*)",
+ "destination": "/index.html"
+ }
+ ]
+}