diff --git a/.stylelintrc.js b/.stylelintrc.js
index f3a4e74272a..8c5fe09f40c 100644
--- a/.stylelintrc.js
+++ b/.stylelintrc.js
@@ -1,4 +1,11 @@
module.exports = {
- extends: "@mate-academy/stylelint-config",
- rules: {}
+ extends: '@mate-academy/stylelint-config',
+ rules: {
+ 'selector-pseudo-class-no-unknown': [
+ true,
+ {
+ ignorePseudoClasses: ['global', 'local'],
+ },
+ ],
+ },
};
diff --git a/index.html b/index.html
index 095fb3a4537..fca93f8b655 100644
--- a/index.html
+++ b/index.html
@@ -2,8 +2,9 @@
+
- Vite + React + TS
+ Nice Gadgets
diff --git a/package-lock.json b/package-lock.json
index 836b9e63b46..61bf462e0c9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,11 +16,12 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.25.1",
- "react-transition-group": "^4.4.5"
+ "react-transition-group": "^4.4.5",
+ "swiper": "^12.1.4"
},
"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,9 +1185,9 @@
}
},
"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,
"dependencies": {
"@octokit/rest": "^17.11.2",
@@ -9930,6 +9931,24 @@
"integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==",
"dev": true
},
+ "node_modules/swiper": {
+ "version": "12.1.4",
+ "resolved": "https://registry.npmjs.org/swiper/-/swiper-12.1.4.tgz",
+ "integrity": "sha512-bihiwoKMOQwW8FfdUbo1DgkVH25E+4ZELIq0oopL1KTKBteLuaTMi/wwFjMxtlhTkk45k3XQ89D1Fvv0spSqBA==",
+ "funding": [
+ {
+ "type": "custom",
+ "url": "https://sponsors.nolimits4web.com"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nolimits4web"
+ }
+ ],
+ "engines": {
+ "node": ">= 4.7.0"
+ }
+ },
"node_modules/synckit": {
"version": "0.8.8",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.8.tgz",
diff --git a/package.json b/package.json
index ae251685c8b..abbb8b04d36 100644
--- a/package.json
+++ b/package.json
@@ -12,11 +12,12 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.25.1",
- "react-transition-group": "^4.4.5"
+ "react-transition-group": "^4.4.5",
+ "swiper": "^12.1.4"
},
"devDependencies": {
"@cypress/react18": "^2.0.1",
- "@mate-academy/scripts": "^1.8.5",
+ "@mate-academy/scripts": "^2.1.3",
"@mate-academy/students-ts-config": "*",
"@mate-academy/stylelint-config": "*",
"@types/node": "^20.14.10",
diff --git a/public/img/airpods.png b/public/img/airpods.png
new file mode 100644
index 00000000000..9ab8e7d12b7
Binary files /dev/null and b/public/img/airpods.png differ
diff --git a/public/img/apple-watch.png b/public/img/apple-watch.png
new file mode 100644
index 00000000000..c5724a9d0a3
Binary files /dev/null and b/public/img/apple-watch.png differ
diff --git a/public/img/category-accessory.png b/public/img/category-accessory.png
new file mode 100644
index 00000000000..d342403a325
Binary files /dev/null and b/public/img/category-accessory.png differ
diff --git a/public/img/category-phone.png b/public/img/category-phone.png
new file mode 100644
index 00000000000..cd4718762fc
Binary files /dev/null and b/public/img/category-phone.png differ
diff --git a/public/img/category-tablet.png b/public/img/category-tablet.png
new file mode 100644
index 00000000000..a82c660ffe5
Binary files /dev/null and b/public/img/category-tablet.png differ
diff --git a/public/img/icons/apple-logo.svg b/public/img/icons/apple-logo.svg
new file mode 100644
index 00000000000..3a26e9afbb5
--- /dev/null
+++ b/public/img/icons/apple-logo.svg
@@ -0,0 +1,4 @@
+
+
\ No newline at end of file
diff --git a/public/img/icons/cart.svg b/public/img/icons/cart.svg
new file mode 100644
index 00000000000..6deb3bf9b71
--- /dev/null
+++ b/public/img/icons/cart.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/img/icons/chevron-right-light.svg b/public/img/icons/chevron-right-light.svg
new file mode 100644
index 00000000000..a15457b9ad6
--- /dev/null
+++ b/public/img/icons/chevron-right-light.svg
@@ -0,0 +1,3 @@
+
diff --git a/public/img/icons/chevron-right.svg b/public/img/icons/chevron-right.svg
new file mode 100644
index 00000000000..b4f46876718
--- /dev/null
+++ b/public/img/icons/chevron-right.svg
@@ -0,0 +1,3 @@
+
diff --git a/public/img/icons/close.svg b/public/img/icons/close.svg
new file mode 100644
index 00000000000..aadcc91fb1f
--- /dev/null
+++ b/public/img/icons/close.svg
@@ -0,0 +1,3 @@
+
diff --git a/public/img/icons/favourites-filled.svg b/public/img/icons/favourites-filled.svg
new file mode 100644
index 00000000000..7138d7522bf
--- /dev/null
+++ b/public/img/icons/favourites-filled.svg
@@ -0,0 +1,3 @@
+
diff --git a/public/img/icons/favourites.svg b/public/img/icons/favourites.svg
new file mode 100644
index 00000000000..d29d2a36aab
--- /dev/null
+++ b/public/img/icons/favourites.svg
@@ -0,0 +1,3 @@
+
diff --git a/public/img/icons/home.svg b/public/img/icons/home.svg
new file mode 100644
index 00000000000..eb96fdbdc74
--- /dev/null
+++ b/public/img/icons/home.svg
@@ -0,0 +1,4 @@
+
diff --git a/public/img/icons/menu.svg b/public/img/icons/menu.svg
new file mode 100644
index 00000000000..2c535f4586b
--- /dev/null
+++ b/public/img/icons/menu.svg
@@ -0,0 +1,5 @@
+
diff --git a/public/img/icons/minus.svg b/public/img/icons/minus.svg
new file mode 100644
index 00000000000..97c41038ac7
--- /dev/null
+++ b/public/img/icons/minus.svg
@@ -0,0 +1,3 @@
+
diff --git a/public/img/icons/plus.svg b/public/img/icons/plus.svg
new file mode 100644
index 00000000000..ab3c34061b5
--- /dev/null
+++ b/public/img/icons/plus.svg
@@ -0,0 +1,3 @@
+
diff --git a/public/img/iphone17.png b/public/img/iphone17.png
new file mode 100644
index 00000000000..31aad3f0b18
Binary files /dev/null and b/public/img/iphone17.png differ
diff --git a/public/img/logo.svg b/public/img/logo.svg
new file mode 100644
index 00000000000..800bc6f277b
--- /dev/null
+++ b/public/img/logo.svg
@@ -0,0 +1,25 @@
+
diff --git a/src/App.scss b/src/App.scss
index 71bc413aade..e905e867169 100644
--- a/src/App.scss
+++ b/src/App.scss
@@ -1 +1,25 @@
-// not empty
+@import './styles/fonts';
+@import './styles/mixins';
+@import './styles/globals';
+@import './styles/typography';
+@import './styles/variables';
+
+html,
+body {
+ scroll-behavior: smooth;
+ margin: 0;
+ padding: 0;
+ font-family: Montserrat, sans-serif;
+}
+
+a {
+ display: inline-block;
+
+ img {
+ display: block;
+ }
+}
+
+.container {
+ @include content-padding-inline;
+}
diff --git a/src/App.tsx b/src/App.tsx
index 372e4b42066..a85b265ea0f 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,7 +1,20 @@
+import { Outlet } from 'react-router-dom';
import './App.scss';
+import { Footer } from './components/Footer';
+import { Header } from './components/Header';
-export const App = () => (
-
-
Product Catalog
-
-);
+function App() {
+ return (
+
+ );
+}
+
+export default App;
diff --git a/src/CartContext.tsx b/src/CartContext.tsx
new file mode 100644
index 00000000000..adee6b608d5
--- /dev/null
+++ b/src/CartContext.tsx
@@ -0,0 +1,36 @@
+import React, { useCallback, useMemo } from 'react';
+import { useLocalStorage } from './hooks/useLocalStorage';
+import type { Cart } from './types/Cart';
+
+type CartContextType = {
+ cart: Cart[];
+ setCart: React.Dispatch>;
+};
+
+export const CartContext = React.createContext({
+ cart: [],
+ setCart: () => {},
+});
+
+type Props = {
+ children: React.ReactNode;
+};
+
+export const CartProvider: React.FC = ({ children }) => {
+ const [cart, saveCart] = useLocalStorage('cart', []);
+
+ const setCart: React.Dispatch> = useCallback(
+ value => {
+ if (typeof value === 'function') {
+ saveCart(value(cart));
+ } else {
+ saveCart(value);
+ }
+ },
+ [cart, saveCart],
+ );
+
+ const value = useMemo(() => ({ cart, setCart }), [cart, setCart]);
+
+ return {children};
+};
diff --git a/src/FavouritesContext.tsx b/src/FavouritesContext.tsx
new file mode 100644
index 00000000000..43d5070b22d
--- /dev/null
+++ b/src/FavouritesContext.tsx
@@ -0,0 +1,47 @@
+import React, { useCallback, useMemo } from 'react';
+import { useLocalStorage } from './hooks/useLocalStorage';
+import type { Product } from './types/Product';
+
+type FavouritesContextType = {
+ favourites: Product[];
+ setFavourites: React.Dispatch>;
+};
+
+export const FavouritesContext = React.createContext({
+ favourites: [],
+ setFavourites: () => {},
+});
+
+type Props = {
+ children: React.ReactNode;
+};
+
+export const FavouritesProvider: React.FC = ({ children }) => {
+ const [favourites, saveFavourites] = useLocalStorage(
+ 'favourites',
+ [],
+ );
+
+ const setFavourites: React.Dispatch> =
+ useCallback(
+ value => {
+ if (typeof value === 'function') {
+ saveFavourites(value(favourites));
+ } else {
+ saveFavourites(value);
+ }
+ },
+ [favourites, saveFavourites],
+ );
+
+ const value = useMemo(
+ () => ({ favourites, setFavourites }),
+ [favourites, setFavourites],
+ );
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/Root.tsx b/src/Root.tsx
new file mode 100644
index 00000000000..bf76450f41b
--- /dev/null
+++ b/src/Root.tsx
@@ -0,0 +1,31 @@
+import { HashRouter, Navigate, Route, Routes } from 'react-router-dom';
+import App from './App';
+import { NotFoundPage } from './modules/NotFoundPage';
+import { CartPage } from './modules/CartPage/CartPage';
+import { FavoritesPage } from './modules/FavoritesPage';
+import { HomePage } from './modules/HomePage';
+import { CatalogPage } from './modules/CatalogPage';
+import { ProductDetailsPage } from './modules/ProductDetailsPage';
+
+export const Root = () => {
+ return (
+
+
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ }
+ >
+ } />
+
+
+
+ );
+};
diff --git a/src/api.ts b/src/api.ts
new file mode 100644
index 00000000000..fc3d6ffdc7f
--- /dev/null
+++ b/src/api.ts
@@ -0,0 +1,9 @@
+function wait(delay: number) {
+ return new Promise(resolve => setTimeout(resolve, delay));
+}
+
+export async function getProducts(type: string): Promise {
+ return wait(1)
+ .then(() => fetch(`./api/${type}.json`))
+ .then(response => response.json());
+}
diff --git a/src/components/Footer/Footer.module.scss b/src/components/Footer/Footer.module.scss
new file mode 100644
index 00000000000..a8f3d3b1ac2
--- /dev/null
+++ b/src/components/Footer/Footer.module.scss
@@ -0,0 +1,60 @@
+@import './../../styles/mixins';
+@import './../../styles/typography';
+
+
+.footer {
+ border-top: 1px solid var(--color-elements);
+
+ &__content {
+ display: flex;
+ justify-content: space-between;
+ flex-direction: column;
+ padding-block: 32px;
+ gap: 32px;
+
+ @include on-tablet {
+ flex-direction: row;
+ }
+ }
+
+ &__items {
+ display: flex;
+ flex-direction: column;
+ align-items: start;
+ gap: 16px;
+
+ @include on-tablet {
+ flex-direction: row;
+ align-items: center;
+ gap: 13px;
+ }
+
+ @include on-desktop {
+ flex-direction: row;
+ align-items: center;
+ gap: 106px;
+ }
+ }
+
+ &__item {
+ text-decoration: none;
+ text-transform: uppercase;
+ color: var(--color-secondary);
+
+ @include uppercase;
+ }
+
+ &__toTop {
+ display: flex;
+ justify-content: center;
+ gap: 16px;
+ }
+
+ &__back {
+ display: flex;
+ align-items: center;
+ color: var(--color-secondary);
+
+ @include small-text;
+ }
+}
diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx
new file mode 100644
index 00000000000..52eef0626d1
--- /dev/null
+++ b/src/components/Footer/Footer.tsx
@@ -0,0 +1,48 @@
+import { Link } from 'react-router-dom';
+import s from './Footer.module.scss';
+import { SliderButton } from '../../modules/shared/SliderButton';
+
+export const Footer = () => {
+ 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..c1c13075d0f
--- /dev/null
+++ b/src/components/Header/Header.module.scss
@@ -0,0 +1,115 @@
+@import './../../styles/mixins';
+
+.header {
+ display: flex;
+ gap: 16px;
+ border-bottom: 1px solid var(--color-elements);
+ justify-content: space-between;
+ align-items: center;
+ height: 48px;
+
+ @include on-desktop {
+ gap: 24px;
+ height: 64px;
+ }
+
+ &__logo-link {
+ padding: 13px 16px;
+
+ @include on-desktop {
+ padding: 18px 24px;
+ }
+ }
+
+ &__logo {
+ height: 22px;
+ width: 64px;
+
+ @include on-desktop {
+ height: 28px;
+ width: 80px;
+ }
+ }
+
+ &__nav {
+ height: 100%;
+ display: none;
+
+ @include on-tablet {
+ display: flex;
+ }
+ }
+
+ &__favorites-cart {
+ display: none;
+
+ @include on-tablet {
+ flex: 1;
+ display: flex;
+ justify-content: flex-end;
+ height: 100%;
+ }
+ }
+
+ &__favorites,
+ &__cart {
+ padding: 16px;
+ border-left: 1px solid var(--color-elements);
+ box-sizing: border-box;
+ position: relative;
+
+ @include on-desktop {
+ padding: 24px;
+ }
+
+ &--active {
+ &::after {
+ content: '';
+ height: 3px;
+ width: 100%;
+ background: var(--color-primary-dark);
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ }
+ }
+ }
+
+ &__menu {
+ display: flex;
+ align-items: center;
+ padding-inline: 16px;
+ border-left: 1px solid var(--color-elements);
+ height: 100%;
+
+ @include on-tablet {
+ display: none;
+ }
+ }
+
+ &__iconWrap {
+ display: flex;
+ position: relative;
+ }
+
+ &__counter {
+ position: absolute;
+ top: -6px;
+ left: 7px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background-color: var(--color-red);
+ box-sizing: border-box;
+ border: 1px solid white;
+ border-radius: 50%;
+ color: white;
+ height: 14px;
+ width: 14px;
+ font-weight: 600;
+ font-size: 10px;
+ line-height: 100%;
+ letter-spacing: 0%;
+ padding-top: 2px;
+ }
+}
diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx
new file mode 100644
index 00000000000..930e50adf1b
--- /dev/null
+++ b/src/components/Header/Header.tsx
@@ -0,0 +1,66 @@
+import { Link, useLocation } from 'react-router-dom';
+import s from './Header.module.scss';
+import { NavBar } from '../NavBar';
+import classNames from 'classnames';
+import { MobileMenu } from '../MobileMenu';
+import { useContext, useState } from 'react';
+import { CartContext } from '../../CartContext';
+import { FavouritesContext } from '../../FavouritesContext';
+
+export const Header = () => {
+ const [isOpen, setIsOpen] = useState(false);
+ const { cart } = useContext(CartContext);
+ const { favourites } = useContext(FavouritesContext);
+ const { pathname } = useLocation();
+
+ const cartAmount = cart.reduce((sum, item) => sum + item.quantity, 0);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+

+ {favourites.length > 0 && (
+
{favourites.length}
+ )}
+
+
+
+
+

+ {cartAmount > 0 && (
+
{cartAmount}
+ )}
+
+
+
+
+ setIsOpen(true)}>
+
+

+
+
+
+
+
+ );
+};
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/MobileMenu/MobileMenu.module.scss b/src/components/MobileMenu/MobileMenu.module.scss
new file mode 100644
index 00000000000..1fb66a1f4c2
--- /dev/null
+++ b/src/components/MobileMenu/MobileMenu.module.scss
@@ -0,0 +1,121 @@
+@import './../../styles/variables';
+@import './../../styles/mixins';
+
+.menu {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100vh;
+ background: white;
+ transform: translateX(-100%);
+ transition: transform 0.3s ease;
+ z-index: 2;
+
+ &__top {
+ display: flex;
+ justify-content: space-between;
+ border-bottom: 1px solid var(--color-elements);
+ height: 48px;
+
+ @include on-desktop {
+ height: 64px;
+ }
+ }
+
+ &__logo {
+ padding: 13px 16px;
+
+ @include on-desktop {
+ padding: 18px 24px;
+ }
+ }
+
+ &__logo-img {
+ height: 22px;
+ width: 64px;
+
+ @include on-desktop {
+ height: 28px;
+ width: 80px;
+ }
+ }
+
+ &__close {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding-inline: 16px;
+ border-left: 1px solid var(--color-elements);
+ height: 100%;
+ }
+
+ &__nav {
+ margin-top: 24px;
+ }
+
+ &__favorites-cart {
+ display: flex;
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ border-top: 1px solid var(--color-elements);
+ }
+
+ &__favorites,
+ &__cart {
+ display: flex;
+ justify-content: center;
+ padding-block: 24px;
+ box-sizing: border-box;
+ position: relative;
+ width: 50%;
+
+ &--active {
+ &::after {
+ content: '';
+ height: 2px;
+ width: 100%;
+ background: var(--color-primary-dark);
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ }
+ }
+ }
+
+ &__cart {
+ border-left: 1px solid var(--color-elements);
+ }
+
+ &__iconWrap {
+ position: relative;
+ margin: auto;
+ }
+
+ &__counter {
+ position: absolute;
+ top: -6px;
+ left: 7px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background-color: var(--color-red);
+ box-sizing: border-box;
+ border: 1px solid white;
+ border-radius: 50%;
+ color: white;
+ height: 14px;
+ width: 14px;
+ font-weight: 600;
+ font-size: 10px;
+ line-height: 100%;
+ letter-spacing: 0%;
+ padding-top: 2px;
+ }
+
+ &--open {
+ transform: translateX(0);
+ }
+}
diff --git a/src/components/MobileMenu/MobileMenu.tsx b/src/components/MobileMenu/MobileMenu.tsx
new file mode 100644
index 00000000000..c4d3e06c3e8
--- /dev/null
+++ b/src/components/MobileMenu/MobileMenu.tsx
@@ -0,0 +1,77 @@
+import classNames from 'classnames';
+import { NavBar } from '../NavBar';
+import s from './MobileMenu.module.scss';
+import { Link, useLocation } from 'react-router-dom';
+import { useContext } from 'react';
+import { CartContext } from '../../CartContext';
+import { FavouritesContext } from '../../FavouritesContext';
+
+type Props = {
+ isOpen: boolean;
+ onClose: (v: boolean) => void;
+};
+
+export const MobileMenu = ({ isOpen, onClose }: Props) => {
+ const { cart } = useContext(CartContext);
+ const { favourites } = useContext(FavouritesContext);
+ const { pathname } = useLocation();
+
+ const favouritesAmount = favourites.length;
+ const cartAmount = cart.reduce((sum, item) => sum + item.quantity, 0);
+
+ return (
+
+
+
+

+
+
onClose(false)}>
+

+
+
+
+
+
+
+
+
+
onClose(false)}
+ >
+
+

+ {favouritesAmount > 0 && (
+
{favouritesAmount}
+ )}
+
+
+
onClose(false)}
+ >
+
+

+ {cartAmount > 0 && (
+
{cartAmount}
+ )}
+
+
+
+
+ );
+};
diff --git a/src/components/MobileMenu/index.ts b/src/components/MobileMenu/index.ts
new file mode 100644
index 00000000000..298a8ff2b8f
--- /dev/null
+++ b/src/components/MobileMenu/index.ts
@@ -0,0 +1 @@
+export * from './MobileMenu';
diff --git a/src/components/NavBar/NavBar.module.scss b/src/components/NavBar/NavBar.module.scss
new file mode 100644
index 00000000000..f6e9274833c
--- /dev/null
+++ b/src/components/NavBar/NavBar.module.scss
@@ -0,0 +1,56 @@
+@import './../../styles/mixins';
+@import './../../styles/typography';
+
+.nav {
+ display: flex;
+ flex-direction: column;
+ gap: 32px;
+ height: 100%;
+ width: 100%;
+ align-items: center;
+
+ @include on-tablet {
+ flex-direction: row;
+ }
+
+ @include on-desktop {
+ gap: 64px;
+ }
+
+ &__link {
+ text-decoration: none;
+ text-transform: uppercase;
+ display: block;
+ align-items: center;
+ position: relative;
+ width: fit-content;
+ height: 100%;
+ padding-bottom: 8px;
+ color: var(--color-secondary);
+
+ @include uppercase;
+
+ @include on-tablet {
+ display: flex;
+ padding-bottom: 0;
+ }
+
+ &--active {
+ color: var(--color-primary-dark);
+
+ &::after {
+ content: '';
+ height: 2px;
+ width: 100%;
+ background: var(--color-primary-dark);
+ position: absolute;
+ bottom: 0;
+ left: 0;
+
+ @include on-tablet {
+ height: 3px;
+ }
+ }
+ }
+ }
+}
diff --git a/src/components/NavBar/NavBar.tsx b/src/components/NavBar/NavBar.tsx
new file mode 100644
index 00000000000..5ab66073ab5
--- /dev/null
+++ b/src/components/NavBar/NavBar.tsx
@@ -0,0 +1,32 @@
+import { Link, useLocation } from 'react-router-dom';
+import s from './NavBar.module.scss';
+import classNames from 'classnames';
+
+type Props = {
+ onClose?: (v: boolean) => void;
+};
+
+export const NavBar = ({ onClose = () => {} }: Props) => {
+ const { pathname } = useLocation();
+
+ return (
+
+ );
+};
diff --git a/src/components/NavBar/index.ts b/src/components/NavBar/index.ts
new file mode 100644
index 00000000000..39c20679455
--- /dev/null
+++ b/src/components/NavBar/index.ts
@@ -0,0 +1 @@
+export * from './NavBar';
diff --git a/src/global.d.ts b/src/global.d.ts
new file mode 100644
index 00000000000..7d7f17f7e21
--- /dev/null
+++ b/src/global.d.ts
@@ -0,0 +1,4 @@
+declare module 'swiper/css';
+declare module 'swiper/css/*';
+
+declare module '*.scss';
diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts
new file mode 100644
index 00000000000..cb38c89bc81
--- /dev/null
+++ b/src/hooks/useLocalStorage.ts
@@ -0,0 +1,29 @@
+import { useState } from 'react';
+
+export function useLocalStorage(
+ key: string,
+ startValue: T,
+): [T, (v: T) => void] {
+ const [value, setValue] = useState(() => {
+ const data = localStorage.getItem(key);
+
+ if (data === null) {
+ return startValue;
+ }
+
+ try {
+ return JSON.parse(data);
+ } catch (error) {
+ localStorage.removeItem(key);
+
+ return startValue;
+ }
+ });
+
+ const save = (newValue: T) => {
+ localStorage.setItem(key, JSON.stringify(newValue));
+ setValue(newValue);
+ };
+
+ return [value, save];
+}
diff --git a/src/index.tsx b/src/index.tsx
index 50470f1508d..abcf16cfbee 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,4 +1,15 @@
+import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
-import { App } from './App';
+import { Root } from './Root.js';
+import { CartProvider } from './CartContext.js';
+import { FavouritesProvider } from './FavouritesContext.js';
-createRoot(document.getElementById('root') as HTMLElement).render();
+createRoot(document.getElementById('root')!).render(
+
+
+
+
+
+
+ ,
+);
diff --git a/src/modules/CartPage/CartPage.module.scss b/src/modules/CartPage/CartPage.module.scss
new file mode 100644
index 00000000000..a85aba18804
--- /dev/null
+++ b/src/modules/CartPage/CartPage.module.scss
@@ -0,0 +1,95 @@
+@import './../../styles/mixins';
+@import './../../styles/typography';
+
+.cart {
+ &__title {
+ @include h1;
+ }
+
+ &__content {
+ @include page-grid;
+
+ grid-gap: 32px;
+ margin-bottom: 56px;
+
+ @include on-tablet {
+ margin-bottom: 64px;
+ }
+
+ @include on-desktop {
+ margin-bottom: 80px;
+ }
+ }
+
+ &__list {
+ grid-column: 1 / -1;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+
+ @include on-desktop {
+ grid-column: 1 / 17;
+ }
+ }
+
+ &__total {
+ grid-column: 1 / -1;
+
+ @include on-desktop {
+ grid-column: 17 / -1;
+ }
+ }
+
+ &__totalContent {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ padding: 24px;
+ gap: 16px;
+ border: 1px solid var(--color-elements);
+ }
+
+ &__totalPriceAmount {
+ display: flex;
+ margin-inline: auto;
+ flex-direction: column;
+ }
+
+ &__totalPrice {
+ display: flex;
+ margin-inline: auto;
+ color: var(--color-primary-dark);
+ font-weight: 800;
+ font-size: 32px;
+ line-height: 41px;
+ text-align: center;
+ letter-spacing: -1%;
+ }
+
+ &__totalAmount {
+ color: var(--color-secondary);
+
+ @include body-text;
+ }
+
+ &__totalButton {
+ height: 48px;
+ display: flex;
+ }
+
+ &__isEmpty {
+ width: 80%;
+ display: flex;
+ margin-inline: auto;
+
+ @include on-tablet {
+ width: 60%;
+
+ }
+
+ @include on-desktop {
+ width: 45%;
+
+ }
+ }
+}
diff --git a/src/modules/CartPage/CartPage.tsx b/src/modules/CartPage/CartPage.tsx
new file mode 100644
index 00000000000..d9221308a28
--- /dev/null
+++ b/src/modules/CartPage/CartPage.tsx
@@ -0,0 +1,95 @@
+import { useLocation } from 'react-router-dom';
+import { useContext, useState } from 'react';
+import { CartContext } from '../../CartContext';
+import { capitalizeFirstLetter } from '../../utils/string';
+import { Back } from '../shared/Back';
+import s from './CartPage.module.scss';
+import { Line } from '../shared/Line';
+import { PrimaryButton } from '../shared/PrimaryButton';
+import { CartItem } from './components/CartItem/CartItem';
+import { CheckoutModal } from './components/CheckoutModal/CheckoutModal';
+
+export const CartPage = () => {
+ const { pathname } = useLocation();
+
+ const { cart, setCart } = useContext(CartContext) || {
+ cart: [],
+ setCart: () => {},
+ };
+
+ const [isModalOpen, setIsModalOpen] = useState(false);
+
+ const type = pathname.slice(1);
+
+ const totalPrice = cart.reduce(
+ (sum, item) => sum + item.product.price * item.quantity,
+ 0,
+ );
+
+ const totalAmount = cart.reduce((sum, item) => sum + item.quantity, 0);
+
+ const handleCheckout = () => {
+ setIsModalOpen(true);
+ };
+
+ const handleConfirmCheckout = () => {
+ setCart([]);
+ setIsModalOpen(false);
+ };
+
+ const handleCancelCheckout = () => {
+ setIsModalOpen(false);
+ };
+
+ return (
+
+
+
+ {cart.length > 0 ? (
+ <>
+
{capitalizeFirstLetter(type)}
+
+
+
+ {cart.map(item => (
+
+ ))}
+
+
+
+
+
+
${totalPrice}
+
+
+ Total for {totalAmount} items
+
+
+
+
+
+
+
+
+
+
+
+ >
+ ) : (
+

+ )}
+
+ );
+};
diff --git a/src/modules/CartPage/components/CartItem/CartItem.module.scss b/src/modules/CartPage/components/CartItem/CartItem.module.scss
new file mode 100644
index 00000000000..9fb2d562feb
--- /dev/null
+++ b/src/modules/CartPage/components/CartItem/CartItem.module.scss
@@ -0,0 +1,68 @@
+@import './../../../../styles/typography';
+@import './../../../../styles/mixins';
+
+.cartItem {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding: 16px;
+ border: 1px solid var(--color-elements);
+
+ @include on-tablet {
+ flex-direction: row;
+ justify-content: space-between;
+ }
+
+ &__firstRow {
+ display: flex;
+ gap: 16px;
+ align-items: center;
+ }
+
+ &__delButton {
+ all: unset;
+ }
+
+ &__img {
+ height: 80px;
+ width: 80px;
+ object-fit: contain;
+ }
+
+ &__name {
+ flex: 1;
+ text-decoration: none;
+ color: var(--color-primary-dark);
+
+ @include body-text;
+ }
+
+ &__secondRow {
+ display: flex;
+ justify-content: space-between;
+
+ @include on-tablet {
+ gap: 16px;
+ justify-content: space-between;
+ }
+ }
+
+ &__counter {
+ display: flex;
+ align-items: center;
+ gap: 13px;
+
+ @include body-text;
+ }
+
+ &__price {
+ display: flex;
+ align-items: center;
+ justify-content: end;
+ color: var(--color-primary-dark);
+ font-weight: 800;
+ font-size: 22px;
+ line-height: 140%;
+ min-width: 80px;
+ }
+}
diff --git a/src/modules/CartPage/components/CartItem/CartItem.tsx b/src/modules/CartPage/components/CartItem/CartItem.tsx
new file mode 100644
index 00000000000..734d37a4298
--- /dev/null
+++ b/src/modules/CartPage/components/CartItem/CartItem.tsx
@@ -0,0 +1,70 @@
+import { Link } from 'react-router-dom';
+import type { Cart } from '../../../../types/Cart';
+import s from './CartItem.module.scss';
+import { useContext } from 'react';
+import { CartContext } from '../../../../CartContext';
+import { SliderButton } from '../../../shared/SliderButton';
+
+type Props = {
+ cartItem: Cart;
+};
+
+export const CartItem: React.FC = ({ cartItem }) => {
+ const { setCart } = useContext(CartContext);
+
+ return (
+
+
+
+
+

+
+
+ {cartItem.product.name}
+
+
+
+
+
+ setCart(prev =>
+ prev.map(item =>
+ item.id === cartItem.id
+ ? { ...item, quantity: item.quantity - 1 }
+ : item,
+ ),
+ )
+ }
+ />
+ {cartItem.quantity}
+
+ setCart(prev =>
+ prev.map(item =>
+ item.id === cartItem.id
+ ? { ...item, quantity: item.quantity + 1 }
+ : item,
+ ),
+ )
+ }
+ />
+
+
${cartItem.product.price}
+
+
+ );
+};
diff --git a/src/modules/CartPage/components/CartItem/index.ts b/src/modules/CartPage/components/CartItem/index.ts
new file mode 100644
index 00000000000..dc0dc8965a3
--- /dev/null
+++ b/src/modules/CartPage/components/CartItem/index.ts
@@ -0,0 +1 @@
+export * from '.';
diff --git a/src/modules/CartPage/components/CheckoutModal/CheckoutModal.module.scss b/src/modules/CartPage/components/CheckoutModal/CheckoutModal.module.scss
new file mode 100644
index 00000000000..c7728e6364a
--- /dev/null
+++ b/src/modules/CartPage/components/CheckoutModal/CheckoutModal.module.scss
@@ -0,0 +1,68 @@
+@import './../../../../styles/typography';
+
+.modalOverlay {
+ position: fixed;
+ inset: 0;
+ background-color: rgba(0, 0, 0, 0.5);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 1000;
+ padding: 16px;
+}
+
+.modal {
+ width: 100%;
+ max-width: 420px;
+ background-color: var(--color-white);
+ border-radius: 16px;
+ padding: 24px;
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
+
+ &__title {
+ font-size: 24px;
+ font-weight: 700;
+ margin-bottom: 12px;
+ text-align: center;
+ color: var(--color-primary-dark);
+ }
+
+ &__text {
+ font-size: 16px;
+ line-height: 1.5;
+ text-align: center;
+ color: var(--color-secondary);
+ margin-bottom: 24px;
+ }
+
+ &__actions {
+ display: flex;
+ gap: 12px;
+ }
+
+ &__button {
+ flex: 1;
+ height: 48px;
+ border: none;
+ cursor: pointer;
+ font-weight: 600;
+ font-size: 16px;
+ transition: 0.3s;
+
+ @include button-text;
+
+ &:hover {
+ opacity: 0.9;
+ }
+ }
+
+ &__buttonCancel {
+ background-color: var(--color-elements);
+ color: var(--color-primary-dark);
+ }
+
+ &__buttonConfirm {
+ background-color: var(--color-primary-dark);
+ color: white;
+ }
+}
diff --git a/src/modules/CartPage/components/CheckoutModal/CheckoutModal.tsx b/src/modules/CartPage/components/CheckoutModal/CheckoutModal.tsx
new file mode 100644
index 00000000000..e23c1aaf1fb
--- /dev/null
+++ b/src/modules/CartPage/components/CheckoutModal/CheckoutModal.tsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import s from './CheckoutModal.module.scss';
+
+type Props = {
+ isOpen: boolean;
+ onConfirm: () => void;
+ onCancel: () => void;
+};
+
+export const CheckoutModal: React.FC = ({
+ isOpen,
+ onConfirm,
+ onCancel,
+}) => {
+ if (!isOpen) {
+ return null;
+ }
+
+ return (
+
+
e.stopPropagation()}>
+
Checkout is not implemented yet
+
+
Do you want to clear the Cart?
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/modules/CartPage/components/CheckoutModal/index.ts b/src/modules/CartPage/components/CheckoutModal/index.ts
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/src/modules/CartPage/index.ts b/src/modules/CartPage/index.ts
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/src/modules/CatalogPage/CatalogPage.module.scss b/src/modules/CatalogPage/CatalogPage.module.scss
new file mode 100644
index 00000000000..5787e4ca8d8
--- /dev/null
+++ b/src/modules/CatalogPage/CatalogPage.module.scss
@@ -0,0 +1,70 @@
+@import './../../styles/typography';
+@import './../../styles/mixins';
+
+.catalog {
+ &__title {
+ margin-bottom: 8px;
+
+ @include h1;
+ }
+
+ &__amount {
+ color: var(--color-secondary);
+ margin: 0;
+ }
+
+ &__filters {
+ margin-bottom: 24px;
+ margin-top: 32px;
+
+ @include page-grid;
+
+ @include on-tablet {
+ margin-top: 40px;
+ }
+ }
+
+ &__filterSort {
+ grid-column: 1 / 3;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+
+ @include on-tablet {
+ grid-column: 1 / 5;
+ }
+ }
+
+ &__filterPage {
+ grid-column: 3 / -1;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+
+ @include on-tablet {
+ grid-column: 5 / 8;
+ }
+ }
+
+ &__filterName {
+ color: var(--color-secondary);
+
+ @include small-text;
+ }
+
+ &__products {
+ margin-bottom: 24px;
+
+ @include on-tablet {
+ margin-bottom: 40px;
+ }
+ }
+
+ &__pagination {
+ margin-bottom: 64px;
+
+ @include on-desktop {
+ margin-bottom: 80px;
+ }
+ }
+}
diff --git a/src/modules/CatalogPage/CatalogPage.tsx b/src/modules/CatalogPage/CatalogPage.tsx
new file mode 100644
index 00000000000..350f2e66f1e
--- /dev/null
+++ b/src/modules/CatalogPage/CatalogPage.tsx
@@ -0,0 +1,116 @@
+import { useEffect, useState } from 'react';
+import { CardList } from '../shared/CardList/CardList';
+import type { Product } from '../../types/Product';
+import { getProducts } from '../../api';
+import { useLocation, useSearchParams } from 'react-router-dom';
+import { Breadcrumbs } from '../shared/Breadcrumbs';
+import { capitalizeFirstLetter } from '../../utils/string';
+import s from './CatalogPage.module.scss';
+import { Dropdown } from '../shared/Dropdown';
+import { Pagination } from '../shared/Pagination';
+
+const sortOptions = [
+ { value: 'age', label: 'Newest' },
+ { value: 'title', label: 'Alphabetically' },
+ { value: 'price', label: 'Cheapest' },
+];
+
+const paginationOptions = [
+ { value: 'all', label: 'All' },
+ { value: '4', label: '4' },
+ { value: '8', label: '8' },
+ { value: '16', label: '16' },
+];
+
+const getPreparedProducts = (
+ products: Product[],
+ sort: string,
+ page: number,
+ perPage: number | string,
+) => {
+ let preparedProducts = [...products];
+
+ if (sort) {
+ preparedProducts = preparedProducts.sort((p1, p2) => {
+ switch (sort) {
+ case 'title':
+ return p1.name.localeCompare(p2.name);
+ case 'price':
+ return p1.price - p2.price;
+ case 'age':
+ return p2.year - p1.year;
+ default:
+ return 0;
+ }
+ });
+ }
+
+ if (perPage !== 'all') {
+ const startItem = (+page - 1) * +perPage;
+ const endItem = startItem + +perPage;
+
+ return [...preparedProducts].slice(startItem, endItem);
+ }
+
+ return preparedProducts;
+};
+
+export const CatalogPage = () => {
+ const [products, setProducts] = useState([]);
+ const { pathname } = useLocation();
+ const [searchParams] = useSearchParams();
+ const sortBy = searchParams.get('sort') || 'age';
+ const perPage = searchParams.get('perPage') || 'all';
+ const page = searchParams.get('page') || '1';
+ const type = pathname.slice(1);
+
+ useEffect(() => {
+ getProducts('products').then(setProducts);
+ }, []);
+
+ const productsByType = products.filter(product => product.category === type);
+
+ const preparedProducts = getPreparedProducts(
+ productsByType,
+ sortBy,
+ +page,
+ perPage,
+ );
+
+ return (
+
+
+
{capitalizeFirstLetter(type)}
+
{productsByType.length} models
+
+
+
+
+
+
+
+ {perPage !== 'all' && (
+
+ )}
+
+ );
+};
diff --git a/src/modules/CatalogPage/index.ts b/src/modules/CatalogPage/index.ts
new file mode 100644
index 00000000000..1cad0ffbfe4
--- /dev/null
+++ b/src/modules/CatalogPage/index.ts
@@ -0,0 +1 @@
+export * from './CatalogPage';
diff --git a/src/modules/FavoritesPage/FavoritesPage.module.scss b/src/modules/FavoritesPage/FavoritesPage.module.scss
new file mode 100644
index 00000000000..4986c2ef4b7
--- /dev/null
+++ b/src/modules/FavoritesPage/FavoritesPage.module.scss
@@ -0,0 +1,28 @@
+@import './../../styles/typography';
+
+.favourites {
+ &__title {
+ color: var(--color-primary-dark);
+ margin-bottom: 8px;
+
+ @include h1;
+ }
+
+ &__amount {
+ color: var(--color-secondary);
+
+ @include body-text;
+ }
+
+ &__list {
+ margin-block: 32px 56px;
+
+ @include on-tablet {
+ margin-block: 40px 64px;
+ }
+
+ @include on-desktop {
+ margin-block: 40px 80px;
+ }
+ }
+}
diff --git a/src/modules/FavoritesPage/FavoritesPage.tsx b/src/modules/FavoritesPage/FavoritesPage.tsx
new file mode 100644
index 00000000000..1ede3a6c4af
--- /dev/null
+++ b/src/modules/FavoritesPage/FavoritesPage.tsx
@@ -0,0 +1,26 @@
+import { useLocation } from 'react-router-dom';
+import { Breadcrumbs } from '../shared/Breadcrumbs';
+import { useContext } from 'react';
+import { FavouritesContext } from '../../FavouritesContext';
+import { CardList } from '../shared/CardList/CardList';
+import { capitalizeFirstLetter } from '../../utils/string';
+import s from './FavoritesPage.module.scss';
+
+export const FavoritesPage = () => {
+ const { pathname } = useLocation();
+ const { favourites } = useContext(FavouritesContext);
+ const type = pathname.slice(1);
+
+ return (
+
+
+
{capitalizeFirstLetter(type)}
+
+ {favourites.length === 1 ? `1 item` : `${favourites.length} items`}
+
+
+
+
+
+ );
+};
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..2816a3c2d1a
--- /dev/null
+++ b/src/modules/HomePage/HomePage.module.scss
@@ -0,0 +1,40 @@
+@import './../../styles/mixins';
+@import './../../styles/typography';
+
+.homePage {
+ &__name {
+ display: none;
+ }
+
+ &__title {
+ color: var(--color-primary-dark);
+ margin-block: 24px;
+
+ @include h1;
+
+ @include on-tablet {
+ margin-block: 32px;
+ }
+
+ @include on-desktop {
+ margin-block: 56px;
+ }
+ }
+
+ &__content {
+ display: flex;
+ flex-direction: column;
+ gap: 56px;
+ padding-block: 56px;
+
+ @include on-tablet {
+ gap: 64px;
+ padding-block: 64px;
+ }
+
+ @include on-desktop {
+ gap: 80px;
+ padding-block: 80px;
+ }
+ }
+}
diff --git a/src/modules/HomePage/HomePage.tsx b/src/modules/HomePage/HomePage.tsx
new file mode 100644
index 00000000000..85ec33e3308
--- /dev/null
+++ b/src/modules/HomePage/HomePage.tsx
@@ -0,0 +1,59 @@
+import { useEffect, useMemo, useState } from 'react';
+import { Banner } from './components/Banner';
+import { Categories } from './components/Categories';
+import type { Product } from '../../types/Product';
+import { getProducts } from '../../api';
+import { CardsSlider } from '../shared/CardsSlider';
+import { SwiperSlide } from 'swiper/react';
+import s from './HomePage.module.scss';
+import { Card } from '../shared/Card';
+
+export const HomePage = () => {
+ const [products, setProducts] = useState([]);
+
+ useEffect(() => {
+ getProducts('products').then(setProducts);
+ }, []);
+
+ const newModel = useMemo(() => {
+ return [...products].sort((p1, p2) => p2.year - p1.year);
+ }, [products]);
+
+ const hotPrices = useMemo(() => {
+ return [...products].sort((p1, p2) => {
+ const discount1 = p1.fullPrice - p1.price;
+ const discount2 = p2.fullPrice - p2.price;
+
+ return discount2 - discount1;
+ });
+ }, [products]);
+
+ return (
+ <>
+ Product Catalog
+ Welcome to Nice Gadgets store!
+
+
+
+
+
+ {newModel.map(product => (
+
+
+
+ ))}
+
+
+
+
+
+ {hotPrices.map(product => (
+
+
+
+ ))}
+
+
+ >
+ );
+};
diff --git a/src/modules/HomePage/components/Banner/Banner.module.scss b/src/modules/HomePage/components/Banner/Banner.module.scss
new file mode 100644
index 00000000000..688feba6c39
--- /dev/null
+++ b/src/modules/HomePage/components/Banner/Banner.module.scss
@@ -0,0 +1,144 @@
+@import './../../../../styles/mixins';
+
+.banner {
+ margin-left: -16px;
+ margin-right: -16px;
+ width: calc(100% + 32px);
+
+ @include on-tablet {
+ margin: 0;
+ width: 100%;
+ display: flex;
+
+ @include page-grid;
+ }
+
+ &__button {
+ display: none;
+
+ @include on-tablet {
+ margin-bottom: 20px;
+ display: block;
+ grid-column: span 1;
+ }
+ }
+
+ &__swiperWrapper {
+ width: 100%;
+
+ @include on-tablet {
+ grid-column: span 10;
+ }
+
+ @include on-desktop {
+ grid-column: span 22;
+ }
+ }
+
+ &__swiper {
+ :global {
+ .swiper-pagination {
+ position: static;
+ margin-top: 16px;
+ display: flex;
+ justify-content: center;
+ }
+
+ .swiper-pagination-bullet {
+ width: 14px;
+ height: 4px;
+ border-radius: 0;
+ background-color: var(--color-elements);
+ opacity: 1;
+ }
+
+ .swiper-pagination-bullet-active {
+ background-color: var(--color-primary-dark);
+ }
+ }
+ }
+
+ &__slide {
+ height: 100%;
+ }
+
+ &__slideCard {
+ aspect-ratio: 1 / 1;
+
+ @include on-tablet {
+ aspect-ratio: 1 / 0.4;
+ }
+ }
+
+
+}
+
+.slide {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+
+ padding: 32px;
+ border-radius: 20px;
+ overflow: hidden;
+
+ // 👉 главное требование
+ aspect-ratio: 1 / 0.4;
+
+ @media (max-width: 768px) {
+ flex-direction: column;
+ justify-content: center;
+ text-align: center;
+
+ aspect-ratio: 1 / 1;
+ }
+
+ &__content {
+ z-index: 2;
+ max-width: 400px;
+ }
+
+ &__subtitle {
+ color: #a855f7;
+ font-size: 14px;
+ margin-bottom: 8px;
+ }
+
+ &__title {
+ font-size: 36px;
+ font-weight: 700;
+ color: #fff;
+ }
+
+ &__desc {
+ color: #aaa;
+ margin: 10px 0 20px;
+ }
+
+ &__btn {
+ padding: 10px 20px;
+ border-radius: 999px;
+ border: none;
+ background: white;
+ color: black;
+ font-weight: 600;
+ cursor: pointer;
+ transition: 0.3s;
+
+ &:hover {
+ transform: scale(1.05);
+ }
+ }
+
+ &__image {
+ height: 90%;
+ object-fit: contain;
+ z-index: 1;
+
+ @media (max-width: 768px) {
+ height: 50%;
+ margin-top: 20px;
+ }
+ }
+}
diff --git a/src/modules/HomePage/components/Banner/Banner.tsx b/src/modules/HomePage/components/Banner/Banner.tsx
new file mode 100644
index 00000000000..ea155a8f06b
--- /dev/null
+++ b/src/modules/HomePage/components/Banner/Banner.tsx
@@ -0,0 +1,54 @@
+import { Swiper, SwiperSlide } from 'swiper/react';
+import s from './Banner.module.scss';
+import 'swiper/css';
+import 'swiper/css/pagination';
+
+import { Pagination, Autoplay } from 'swiper/modules';
+import { useRef } from 'react';
+import type { SwiperRef } from 'swiper/react';
+import { SliderButton } from '../../../shared/SliderButton';
+import BannerSlide, { bannerSlides } from '../BannerSlide/BannerSlide';
+
+export const Banner = () => {
+ const swiperRef = useRef(null);
+
+ return (
+
+
+ swiperRef.current?.swiper.slidePrev()}
+ />
+
+
+
+
+ {bannerSlides.map((slide, index) => (
+
+
+
+ ))}
+
+
+
+
+ swiperRef.current?.swiper.slideNext()}
+ />
+
+
+ );
+};
diff --git a/src/modules/HomePage/components/Banner/index.ts b/src/modules/HomePage/components/Banner/index.ts
new file mode 100644
index 00000000000..bc95f09d62a
--- /dev/null
+++ b/src/modules/HomePage/components/Banner/index.ts
@@ -0,0 +1 @@
+export * from './Banner';
diff --git a/src/modules/HomePage/components/BannerSlide/BannerSlide.module.scss b/src/modules/HomePage/components/BannerSlide/BannerSlide.module.scss
new file mode 100644
index 00000000000..5662c6695bb
--- /dev/null
+++ b/src/modules/HomePage/components/BannerSlide/BannerSlide.module.scss
@@ -0,0 +1,292 @@
+@import './../../../../styles/mixins';
+
+.slide {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 10px;
+ padding: 0 5% 0 6%;
+ box-sizing: border-box;
+ font-family: 'SF Pro Display', 'Helvetica Neue', Arial, sans-serif;
+
+ @include on-tablet {
+ flex-direction: row;
+ justify-content: space-between;
+ }
+
+ .glow1,
+ .glow2 {
+ position: absolute;
+ border-radius: 50%;
+ pointer-events: none;
+ filter: blur(75px);
+ }
+
+ &__leftContent {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: clamp(10px, 0.9vw, 14px);
+ padding-block: 20px;
+
+ @include on-tablet {
+ align-items: flex-start;
+ min-width: 35%;
+ }
+ }
+
+ &__rigthContent {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ justify-content: flex-end;
+ height: 100%;
+ width: 100%;
+ }
+}
+
+.badge {
+ font-size: clamp(9px, 0.85vw, 12px);
+ font-weight: 700;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ padding: 4px 12px;
+ border-radius: 100px;
+}
+
+.headline {
+ font-size: clamp(16px, 3vw, 42px);
+ font-weight: 800;
+ line-height: 1.1;
+ margin: 0;
+ color: #fff;
+ letter-spacing: -0.02em;
+ display: flex;
+ gap: 10px;
+ flex-direction: row;
+
+ @include on-tablet {
+ flex-direction: column;
+ }
+}
+
+.sub {
+ margin: 0;
+ font-size: clamp(9px, 0.95vw, 14px);
+ color: rgba(255, 255, 255, 0.5);
+}
+
+.btn {
+ display: inline-block;
+ margin-top: clamp(4px, 0.7vw, 10px);
+ padding: clamp(7px, 0.85vw, 13px) clamp(16px, 2vw, 30px);
+ font-size: clamp(9px, 0.8vw, 12px);
+ font-weight: 700;
+ letter-spacing: 0.1em;
+ text-decoration: none;
+ border-radius: 100px;
+ background: transparent;
+ color: #fff;
+ border: 1.5px solid rgba(255, 255, 255, 0.45);
+ transition:
+ background 0.22s,
+ border-color 0.22s,
+ transform 0.15s;
+ cursor: pointer;
+}
+
+.btn:hover {
+ background: rgba(255, 255, 255, 0.1);
+ border-color: #fff;
+ transform: scale(1.03);
+}
+
+.imgWrap {
+ display: flex;
+ justify-content: center;
+ align-items: flex-end;
+ min-height: 0;
+ height: 100%;
+ width: 100%;
+
+ @include on-tablet {
+ height: 70%;
+ }
+}
+
+.productImg {
+ max-width: 100%;
+ flex-shrink: 1;
+ object-fit: contain;
+ display: block;
+ filter: drop-shadow(0 20px 40px rgba(0, 0, 0, 0.55));
+
+ max-height: 150px;
+
+ @include on-tablet {
+ max-height: 100%;
+ }
+}
+
+.productLabel {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
+}
+
+.productTitle {
+ font-size: clamp(24px, 2.7vw, 36px);
+ font-weight: 700;
+ color: #fff;
+ letter-spacing: -0.01em;
+}
+
+.productSub {
+ font-size: clamp(7px, 0.75vw, 16px);
+ color: rgba(255, 255, 255, 0.4);
+ letter-spacing: 0.05em;
+}
+
+.orange .btn {
+ border-color: rgba(251, 146, 60, 0.5);
+ color: #fb923c;
+}
+
+.cyan .btn {
+ border-color: rgba(34, 211, 238, 0.5);
+ color: #22d3ee;
+}
+
+
+.purple {
+ background: radial-gradient(
+ ellipse at 62% 50%,
+ #1a0533 0%,
+ #0b0012 60%,
+ #000 100%
+ );
+}
+
+.purple .glow1 {
+ width: 52%;
+ height: 140%;
+ top: -20%;
+ right: 16%;
+ background: #6d28d9;
+ opacity: 0.38;
+}
+
+.purple .glow2 {
+ width: 28%;
+ height: 80%;
+ bottom: -20%;
+ right: 4%;
+ background: #4f46e5;
+ opacity: 0.3;
+}
+
+.purple .accent {
+ color: #a855f7;
+}
+
+.purple .badge {
+ background: rgba(138, 43, 226, 0.22);
+ color: #c084fc;
+ border: 1px solid rgba(192, 132, 252, 0.35);
+}
+
+.orange {
+ background: radial-gradient(
+ ellipse at 62% 50%,
+ #1c0a00 0%,
+ #120600 55%,
+ #000 100%
+ );
+}
+
+.orange .glow1 {
+ width: 48%;
+ height: 130%;
+ top: -15%;
+ right: 18%;
+ background: #ea580c;
+ opacity: 0.38;
+}
+
+.orange .glow2 {
+ width: 22%;
+ height: 70%;
+ bottom: -10%;
+ right: 6%;
+ background: #dc2626;
+ opacity: 0.28;
+}
+
+.orange .accent {
+ color: #f97316;
+}
+
+.orange .badge {
+ background: rgba(249, 115, 22, 0.2);
+ color: #fb923c;
+ border: 1px solid rgba(251, 146, 60, 0.35);
+}
+
+
+
+.orange .btn:hover {
+ background: rgba(251, 146, 60, 0.1);
+ border-color: #fb923c;
+ color: #fff;
+}
+
+.cyan {
+ background: radial-gradient(
+ ellipse at 58% 50%,
+ #001e2e 0%,
+ #00101a 60%,
+ #000 100%
+ );
+}
+
+.cyan .glow1 {
+ width: 48%;
+ height: 130%;
+ top: -15%;
+ right: 20%;
+ background: #0891b2;
+ opacity: 0.38;
+}
+
+.cyan .glow2 {
+ width: 18%;
+ height: 60%;
+ top: 10%;
+ right: 26%;
+ background: #e0f2fe;
+ opacity: 0.07;
+}
+
+.cyan .accent {
+ color: #22d3ee;
+}
+
+.cyan .badge {
+ background: rgba(6, 182, 212, 0.2);
+ color: #22d3ee;
+ border: 1px solid rgba(34, 211, 238, 0.35);
+}
+
+
+
+.cyan .btn:hover {
+ background: rgba(34, 211, 238, 0.1);
+ border-color: #22d3ee;
+ color: #fff;
+}
diff --git a/src/modules/HomePage/components/BannerSlide/BannerSlide.tsx b/src/modules/HomePage/components/BannerSlide/BannerSlide.tsx
new file mode 100644
index 00000000000..39c08723e39
--- /dev/null
+++ b/src/modules/HomePage/components/BannerSlide/BannerSlide.tsx
@@ -0,0 +1,110 @@
+import React from 'react';
+import s from './BannerSlide.module.scss';
+
+export interface BannerSlideData {
+ badge: string;
+ headlineAccent: string;
+ headlineRest: string;
+ sub: string;
+ btnText: string;
+ btnHref?: string;
+ productTitle: string;
+ productSub: string;
+ imgSrc: string;
+ imgAlt: string;
+ theme: 'purple' | 'cyan' | 'orange';
+}
+
+export const bannerSlides: BannerSlideData[] = [
+ {
+ badge: 'New arrival',
+ headlineAccent: 'Now available!',
+ headlineRest: 'In our store!',
+ sub: 'Be the first to experience it',
+ btnText: 'Order now',
+ btnHref: '#',
+ productTitle: 'iPhone 17 Pro',
+ productSub: 'Pro. Beyond.',
+ imgSrc: './img/iphone17.png',
+ imgAlt: 'iPhone 17 Pro',
+ theme: 'orange',
+ },
+ {
+ badge: 'Best seller',
+ headlineAccent: 'Pure sound.',
+ headlineRest: 'Zero limits.',
+ sub: 'Adaptive Noise Cancellation',
+ btnText: 'Shop now',
+ btnHref: '#',
+ productTitle: 'AirPods Pro',
+ productSub: 'Hear everything. Filter the rest.',
+ imgSrc: './img/airpods.png',
+ imgAlt: 'AirPods Pro',
+ theme: 'cyan',
+ },
+ {
+ badge: 'Limited offer',
+ headlineAccent: 'Your health.',
+ headlineRest: 'On your wrist.',
+ sub: 'Advanced health sensors, all day',
+ btnText: 'Explore',
+ btnHref: '#',
+ productTitle: 'Apple Watch Series 9',
+ productSub: 'Smarter. Faster. Healthier.',
+ imgSrc: './img/apple-watch.png',
+ imgAlt: 'Apple Watch Series 9',
+ theme: 'purple',
+ },
+];
+
+interface BannerSlideProps {
+ data: BannerSlideData;
+}
+
+const BannerSlide: React.FC = ({ data }) => {
+ const {
+ badge,
+ headlineAccent,
+ headlineRest,
+ sub,
+ btnText,
+ btnHref,
+ productTitle,
+ productSub,
+ imgSrc,
+ imgAlt,
+ theme,
+ } = data;
+
+ return (
+
+
+
+
+
+
+
+
+ {productTitle}
+ {productSub}
+
+
+
+

+
+
+
+ );
+};
+
+export default BannerSlide;
diff --git a/src/modules/HomePage/components/BannerSlide/index.tsx b/src/modules/HomePage/components/BannerSlide/index.tsx
new file mode 100644
index 00000000000..f1d72da4313
--- /dev/null
+++ b/src/modules/HomePage/components/BannerSlide/index.tsx
@@ -0,0 +1 @@
+export * from './BannerSlide.module.scss';
diff --git a/src/modules/HomePage/components/Categories/Categories.module.scss b/src/modules/HomePage/components/Categories/Categories.module.scss
new file mode 100644
index 00000000000..34e5f64c828
--- /dev/null
+++ b/src/modules/HomePage/components/Categories/Categories.module.scss
@@ -0,0 +1,60 @@
+@import './../../../../styles/mixins';
+@import './../../../../styles/typography';
+
+.categories {
+ &__title {
+ color: var(--color-primary-dark);
+ margin-block: 0 24px;
+
+ @include h2;
+ }
+
+ &__content {
+ @include page-grid;
+
+ row-gap: 32px;
+ }
+
+ &__item {
+ grid-column: 1 / -1;
+
+ @include on-tablet {
+ grid-column: span 4;
+ }
+
+ @include on-desktop {
+ grid-column: span 8;
+ }
+
+ @include hover(transform, scale(1.05));
+ }
+
+ &__link {
+ position: relative;
+ width: 100%;
+ aspect-ratio: 1 / 1;
+ border-radius: 5px;
+ overflow: hidden;
+ }
+
+ &__img {
+ position: absolute;
+ height: 90%;
+ bottom: 0;
+ right: 0;
+ }
+
+ &__name {
+ text-decoration: none;
+ margin-block: 16px 4px;
+ color: var(--color-primary-dark);
+
+ @include h4;
+ }
+
+ &__amount {
+ color: var(--color-secondary);
+
+ @include body-text;
+ }
+}
diff --git a/src/modules/HomePage/components/Categories/Categories.tsx b/src/modules/HomePage/components/Categories/Categories.tsx
new file mode 100644
index 00000000000..99ba35d3a03
--- /dev/null
+++ b/src/modules/HomePage/components/Categories/Categories.tsx
@@ -0,0 +1,66 @@
+import { Link } from 'react-router-dom';
+import s from './Categories.module.scss';
+
+export const Categories = () => {
+ return (
+
+
Shop by category
+
+
+
+
+

+
+
+ Mobile phones
+
+
95 models
+
+
+
+
+

+
+
+ Tablets
+
+
24 models
+
+
+
+
+

+
+
+ Accessories
+
+
100 models
+
+
+
+ );
+};
diff --git a/src/modules/HomePage/components/Categories/index.ts b/src/modules/HomePage/components/Categories/index.ts
new file mode 100644
index 00000000000..79c7c7dcde7
--- /dev/null
+++ b/src/modules/HomePage/components/Categories/index.ts
@@ -0,0 +1 @@
+export * from './Categories';
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..e8bf29ba62e
--- /dev/null
+++ b/src/modules/NotFoundPage/NotFoundPage.module.scss
@@ -0,0 +1,8 @@
+.notFoundPage {
+
+ &__img {
+ margin-inline: auto;
+ display: flex;
+ width: 50%;
+ }
+}
diff --git a/src/modules/NotFoundPage/NotFoundPage.tsx b/src/modules/NotFoundPage/NotFoundPage.tsx
new file mode 100644
index 00000000000..c77eb6dafa7
--- /dev/null
+++ b/src/modules/NotFoundPage/NotFoundPage.tsx
@@ -0,0 +1,13 @@
+import s from './NotFoundPage.module.scss';
+
+export const NotFoundPage = () => {
+ return (
+
+

+
+ );
+};
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..1f58f9707e6
--- /dev/null
+++ b/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss
@@ -0,0 +1,15 @@
+@import './../../styles/mixins';
+
+.productDetailsPage {
+ &__slider {
+ margin-block: 56px;
+
+ @include on-tablet {
+ margin-block: 64px;
+ }
+
+ @include on-desktop {
+ margin-block: 80px;
+ }
+ }
+}
diff --git a/src/modules/ProductDetailsPage/ProductDetailsPage.tsx b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx
new file mode 100644
index 00000000000..6c178ccf331
--- /dev/null
+++ b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx
@@ -0,0 +1,88 @@
+import { useParams } from 'react-router-dom';
+import { Breadcrumbs } from '../shared/Breadcrumbs';
+import { useEffect, useMemo, useState } from 'react';
+import { getProducts } from '../../api';
+import type { ProductFull } from '../../types/ProductFull';
+import { Back } from '../shared/Back';
+import { ProductDetails } from './components/ProductDetails';
+import type { Product } from '../../types/Product';
+import { CardsSlider } from '../shared/CardsSlider';
+import s from './ProductDetailsPage.module.scss';
+
+type Params = {
+ productId?: string;
+};
+
+export const ProductDetailsPage = () => {
+ const [detailedProducts, setDetailedProducts] = useState([]);
+ const [catalogProducts, setCatalogProducts] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const { productId } = useParams();
+
+ // if (!productId) {
+ // return;
+ // }
+
+ useEffect(() => {
+ setLoading(true);
+
+ Promise.all([
+ getProducts('phones'),
+ getProducts('tablets'),
+ getProducts('accessories'),
+ ])
+ .then(([x, y, z]) => setDetailedProducts([...x, ...y, ...z]))
+ .catch(e => new Error(e))
+ .finally(() => setLoading(false));
+
+ getProducts('products').then(setCatalogProducts);
+ }, []);
+
+ const searchProduct = (
+ namespaceId: string,
+ color: string,
+ capacity: string,
+ ): string | undefined => {
+ return detailedProducts.find(
+ item =>
+ item.namespaceId === namespaceId &&
+ item.color === color &&
+ item.capacity === capacity,
+ )?.id;
+ };
+
+ const detailedProduct = useMemo(() => {
+ return detailedProducts.find(product => product.id === productId);
+ }, [detailedProducts, productId]);
+
+ const catalogProduct = useMemo(() => {
+ return catalogProducts.find(product => product.itemId === productId);
+ }, [catalogProducts, productId]);
+
+ return (
+ <>
+ {loading && Loading...
}
+
+ {!loading && detailedProduct && (
+
+
+
+
+
+ Math.random() - 0.5)}
+ name="You may also like"
+ />
+
+
+ )}
+ >
+ );
+};
diff --git a/src/modules/ProductDetailsPage/components/About/About.module.scss b/src/modules/ProductDetailsPage/components/About/About.module.scss
new file mode 100644
index 00000000000..0de88bcd40b
--- /dev/null
+++ b/src/modules/ProductDetailsPage/components/About/About.module.scss
@@ -0,0 +1,44 @@
+@import './../../../../styles/typography';
+
+.about {
+ &__title {
+ margin-block: 0 32px;
+ padding-bottom: 16px;
+ box-sizing: border-box;
+ border-bottom: 1px solid var(--color-elements);
+
+ @include h3;
+ }
+
+ &__list {
+ display: flex;
+ flex-direction: column;
+ gap: 32px;
+ }
+
+ &__item {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ }
+
+ &__itemTitle {
+ margin: 0;
+ color: var(--color-primary-dark);
+
+ @include h4;
+ }
+
+ &__texts {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ color: var(--color-secondary);
+
+ @include body-text;
+ }
+
+ &__text {
+ margin: 0;
+ }
+}
diff --git a/src/modules/ProductDetailsPage/components/About/About.tsx b/src/modules/ProductDetailsPage/components/About/About.tsx
new file mode 100644
index 00000000000..40ee0d6c73f
--- /dev/null
+++ b/src/modules/ProductDetailsPage/components/About/About.tsx
@@ -0,0 +1,29 @@
+import type { ProductFullDescription } from 'types/ProductFullDescription';
+import s from './About.module.scss';
+
+type Props = {
+ description: ProductFullDescription[];
+};
+
+export const About = ({ description }: Props) => {
+ return (
+
+
About
+
+
+ {description.map((item, index) => (
+
+
{item.title}
+
+ {item.text.map(t => (
+
+ {t}
+
+ ))}
+
+
+ ))}
+
+
+ );
+};
diff --git a/src/modules/ProductDetailsPage/components/About/index.ts b/src/modules/ProductDetailsPage/components/About/index.ts
new file mode 100644
index 00000000000..da0f79ebaaa
--- /dev/null
+++ b/src/modules/ProductDetailsPage/components/About/index.ts
@@ -0,0 +1 @@
+export * from './About';
diff --git a/src/modules/ProductDetailsPage/components/MainControls/MainControls.module.scss b/src/modules/ProductDetailsPage/components/MainControls/MainControls.module.scss
new file mode 100644
index 00000000000..89038a92765
--- /dev/null
+++ b/src/modules/ProductDetailsPage/components/MainControls/MainControls.module.scss
@@ -0,0 +1,96 @@
+@import './../../../../styles/typography';
+
+.controls {
+ &__title {
+ color: var(--color-secondary);
+ margin-block: 0 8px;
+
+ @include small-text;
+ }
+
+ &__colorsBlock {
+ margin-bottom: 24px;
+ }
+
+ &__colors {
+ display: flex;
+ gap: 8px;
+ }
+
+ &__colorButton {
+ height: 32px;
+ width: 32px;
+ border-radius: 50%;
+ box-sizing: border-box;
+ border: 1px solid var(--color-elements);
+ cursor: pointer;
+
+ &--active {
+ border: 1px solid var(--color-primary-dark);
+ }
+ }
+
+ &__color {
+ height: 100%;
+ width: 100%;
+ border-radius: 50%;
+ box-sizing: border-box;
+ border: 2px solid white;
+ }
+
+ &__capacitiesBlock {
+ margin-block: 24px;
+ }
+
+ &__capacities {
+ display: flex;
+ gap: 8px;
+ }
+
+ &__capacity {
+ display: flex;
+ align-items: center;
+ height: 32px;
+ padding-inline: 8px;
+ box-sizing: border-box;
+ border: 1px solid var(--color-elements);
+ cursor: pointer;
+
+ &--active {
+ background-color: var(--color-primary-dark);
+ color: white;
+ border: 1px solid var(--color-primary-dark);
+ }
+ }
+
+ &__prises {
+ display: flex;
+ gap: 8px;
+ margin-block: 32px 16px;
+ }
+
+ &__priceDiscount {
+ color: var(--color-primary-dark);
+ font-weight: 800;
+ font-size: 32px;
+ line-height: 41px;
+ letter-spacing: -1%;
+ }
+
+ &__priceRegular {
+ color: var(--color-secondary);
+ display: flex;
+ align-items: center;
+ font-weight: 500;
+ font-size: 22px;
+ line-height: 100%;
+ letter-spacing: 0%;
+ text-decoration: line-through;
+ }
+
+ &__buttons {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 32px;
+ }
+}
diff --git a/src/modules/ProductDetailsPage/components/MainControls/MainControls.tsx b/src/modules/ProductDetailsPage/components/MainControls/MainControls.tsx
new file mode 100644
index 00000000000..9b96d3e642f
--- /dev/null
+++ b/src/modules/ProductDetailsPage/components/MainControls/MainControls.tsx
@@ -0,0 +1,161 @@
+import classNames from 'classnames';
+import s from './MainControls.module.scss';
+import { PrimaryButton } from '../../../shared/PrimaryButton';
+import { AddToFovouritesButton } from '../../../shared/AddToFovouritesButton';
+import { TechSpecsList } from '../TechSpecsList';
+import type { ProductFull } from '../../../../types/ProductFull';
+import { colors } from '../../../../utils/colors';
+import { Line } from '../../../shared/Line';
+import { useNavigate } from 'react-router-dom';
+import { useContext } from 'react';
+import { CartContext } from '../../../../CartContext';
+import { FavouritesContext } from '../../../../FavouritesContext';
+import type { Product } from '../../../../types/Product';
+
+type Props = {
+ product: ProductFull;
+ searchProduct: (
+ namespaceId: string,
+ color: string,
+ capacity: string,
+ ) => string | undefined;
+ catalogProduct: Product | undefined;
+};
+
+export const MainControls = ({
+ product,
+ searchProduct,
+ catalogProduct,
+}: Props) => {
+ const navigate = useNavigate();
+ const { cart, setCart } = useContext(CartContext);
+ const { favourites, setFavourites } = useContext(FavouritesContext);
+ const {
+ namespaceId,
+ colorsAvailable,
+ color,
+ capacityAvailable,
+ capacity,
+ priceDiscount,
+ priceRegular,
+ screen,
+ resolution,
+ processor,
+ ram,
+ } = product;
+
+ if (catalogProduct === undefined) {
+ return;
+ }
+
+ const isInCart = (id: string) => {
+ return !!cart.find(item => item.id === id);
+ };
+
+ const isFavourites = (id: string) => {
+ return !!favourites.find(item => item.itemId === id);
+ };
+
+ return (
+
+
+
Available colors
+
+ {colorsAvailable.map(col => (
+
{
+ const productId = searchProduct(namespaceId, col, capacity);
+
+ if (productId) {
+ navigate(`/product/${productId}`, { replace: true });
+ }
+ }}
+ >
+
+
+ ))}
+
+
+
+
+
+
+
Select capacity
+
+ {capacityAvailable.map(cap => (
+
{
+ const productId = searchProduct(namespaceId, color, cap);
+
+ if (productId) {
+ navigate(`/product/${productId}`, { replace: true });
+ }
+ }}
+ >
+ {cap}
+
+ ))}
+
+
+
+
+
+
+
${priceDiscount}
+
${priceRegular}
+
+
+ {isInCart(catalogProduct.itemId) ? (
+
Added to cart
+ ) : (
+
+ setCart(prev => [
+ ...prev,
+ {
+ id: catalogProduct.itemId,
+ quantity: 1,
+ product: catalogProduct,
+ },
+ ])
+ }
+ >
+ Add to cart
+
+ )}
+
+ setFavourites(prev =>
+ isFavourites(catalogProduct.itemId)
+ ? prev.filter(item => item.itemId !== catalogProduct.itemId)
+ : [...prev, catalogProduct],
+ )
+ }
+ />
+
+
+
+
+
+ );
+};
diff --git a/src/modules/ProductDetailsPage/components/MainControls/index.ts b/src/modules/ProductDetailsPage/components/MainControls/index.ts
new file mode 100644
index 00000000000..0444e1d1933
--- /dev/null
+++ b/src/modules/ProductDetailsPage/components/MainControls/index.ts
@@ -0,0 +1 @@
+export * from './MainControls';
diff --git a/src/modules/ProductDetailsPage/components/Photo/Photo.module.scss b/src/modules/ProductDetailsPage/components/Photo/Photo.module.scss
new file mode 100644
index 00000000000..3b9fc2e2a1a
--- /dev/null
+++ b/src/modules/ProductDetailsPage/components/Photo/Photo.module.scss
@@ -0,0 +1,15 @@
+.photo {
+ width: 100%;
+ aspect-ratio: 1 / 1;
+
+
+ &__img {
+ max-width: 100%;
+ max-height: 100%;
+ aspect-ratio: 1 / 1;
+
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ }
+}
diff --git a/src/modules/ProductDetailsPage/components/Photo/Photo.tsx b/src/modules/ProductDetailsPage/components/Photo/Photo.tsx
new file mode 100644
index 00000000000..798180a9fff
--- /dev/null
+++ b/src/modules/ProductDetailsPage/components/Photo/Photo.tsx
@@ -0,0 +1,51 @@
+import React, { useEffect } from 'react';
+import { Swiper, SwiperSlide } from 'swiper/react';
+import type { Swiper as SwiperType } from 'swiper';
+import s from './Photo.module.scss';
+
+type Props = {
+ images: string[];
+ swiperRef: React.MutableRefObject;
+ activeImg: string;
+ onActiveImg: (img: string, index: number) => void;
+};
+
+export const Photo = ({ images, swiperRef, activeImg, onActiveImg }: Props) => {
+ useEffect(() => {
+ const activeIndex = images.indexOf(activeImg);
+
+ if (
+ activeIndex !== -1 &&
+ swiperRef.current &&
+ swiperRef.current.realIndex !== activeIndex
+ ) {
+ swiperRef.current.slideToLoop(activeIndex);
+ }
+ }, [activeImg, images, swiperRef]); // ✅ добавлен swiperRef
+
+ return (
+
+
{
+ const ref = swiperRef;
+
+ ref.current = swiper;
+ }}
+ onSlideChange={swiper => {
+ if (images[swiper.realIndex] !== activeImg) {
+ onActiveImg(images[swiper.realIndex], swiper.realIndex);
+ }
+ }}
+ >
+ {images.map(img => (
+
+
+
+ ))}
+
+
+ );
+};
diff --git a/src/modules/ProductDetailsPage/components/Photo/index.ts b/src/modules/ProductDetailsPage/components/Photo/index.ts
new file mode 100644
index 00000000000..fd6cc4f3767
--- /dev/null
+++ b/src/modules/ProductDetailsPage/components/Photo/index.ts
@@ -0,0 +1 @@
+export * from './Photo';
diff --git a/src/modules/ProductDetailsPage/components/PhotoPreviews/PhotoPreviews.module.scss b/src/modules/ProductDetailsPage/components/PhotoPreviews/PhotoPreviews.module.scss
new file mode 100644
index 00000000000..997273496cd
--- /dev/null
+++ b/src/modules/ProductDetailsPage/components/PhotoPreviews/PhotoPreviews.module.scss
@@ -0,0 +1,29 @@
+@import './../../../../styles/mixins';
+
+.images {
+ display: flex;
+ gap: 8px;
+ width: 100%;
+
+ @include on-tablet {
+ flex-direction: column;
+ }
+
+ &__item {
+ max-width: calc((100% - 32px) / 5);
+ max-height: 100%;
+ box-sizing: border-box;
+ padding: 4px;
+ border: 1px solid var(--color-elements);
+ aspect-ratio: 1 / 1;
+ object-fit: contain;
+
+ @include on-tablet {
+ max-width: 100%;
+ }
+
+ &--active {
+ border: 1px solid var(--color-primary-dark);
+ }
+ }
+}
diff --git a/src/modules/ProductDetailsPage/components/PhotoPreviews/PhotoPreviews.tsx b/src/modules/ProductDetailsPage/components/PhotoPreviews/PhotoPreviews.tsx
new file mode 100644
index 00000000000..f922d8e80f6
--- /dev/null
+++ b/src/modules/ProductDetailsPage/components/PhotoPreviews/PhotoPreviews.tsx
@@ -0,0 +1,29 @@
+import classNames from 'classnames';
+import s from './PhotoPreviews.module.scss';
+
+type Props = {
+ images: string[];
+ activeImg: string;
+ onActiveImg: (img: string, index: number) => void;
+};
+
+export const PhotoPreviews = ({ images, activeImg, onActiveImg }: Props) => {
+ return (
+
+ {images.map((img, index) => (
+

onActiveImg(img, index)}
+ />
+ ))}
+
+ );
+};
diff --git a/src/modules/ProductDetailsPage/components/PhotoPreviews/index.ts b/src/modules/ProductDetailsPage/components/PhotoPreviews/index.ts
new file mode 100644
index 00000000000..fe2420fc619
--- /dev/null
+++ b/src/modules/ProductDetailsPage/components/PhotoPreviews/index.ts
@@ -0,0 +1 @@
+export * from './PhotoPreviews';
diff --git a/src/modules/ProductDetailsPage/components/ProductDetails/ProductDetails.module.scss b/src/modules/ProductDetailsPage/components/ProductDetails/ProductDetails.module.scss
new file mode 100644
index 00000000000..eee3e3c1d75
--- /dev/null
+++ b/src/modules/ProductDetailsPage/components/ProductDetails/ProductDetails.module.scss
@@ -0,0 +1,86 @@
+@import './../../../../styles/mixins';
+@import './../../../../styles/typography';
+
+.product {
+ @include page-grid;
+
+ &__title {
+ grid-column: 1 / -1;
+ margin-block: 16px 32px;
+
+ @include h2;
+
+ @include on-tablet {
+ margin-block: 16px 40px;
+ }
+ }
+
+ &__photo {
+ grid-column: 1 / -1;
+ display: flex;
+ align-items: center;
+
+ @include on-tablet {
+ grid-column: 2 / 8;
+ grid-row: 2 / 3;
+ }
+
+ @include on-desktop {
+ grid-column: 3 / 13;
+ }
+ }
+
+ &__photoPreviews {
+ grid-column: 1 / -1;
+ margin-top: 16px;
+
+ @include on-tablet {
+ grid-column: 1 / 2;
+ margin-top: 0;
+ }
+
+ @include on-desktop {
+ grid-column: 1 / 3;
+ }
+ }
+
+ &__mainControls {
+ grid-column: 1 / -1;
+
+ @include on-tablet {
+ grid-column: 8 / -1;
+ }
+
+ @include on-desktop {
+ grid-column: 14 / 21;
+ }
+ }
+
+ &__about {
+ grid-column: 1 / -1;
+ margin-top: 56px;
+
+ @include on-tablet {
+ margin-top: 64px;
+ }
+
+ @include on-desktop {
+ grid-column: 1 / 13;
+ margin-top: 80px;
+ }
+ }
+
+ &__techSpecs {
+ grid-column: 1 / -1;
+ margin-top: 56px;
+
+ @include on-tablet {
+ margin-top: 64px;
+ }
+
+ @include on-desktop {
+ grid-column: 14 / -1;
+ margin-top: 80px;
+ }
+ }
+}
diff --git a/src/modules/ProductDetailsPage/components/ProductDetails/ProductDetails.tsx b/src/modules/ProductDetailsPage/components/ProductDetails/ProductDetails.tsx
new file mode 100644
index 00000000000..9451b40f71d
--- /dev/null
+++ b/src/modules/ProductDetailsPage/components/ProductDetails/ProductDetails.tsx
@@ -0,0 +1,69 @@
+import { useRef, useState } from 'react';
+import type { ProductFull } from '../../../../types/ProductFull';
+import { About } from '../About';
+import { MainControls } from '../MainControls';
+import { Photo } from '../Photo';
+import { PhotoPreviews } from '../PhotoPreviews';
+import { TechSpecs } from '../TechSpecs';
+import s from './ProductDetails.module.scss';
+import { Swiper as SwiperType } from 'swiper';
+import type { Product } from '../../../../types/Product';
+
+type Props = {
+ product: ProductFull;
+ searchProduct: (
+ namespaceId: string,
+ color: string,
+ capacity: string,
+ ) => string | undefined;
+ catalogProduct: Product | undefined;
+};
+
+export const ProductDetails = ({
+ product,
+ searchProduct,
+ catalogProduct,
+}: Props) => {
+ const { name, images, description } = product;
+ const swiperRef = useRef(null);
+ const [activeImg, setActiveImg] = useState(images[0]);
+
+ const handleThumbnailClick = (img: string, index: number) => {
+ setActiveImg(img);
+ swiperRef.current?.slideTo(index);
+ };
+
+ return (
+
+
{name}
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/modules/ProductDetailsPage/components/ProductDetails/index.ts b/src/modules/ProductDetailsPage/components/ProductDetails/index.ts
new file mode 100644
index 00000000000..8812622b6c9
--- /dev/null
+++ b/src/modules/ProductDetailsPage/components/ProductDetails/index.ts
@@ -0,0 +1 @@
+export * from './ProductDetails';
diff --git a/src/modules/ProductDetailsPage/components/TechSpecs/TechSpecs.module.scss b/src/modules/ProductDetailsPage/components/TechSpecs/TechSpecs.module.scss
new file mode 100644
index 00000000000..2d1e6275d9f
--- /dev/null
+++ b/src/modules/ProductDetailsPage/components/TechSpecs/TechSpecs.module.scss
@@ -0,0 +1,18 @@
+@import './../../../../styles/mixins';
+@import './../../../../styles/typography';
+
+.specs {
+ &__title {
+ margin-block: 0 30px;
+ padding-bottom: 16px;
+ box-sizing: border-box;
+ border-bottom: 1px solid var(--color-elements);
+
+ @include h3;
+
+ @include on-tablet {
+ margin-block: 0 25px;
+ padding-bottom: 16px;
+ }
+ }
+}
diff --git a/src/modules/ProductDetailsPage/components/TechSpecs/TechSpecs.tsx b/src/modules/ProductDetailsPage/components/TechSpecs/TechSpecs.tsx
new file mode 100644
index 00000000000..bcdbe7defed
--- /dev/null
+++ b/src/modules/ProductDetailsPage/components/TechSpecs/TechSpecs.tsx
@@ -0,0 +1,30 @@
+import type { ProductFull } from '../../../../types/ProductFull';
+import { TechSpecsList } from '../TechSpecsList';
+import s from './TechSpecs.module.scss';
+
+type Props = {
+ product: ProductFull;
+};
+
+export const TechSpecs = ({ product }: Props) => {
+ const { screen, resolution, processor, ram, capacity, camera, zoom, cell } =
+ product;
+
+ return (
+
+
Tech specs
+
+
+ );
+};
diff --git a/src/modules/ProductDetailsPage/components/TechSpecs/index.ts b/src/modules/ProductDetailsPage/components/TechSpecs/index.ts
new file mode 100644
index 00000000000..eada3132a08
--- /dev/null
+++ b/src/modules/ProductDetailsPage/components/TechSpecs/index.ts
@@ -0,0 +1 @@
+export * from './TechSpecs';
diff --git a/src/modules/ProductDetailsPage/components/TechSpecsList/TechSpecsList.module.scss b/src/modules/ProductDetailsPage/components/TechSpecsList/TechSpecsList.module.scss
new file mode 100644
index 00000000000..49e7a23df07
--- /dev/null
+++ b/src/modules/ProductDetailsPage/components/TechSpecsList/TechSpecsList.module.scss
@@ -0,0 +1,24 @@
+@import './../../../../styles/typography';
+
+.specsList {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+
+ &__item {
+ display: flex;
+ justify-content: space-between;
+ }
+
+ &__label {
+ color: var(--color-secondary);
+
+ @include body-text;
+ }
+
+ &__value {
+ color: var(--color-primary-dark);
+
+ @include body-text;
+ }
+}
diff --git a/src/modules/ProductDetailsPage/components/TechSpecsList/TechSpecsList.tsx b/src/modules/ProductDetailsPage/components/TechSpecsList/TechSpecsList.tsx
new file mode 100644
index 00000000000..a60934ac470
--- /dev/null
+++ b/src/modules/ProductDetailsPage/components/TechSpecsList/TechSpecsList.tsx
@@ -0,0 +1,34 @@
+import type { ProductFull } from '../../../../types/ProductFull';
+import { capitalizeFirstLetter } from '../../../../utils/string';
+import s from './TechSpecsList.module.scss';
+
+type Props = {
+ specs: Partial;
+};
+
+export const TechSpecsList = ({ specs }: Props) => {
+ return (
+
+ {Object.keys(specs).map(key => {
+ const typedKey = key as keyof ProductFull;
+
+ if (!specs[typedKey]) {
+ return;
+ }
+
+ return (
+
+
+ {capitalizeFirstLetter(typedKey)}
+
+
+ {Array.isArray(specs[typedKey])
+ ? specs[typedKey]?.join(', ')
+ : specs[typedKey]}
+
+
+ );
+ })}
+
+ );
+};
diff --git a/src/modules/ProductDetailsPage/components/TechSpecsList/index.ts b/src/modules/ProductDetailsPage/components/TechSpecsList/index.ts
new file mode 100644
index 00000000000..9f088dd330d
--- /dev/null
+++ b/src/modules/ProductDetailsPage/components/TechSpecsList/index.ts
@@ -0,0 +1 @@
+export * from './TechSpecsList';
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/shared/AddToFovouritesButton/AddToFovouritesButton.module.scss b/src/modules/shared/AddToFovouritesButton/AddToFovouritesButton.module.scss
new file mode 100644
index 00000000000..0165e2b8201
--- /dev/null
+++ b/src/modules/shared/AddToFovouritesButton/AddToFovouritesButton.module.scss
@@ -0,0 +1,20 @@
+.button {
+ all: unset;
+ display: inline-block;
+ height: 40px;
+ width: 40px;
+ background-image: url('/img/icons/favourites.svg');
+ background-repeat: no-repeat;
+ background-position: center;
+ border: 1px solid var(--color-icons);
+ box-sizing: border-box;
+ cursor: pointer;
+
+ &:hover {
+ border: 1px solid var(--color-primary-dark);
+ }
+
+ &--selected {
+ background-image: url('/img/icons/favourites-filled.svg');
+ }
+}
diff --git a/src/modules/shared/AddToFovouritesButton/AddToFovouritesButton.tsx b/src/modules/shared/AddToFovouritesButton/AddToFovouritesButton.tsx
new file mode 100644
index 00000000000..3eb2adebedd
--- /dev/null
+++ b/src/modules/shared/AddToFovouritesButton/AddToFovouritesButton.tsx
@@ -0,0 +1,21 @@
+import classNames from 'classnames';
+import s from './AddToFovouritesButton.module.scss';
+
+type Props = {
+ selected?: boolean;
+ onClick?: () => void;
+};
+
+export const AddToFovouritesButton = ({
+ selected = false,
+ onClick = () => {},
+}: Props) => {
+ return (
+
+ );
+};
diff --git a/src/modules/shared/AddToFovouritesButton/index.ts b/src/modules/shared/AddToFovouritesButton/index.ts
new file mode 100644
index 00000000000..b38eecd3c57
--- /dev/null
+++ b/src/modules/shared/AddToFovouritesButton/index.ts
@@ -0,0 +1 @@
+export * from './AddToFovouritesButton';
diff --git a/src/modules/shared/Back/Back.module.scss b/src/modules/shared/Back/Back.module.scss
new file mode 100644
index 00000000000..3a182e2ea15
--- /dev/null
+++ b/src/modules/shared/Back/Back.module.scss
@@ -0,0 +1,24 @@
+@import './../../../styles/typography';
+
+.back {
+ all: unset;
+ display: flex;
+ gap: 8px;
+ color: var(--color-secondary);
+ margin-top: 24px;
+ align-items: center;
+ cursor: pointer;
+
+ &__chevron {
+ height: 16px;
+ width: 16px;
+ margin-block: auto;
+ transform: rotate(180deg);
+ }
+
+ &__title {
+ @include small-text;
+
+ line-height: 12px;
+ }
+}
diff --git a/src/modules/shared/Back/Back.tsx b/src/modules/shared/Back/Back.tsx
new file mode 100644
index 00000000000..043d9641795
--- /dev/null
+++ b/src/modules/shared/Back/Back.tsx
@@ -0,0 +1,13 @@
+import { useNavigate } from 'react-router-dom';
+import s from './Back.module.scss';
+
+export const Back = () => {
+ const navigate = useNavigate();
+
+ return (
+
+ );
+};
diff --git a/src/modules/shared/Back/index.ts b/src/modules/shared/Back/index.ts
new file mode 100644
index 00000000000..c4e96e28419
--- /dev/null
+++ b/src/modules/shared/Back/index.ts
@@ -0,0 +1 @@
+export * from './Back';
diff --git a/src/modules/shared/Breadcrumbs/Breadcrumbs.module.scss b/src/modules/shared/Breadcrumbs/Breadcrumbs.module.scss
new file mode 100644
index 00000000000..2dac169b928
--- /dev/null
+++ b/src/modules/shared/Breadcrumbs/Breadcrumbs.module.scss
@@ -0,0 +1,58 @@
+.breadcrumbs {
+ margin-top: 24px;
+
+ &__home {
+ display: flex;
+ margin-block: auto;
+ }
+
+ &__list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ display: flex;
+ gap: 8px;
+ width: 100%;
+ }
+
+ &__item {
+ display: flex;
+ gap: 8px;
+ }
+
+ &__chevron {
+ display: flex;
+ margin-block: auto;
+ height: 16px;
+ width: 16px;
+ }
+
+ &__itemLink {
+ display: flex;
+ text-decoration: none;
+ color: var(--color-secondary);
+ font-weight: 600;
+ font-size: 12px;
+
+ &:hover {
+ color: var(--color-primary-dark);
+ }
+ }
+
+ &__itemName {
+ display: flex;
+ gap: 8px;
+ overflow: hidden;
+ flex: 1;
+ }
+
+ &__name {
+ display: inline;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ color: var(--color-primary-dark);
+ font-weight: 600;
+ font-size: 12px;
+ }
+}
diff --git a/src/modules/shared/Breadcrumbs/Breadcrumbs.tsx b/src/modules/shared/Breadcrumbs/Breadcrumbs.tsx
new file mode 100644
index 00000000000..3b1d1b40d2a
--- /dev/null
+++ b/src/modules/shared/Breadcrumbs/Breadcrumbs.tsx
@@ -0,0 +1,42 @@
+import { Link } from 'react-router-dom';
+import s from './Breadcrumbs.module.scss';
+import { capitalizeFirstLetter } from '../../../utils/string';
+
+type Props = {
+ type: string;
+ name?: string;
+};
+
+export const Breadcrumbs = ({ type, name }: Props) => {
+ return (
+
+ );
+};
diff --git a/src/modules/shared/Breadcrumbs/index.ts b/src/modules/shared/Breadcrumbs/index.ts
new file mode 100644
index 00000000000..ce977548b14
--- /dev/null
+++ b/src/modules/shared/Breadcrumbs/index.ts
@@ -0,0 +1 @@
+export * from './Breadcrumbs';
diff --git a/src/modules/shared/Card/Card.module.scss b/src/modules/shared/Card/Card.module.scss
new file mode 100644
index 00000000000..b3f09eb9798
--- /dev/null
+++ b/src/modules/shared/Card/Card.module.scss
@@ -0,0 +1,124 @@
+@import './../../../styles/mixins';
+@import './../../../styles/typography';
+
+.card {
+ box-sizing: border-box;
+ border: 1px solid var(--color-elements);
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+
+ &--grid {
+ grid-column: span 4;
+
+ @media (min-width: 500px) {
+ grid-column: span 2;
+ }
+
+ @include on-tablet {
+ grid-column: span 6;
+ }
+
+ @media (min-width: 800px) {
+ grid-column: span 4;
+ }
+
+ @include on-desktop {
+ grid-column: span 6;
+ }
+ }
+
+ &:hover {
+ box-shadow: 0 2px 16px 0 #0000001a;
+ }
+
+ &__content {
+ margin: 32px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ min-height: 0;
+ flex: 1;
+ }
+
+ &__imgLink {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ }
+
+ &__img {
+ max-width: 100%;
+ max-height: 100%;
+ aspect-ratio: 1 / 1;
+
+ object-fit: contain;
+ }
+
+ &__name {
+ text-decoration: none;
+ color: var(--color-primary-dark);
+ margin-top: 16px;
+
+ @include body-text;
+ }
+
+ &__prices {
+ display: flex;
+ gap: 8px;
+ }
+
+ &__price {
+ color: var(--color-primary-dark);
+ font-weight: 800;
+ font-size: 22px;
+ line-height: 140%;
+ letter-spacing: 0%;
+ }
+
+ &__fullPrice {
+ color: var(--color-secondary);
+ font-weight: 500;
+ font-size: 22px;
+ line-height: 140%;
+ text-decoration: line-through;
+ }
+
+ &__divider {
+ height: 1px;
+ background-color: var(--color-elements);
+ width: 100%;
+ }
+
+ &__specs {
+ display: flex;
+ justify-content: space-between;
+ flex-direction: column;
+ gap: 8px;
+ margin-block: 8px;
+ }
+
+ &__spec {
+ display: flex;
+ justify-content: space-between;
+ flex-direction: row;
+ }
+
+ &__label {
+ color: var(--color-secondary);
+
+ @include small-text;
+ }
+
+ &__value {
+ color: var(--color-primary-dark);
+ font-weight: 600;
+ font-size: 12px;
+ }
+
+ &__buttons {
+ display: flex;
+ gap: 8px;
+ }
+}
diff --git a/src/modules/shared/Card/Card.tsx b/src/modules/shared/Card/Card.tsx
new file mode 100644
index 00000000000..113e34a689a
--- /dev/null
+++ b/src/modules/shared/Card/Card.tsx
@@ -0,0 +1,103 @@
+import { Link } from 'react-router-dom';
+import s from './Card.module.scss';
+import type { Product } from '../../../types/Product';
+import { PrimaryButton } from '../PrimaryButton';
+import { AddToFovouritesButton } from '../AddToFovouritesButton';
+import classNames from 'classnames';
+import { useContext } from 'react';
+import { CartContext } from '../../../CartContext';
+import { FavouritesContext } from '../../../FavouritesContext';
+
+type Props = {
+ product: Product;
+ grid?: boolean;
+ withoutDiscount?: boolean;
+};
+
+export const Card = ({
+ product,
+ grid = false,
+ withoutDiscount = false,
+}: Props) => {
+ const { cart, setCart } = useContext(CartContext);
+ const { favourites, setFavourites } = useContext(FavouritesContext);
+
+ const isInCart = (id: string) => {
+ return !!cart.find(item => item.id === id);
+ };
+
+ const isFavourites = (id: string) => {
+ return !!favourites.find(item => item.itemId === id);
+ };
+
+ return (
+
+
+
+

+
+
+
+ {product.name}
+
+
+
+
+ ${withoutDiscount ? product.fullPrice : product.price}
+
+
+ {!withoutDiscount && '$' + product.fullPrice}
+
+
+
+
+
+
+
+
Screen
+
{product.screen}
+
+
+
Capacity
+
{product.capacity}
+
+
+
+
+
+ {isInCart(product.itemId) ? (
+
Added to cart
+ ) : (
+
+ setCart(prev => [
+ ...prev,
+ { id: product.itemId, quantity: 1, product: product },
+ ])
+ }
+ >
+ Add to cart
+
+ )}
+
+ setFavourites(prev =>
+ isFavourites(product.itemId)
+ ? [...prev].filter(item => item.itemId !== product.itemId)
+ : [...prev, product],
+ )
+ }
+ />
+
+
+
+ );
+};
diff --git a/src/modules/shared/Card/index.ts b/src/modules/shared/Card/index.ts
new file mode 100644
index 00000000000..ca0b060473a
--- /dev/null
+++ b/src/modules/shared/Card/index.ts
@@ -0,0 +1 @@
+export * from './Card';
diff --git a/src/modules/shared/CardList/CardList.module.scss b/src/modules/shared/CardList/CardList.module.scss
new file mode 100644
index 00000000000..14e77537c5e
--- /dev/null
+++ b/src/modules/shared/CardList/CardList.module.scss
@@ -0,0 +1,7 @@
+@import './../../../styles/mixins';
+
+.card-list {
+ @include page-grid;
+
+ row-gap: 40px;
+}
diff --git a/src/modules/shared/CardList/CardList.tsx b/src/modules/shared/CardList/CardList.tsx
new file mode 100644
index 00000000000..d930e26915d
--- /dev/null
+++ b/src/modules/shared/CardList/CardList.tsx
@@ -0,0 +1,17 @@
+import type { Product } from '../../../types/Product';
+import { Card } from '../Card/Card';
+import s from './CardList.module.scss';
+
+type Props = {
+ products: Product[];
+};
+
+export const CardList = ({ products }: Props) => {
+ return (
+
+ {products.map(product => (
+
+ ))}
+
+ );
+};
diff --git a/src/modules/shared/CardList/index.ts b/src/modules/shared/CardList/index.ts
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/src/modules/shared/CardsSlider/CardsSlider.module.scss b/src/modules/shared/CardsSlider/CardsSlider.module.scss
new file mode 100644
index 00000000000..3b99fc8786c
--- /dev/null
+++ b/src/modules/shared/CardsSlider/CardsSlider.module.scss
@@ -0,0 +1,73 @@
+@import './../../../styles/mixins';
+@import './../../../styles/typography';
+
+.cardsSlider {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+
+ &__top {
+ display: flex;
+ justify-content: space-between;
+ }
+
+ &__name {
+ margin-block: 0;
+ color: var(--color-primary-dark);
+
+ @include h2;
+ }
+
+ &__buttons {
+ display: flex;
+ gap: 16px;
+ }
+
+ &__list {
+ overflow: hidden;
+ width: calc(100%);
+ padding: 16px;
+ margin: -16px;
+
+ :global {
+ .swiper-wrapper {
+ display: flex;
+ align-items: stretch; // 👈 ключевая строка
+ }
+
+ .swiper-slide {
+ height: auto !important;
+ display: flex;
+ }
+ }
+
+ @include on-tablet {
+ padding: 24px;
+ margin: -24px;
+ }
+
+ @include on-desktop {
+ padding: 10px;
+ margin: -10px;
+ }
+
+ // &__swiper {
+ // :global {
+ // .swiper-wrapper {
+ // display: flex;
+ // align-items: stretch; // 👈 ключевая строка
+ // }
+
+ // .swiper-slide {
+ // height: 0 !important;
+ // display: flex;
+ // }
+ // }
+ // }
+
+ &__slide {
+ height: auto !important;
+ display: flex;
+ }
+ }
+}
diff --git a/src/modules/shared/CardsSlider/CardsSlider.tsx b/src/modules/shared/CardsSlider/CardsSlider.tsx
new file mode 100644
index 00000000000..fde77204dcb
--- /dev/null
+++ b/src/modules/shared/CardsSlider/CardsSlider.tsx
@@ -0,0 +1,69 @@
+import { SliderButton } from '../SliderButton';
+import s from './CardsSlider.module.scss';
+import { Swiper } from 'swiper/react';
+import type { SwiperRef } from 'swiper/react';
+
+import { useRef, useState } from 'react';
+import 'swiper/css';
+
+type Props = {
+ children: React.ReactNode;
+ name: string;
+};
+
+export const CardsSlider = ({ children, name }: Props) => {
+ const [isBeginning, setIsBeginning] = useState(true);
+ const [isEnd, setIsEnd] = useState(false);
+ const swiperRef = useRef(null);
+
+ return (
+
+
+
{name}
+
+ swiperRef.current?.swiper.slidePrev()}
+ />
+ swiperRef.current?.swiper.slideNext()}
+ />
+
+
+
+ {
+ setIsBeginning(swiper.isBeginning);
+ setIsEnd(swiper.isEnd);
+ }}
+ breakpoints={{
+ 0: {
+ slidesPerView: 1.3,
+ },
+ 450: {
+ slidesPerView: 1.6,
+ },
+ 640: {
+ slidesPerView: 2.5,
+ },
+ 840: {
+ slidesPerView: 3,
+ },
+ 1200: {
+ slidesPerView: 4,
+ },
+ }}
+ >
+ {children}
+
+
+
+ );
+};
diff --git a/src/modules/shared/CardsSlider/index.ts b/src/modules/shared/CardsSlider/index.ts
new file mode 100644
index 00000000000..16465df2425
--- /dev/null
+++ b/src/modules/shared/CardsSlider/index.ts
@@ -0,0 +1 @@
+export * from './CardsSlider';
diff --git a/src/modules/shared/Dropdown/Dropdown.module.scss b/src/modules/shared/Dropdown/Dropdown.module.scss
new file mode 100644
index 00000000000..23e548b2f12
--- /dev/null
+++ b/src/modules/shared/Dropdown/Dropdown.module.scss
@@ -0,0 +1,77 @@
+@import './../../../styles/typography';
+
+.select {
+ position: relative;
+ width: 100%;
+
+ &__trigger {
+ width: 100%;
+ padding: 10px;
+ cursor: pointer;
+ color: var(--color-primary-dark);
+ border: 1px solid var(--color-icons);
+ background: white;
+ text-align: left;
+ position: relative;
+
+ @include button-text;
+
+ &::after {
+ content: '';
+ position: absolute;
+ right: 12px;
+ top: 12px;
+ width: 16px;
+ height: 16px;
+ background-image: url('/img/icons/chevron-right-light.svg');
+ background-repeat: no-repeat;
+ background-position: right;
+ transform: rotate(90deg);
+ }
+
+ &--open {
+ &::after {
+ transform: rotate(270deg);
+ }
+ }
+
+ &:hover {
+ border: 1px solid var(--color-secondary);
+ }
+
+ &:focus {
+ border: 1px solid var(--color-primary-dark);
+ }
+ }
+
+ &__dropdown {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ right: 0;
+ border: 1px solid var(--color-elements);
+ background: white;
+ list-style: none;
+ margin: 4px 0 0;
+ padding: 0;
+ z-index: 10;
+ box-shadow: 0 2px 15px 0 #0000000d;
+ }
+
+ &__option {
+ cursor: pointer;
+ }
+
+ &__optionLink {
+ padding: 10px;
+ display: flex;
+ text-decoration: none;
+ color: var(--color-secondary);
+
+ @include body-text;
+
+ &:hover {
+ color: var(--color-primary-dark);
+ }
+ }
+}
diff --git a/src/modules/shared/Dropdown/Dropdown.tsx b/src/modules/shared/Dropdown/Dropdown.tsx
new file mode 100644
index 00000000000..86a29f9416a
--- /dev/null
+++ b/src/modules/shared/Dropdown/Dropdown.tsx
@@ -0,0 +1,80 @@
+import { useEffect, useRef, useState } from 'react';
+import type { SelectOption } from '../../../types/SelectOption';
+import s from './Dropdown.module.scss';
+import classNames from 'classnames';
+import { Link, useSearchParams } from 'react-router-dom';
+import { getSearchWith } from '../../../utils/searchHelper';
+import { getDropdownParams } from '../../../utils/getDropdownParams';
+
+type Props = {
+ options: SelectOption[];
+ value: string;
+ paramKey: string;
+};
+
+export const Dropdown = ({ options, value, paramKey }: Props) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const selectRef = useRef(null);
+ const [searchParams] = useSearchParams();
+
+ const selectedOption = options.find(option => option.value === value);
+
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (
+ selectRef.current &&
+ !selectRef.current.contains(event.target as Node)
+ ) {
+ setIsOpen(false);
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, []);
+
+ return (
+
+
+
+ {isOpen && (
+
+ {options.map(option => (
+ -
+ setIsOpen(false)}
+ >
+ {option.label}
+
+
+ ))}
+
+ )}
+
+ );
+};
diff --git a/src/modules/shared/Dropdown/index.ts b/src/modules/shared/Dropdown/index.ts
new file mode 100644
index 00000000000..2f29bad4e67
--- /dev/null
+++ b/src/modules/shared/Dropdown/index.ts
@@ -0,0 +1 @@
+export * from './Dropdown';
diff --git a/src/modules/shared/Line/Line.module.scss b/src/modules/shared/Line/Line.module.scss
new file mode 100644
index 00000000000..250f6d77f68
--- /dev/null
+++ b/src/modules/shared/Line/Line.module.scss
@@ -0,0 +1,7 @@
+.line {
+ height: 1px;
+ width: 100%;
+ background-color: var(--color-elements);
+ margin: 0;
+ padding: 0;
+}
\ No newline at end of file
diff --git a/src/modules/shared/Line/Line.tsx b/src/modules/shared/Line/Line.tsx
new file mode 100644
index 00000000000..86043e2a77a
--- /dev/null
+++ b/src/modules/shared/Line/Line.tsx
@@ -0,0 +1,5 @@
+import s from './Line.module.scss';
+
+export const Line = () => {
+ return ;
+};
diff --git a/src/modules/shared/Line/index.ts b/src/modules/shared/Line/index.ts
new file mode 100644
index 00000000000..34a969a3d62
--- /dev/null
+++ b/src/modules/shared/Line/index.ts
@@ -0,0 +1 @@
+export * from './Line';
diff --git a/src/modules/shared/Pagination/Pagination.module.scss b/src/modules/shared/Pagination/Pagination.module.scss
new file mode 100644
index 00000000000..3517f93d5d0
--- /dev/null
+++ b/src/modules/shared/Pagination/Pagination.module.scss
@@ -0,0 +1,33 @@
+.pagination {
+ display: flex;
+ gap: 16px;
+ justify-content: center;
+
+ &__pages {
+ display: flex;
+ gap: 8px;
+ }
+
+ &__page {
+ box-sizing: border-box;
+ height: 32px;
+ width: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ text-decoration: none;
+ border: 1px solid var(--color-icons);
+ color: var(--color-primary-dark);
+
+ &--active {
+ background-color: var(--color-primary-dark);
+ color: white;
+ }
+
+ &--disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+ pointer-events: no;
+ }
+ }
+}
diff --git a/src/modules/shared/Pagination/Pagination.tsx b/src/modules/shared/Pagination/Pagination.tsx
new file mode 100644
index 00000000000..3eead422705
--- /dev/null
+++ b/src/modules/shared/Pagination/Pagination.tsx
@@ -0,0 +1,113 @@
+import { Link, useSearchParams } from 'react-router-dom';
+import { SliderButton } from '../SliderButton';
+import s from './Pagination.module.scss';
+import { getSearchWith } from '../../../utils/searchHelper';
+import classNames from 'classnames';
+
+type Props = {
+ total: number;
+ perPage: number;
+ currentPage: number;
+};
+
+export const Pagination = ({ total, perPage, currentPage }: Props) => {
+ const [searchParams] = useSearchParams();
+
+ const totalPages = Math.ceil(total / perPage);
+
+ const getVisiblePages = () => {
+ if (totalPages <= 5) {
+ return Array.from({ length: totalPages }, (_, i) => i + 1);
+ }
+
+ const pages: (number | string)[] = [];
+ const visibleNumbers = new Set();
+
+ visibleNumbers.add(1);
+ visibleNumbers.add(totalPages);
+
+ visibleNumbers.add(currentPage);
+
+ if (currentPage - 1 > 1) {
+ visibleNumbers.add(currentPage - 1);
+ }
+
+ if (currentPage + 1 < totalPages) {
+ visibleNumbers.add(currentPage + 1);
+ }
+
+ const sortedPages = [...visibleNumbers].sort((a, b) => a - b);
+
+ for (let i = 0; i < sortedPages.length; i++) {
+ const current = sortedPages[i];
+ const prev = sortedPages[i - 1];
+
+ if (prev && current - prev > 1) {
+ pages.push('...');
+ }
+
+ pages.push(current);
+ }
+
+ return pages;
+ };
+
+ const visiblePages = getVisiblePages();
+
+ return (
+
+
1 ? String(currentPage - 1) : null,
+ }),
+ }}
+ className={classNames({
+ [s['pagination__page--disabled']]: currentPage <= 1,
+ })}
+ >
+
+
+
+
+ {visiblePages.map((page, index) =>
+ page === '...' ? (
+
+ ...
+
+ ) : (
+
+ {page}
+
+ ),
+ )}
+
+
+
= totalPages,
+ })}
+ >
+
+
+
+ );
+};
diff --git a/src/modules/shared/Pagination/index.tsx b/src/modules/shared/Pagination/index.tsx
new file mode 100644
index 00000000000..e016c96b72e
--- /dev/null
+++ b/src/modules/shared/Pagination/index.tsx
@@ -0,0 +1 @@
+export * from './Pagination';
diff --git a/src/modules/shared/PrimaryButton/PrimaryButton.module.scss b/src/modules/shared/PrimaryButton/PrimaryButton.module.scss
new file mode 100644
index 00000000000..7075e9bec00
--- /dev/null
+++ b/src/modules/shared/PrimaryButton/PrimaryButton.module.scss
@@ -0,0 +1,27 @@
+@import './../../../styles/typography';
+
+.button {
+ display: flex;
+ flex: 1;
+ height: 40px;
+ max-height: 48px;
+ align-items: center;
+ justify-content: center;
+ color: white;
+ background-color: var(--color-primary-dark);
+ border: none;
+ cursor: pointer;
+
+ @include button-text;
+
+ &--selected {
+ box-sizing: border-box;
+ color: var(--color-green);
+ border: 1px solid var(--color-elements);
+ background-color: white;
+ }
+
+ &:hover {
+ box-shadow: 0 3px 13px 0 #17203166;
+ }
+}
diff --git a/src/modules/shared/PrimaryButton/PrimaryButton.tsx b/src/modules/shared/PrimaryButton/PrimaryButton.tsx
new file mode 100644
index 00000000000..f431cad0b25
--- /dev/null
+++ b/src/modules/shared/PrimaryButton/PrimaryButton.tsx
@@ -0,0 +1,25 @@
+import classNames from 'classnames';
+import s from './PrimaryButton.module.scss';
+
+type Props = {
+ children: React.ReactNode;
+ selected?: boolean;
+ onClick?: React.MouseEventHandler;
+};
+
+export const PrimaryButton = ({
+ selected = false,
+ children,
+ onClick,
+}: Props) => {
+ return (
+
+ );
+};
diff --git a/src/modules/shared/PrimaryButton/index.ts b/src/modules/shared/PrimaryButton/index.ts
new file mode 100644
index 00000000000..ad4b80e70dc
--- /dev/null
+++ b/src/modules/shared/PrimaryButton/index.ts
@@ -0,0 +1 @@
+export * from './PrimaryButton';
diff --git a/src/modules/shared/SliderButton/SliderButton.module.scss b/src/modules/shared/SliderButton/SliderButton.module.scss
new file mode 100644
index 00000000000..47ed84ff2d0
--- /dev/null
+++ b/src/modules/shared/SliderButton/SliderButton.module.scss
@@ -0,0 +1,53 @@
+.button {
+ height: 32px;
+ width: 32px;
+ box-sizing: border-box;
+ border: 1px solid var(--color-icons);
+ background-image: url('/img/icons/chevron-right.svg');
+ background-repeat: no-repeat;
+ background-position: center;
+ cursor: pointer;
+
+ &--up {
+ transform: rotate(270deg);
+ }
+
+ &--down {
+ transform: rotate(90deg);
+ }
+
+ &--left {
+ transform: rotate(180deg);
+ }
+
+ &--right {
+ transform: rotate(0deg);
+ }
+
+ &--plus {
+ background-image: url('/img/icons/plus.svg');
+ }
+
+ &--minus {
+ background-image: url('/img/icons/minus.svg');
+ }
+
+ &:hover {
+ border: 1px solid var(--color-primary-dark);
+ }
+
+ &--disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+ pointer-events: none;
+
+ &:hover {
+ border: 1px solid var(--color-elements);
+ }
+ }
+
+ &--forBanner {
+ height: 100%;
+ width: 100%;
+ }
+}
diff --git a/src/modules/shared/SliderButton/SliderButton.tsx b/src/modules/shared/SliderButton/SliderButton.tsx
new file mode 100644
index 00000000000..1cab823d418
--- /dev/null
+++ b/src/modules/shared/SliderButton/SliderButton.tsx
@@ -0,0 +1,26 @@
+import classNames from 'classnames';
+import s from './SliderButton.module.scss';
+
+type Props = {
+ direction?: 'up' | 'down' | 'left' | 'right' | 'plus' | 'minus';
+ disabled?: boolean;
+ forBanner?: boolean;
+ onClick?: () => void;
+};
+
+export const SliderButton = ({
+ direction = 'right',
+ disabled = false,
+ forBanner = false,
+ onClick = () => {},
+}: Props) => {
+ return (
+
+ );
+};
diff --git a/src/modules/shared/SliderButton/index.ts b/src/modules/shared/SliderButton/index.ts
new file mode 100644
index 00000000000..8382581a959
--- /dev/null
+++ b/src/modules/shared/SliderButton/index.ts
@@ -0,0 +1 @@
+export * from './SliderButton';
diff --git a/src/styles/_fonts.scss b/src/styles/_fonts.scss
new file mode 100644
index 00000000000..9bff55bf4de
--- /dev/null
+++ b/src/styles/_fonts.scss
@@ -0,0 +1,20 @@
+@font-face {
+ font-family: Mont;
+ font-weight: 400;
+ font-style: normal;
+ src: url('/fonts/Mont-Regular.otf') format('opentype');
+}
+
+@font-face {
+ font-family: Mont;
+ font-weight: 600;
+ font-style: normal;
+ src: url('/fonts/Mont-SemiBold.otf') format('opentype');
+}
+
+@font-face {
+ font-family: Mont;
+ font-weight: 700;
+ font-style: normal;
+ src: url('/fonts/Mont-Bold.otf') format('opentype');
+}
diff --git a/src/styles/_globals.scss b/src/styles/_globals.scss
new file mode 100644
index 00000000000..4d8deb4c370
--- /dev/null
+++ b/src/styles/_globals.scss
@@ -0,0 +1,11 @@
+:root {
+ --color-primary-dark: #313237;
+ --color-secondary: #89939a;
+ --color-icons: #b4bdc3;
+ --color-elements: #e2e6e9;
+ --color-hover-bg: #fafbfc;
+ --color-white: #fff;
+ --color-green: #27ae60;
+ --color-red: #eb5757;
+ --color-primary: var(--color-primary-dark);
+}
\ No newline at end of file
diff --git a/src/styles/_mixins.scss b/src/styles/_mixins.scss
new file mode 100644
index 00000000000..782d61dc757
--- /dev/null
+++ b/src/styles/_mixins.scss
@@ -0,0 +1,50 @@
+@import './variables';
+
+@mixin on-tablet {
+ @media (min-width: $tablet-min-width) {
+ @content;
+ }
+}
+
+@mixin on-desktop {
+ @media (min-width: $desktop-min-width) {
+ @content;
+ }
+}
+
+@mixin content-padding-inline {
+ padding-inline: 16px;
+
+ @include on-tablet {
+ padding-inline: 24px;
+ }
+
+ @include on-desktop {
+ padding-inline: 32px;
+ max-width: 1136px;
+ margin-inline: auto;
+ }
+}
+
+@mixin page-grid {
+ --columns: 4;
+
+ display: grid;
+ column-gap: 16px;
+ grid-template-columns: repeat(var(--columns), 1fr);
+
+ @include on-tablet {
+ --columns: 12;
+ }
+
+ @include on-desktop {
+ --columns: 24;
+ }
+}
+
+@mixin hover($_property, $_toValue) {
+ transition: #{$_property} 0.3s;
+ &:hover {
+ #{$_property}: $_toValue;
+ }
+}
diff --git a/src/styles/_typography.scss b/src/styles/_typography.scss
new file mode 100644
index 00000000000..296c5dc7c6c
--- /dev/null
+++ b/src/styles/_typography.scss
@@ -0,0 +1,89 @@
+@import './mixins';
+
+$font-main: 'Mont', sans-serif;
+
+@mixin h1 {
+ font-family: $font-main;
+ font-weight: 700;
+ font-size: 32px;
+ line-height: 41px;
+ letter-spacing: -0.01em;
+
+ @include on-tablet {
+ font-size: 48px;
+ line-height: 56px;
+ }
+}
+
+@mixin h2 {
+ font-family: $font-main;
+ font-weight: 700;
+ font-size: 22px;
+ line-height: 31px;
+ letter-spacing: 0;
+
+ @include on-tablet {
+ font-size: 32px;
+ line-height: 41px;
+ letter-spacing: -0.01em;
+ }
+}
+
+@mixin h3 {
+ font-family: $font-main;
+ font-weight: 600;
+ font-size: 20px;
+ line-height: 26px;
+ letter-spacing: 0;
+
+ @include on-tablet {
+ font-size: 22px;
+ line-height: 31px;
+ }
+}
+
+@mixin h4 {
+ font-family: $font-main;
+ font-weight: 600;
+ font-size: 16px;
+ line-height: 20px;
+ letter-spacing: 0;
+
+ @include on-tablet {
+ font-size: 20px;
+ line-height: 26px;
+ }
+}
+
+@mixin uppercase {
+ font-family: $font-main;
+ font-weight: 700;
+ font-size: 12px;
+ line-height: 11px;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+@mixin button-text {
+ font-family: $font-main;
+ font-weight: 600;
+ font-size: 14px;
+ line-height: 21px;
+ letter-spacing: 0;
+}
+
+@mixin body-text {
+ font-family: $font-main;
+ font-weight: 400;
+ font-size: 14px;
+ line-height: 21px;
+ letter-spacing: 0;
+}
+
+@mixin small-text {
+ font-family: $font-main;
+ font-weight: 600;
+ font-size: 12px;
+ line-height: 15px;
+ letter-spacing: 0;
+}
\ No newline at end of file
diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss
new file mode 100644
index 00000000000..a1d52b6870a
--- /dev/null
+++ b/src/styles/_variables.scss
@@ -0,0 +1,3 @@
+$tablet-min-width: 640px;
+$desktop-min-width: 1200px;
+$effectDuration: 0.3s;
\ No newline at end of file
diff --git a/src/types/Cart.ts b/src/types/Cart.ts
new file mode 100644
index 00000000000..f48c5758625
--- /dev/null
+++ b/src/types/Cart.ts
@@ -0,0 +1,7 @@
+import type { Product } from './Product';
+
+export type Cart = {
+ id: string;
+ quantity: number;
+ product: Product;
+};
diff --git a/src/types/Product.ts b/src/types/Product.ts
new file mode 100644
index 00000000000..8111167715a
--- /dev/null
+++ b/src/types/Product.ts
@@ -0,0 +1,14 @@
+export type Product = {
+ id: number;
+ category: string;
+ itemId: string;
+ name: string;
+ fullPrice: number;
+ price: number;
+ screen: string;
+ capacity: string;
+ color: string;
+ ram: string;
+ year: number;
+ image: string;
+};
diff --git a/src/types/ProductFull.ts b/src/types/ProductFull.ts
new file mode 100644
index 00000000000..4cbc29ff384
--- /dev/null
+++ b/src/types/ProductFull.ts
@@ -0,0 +1,23 @@
+import type { ProductFullDescription } from './ProductFullDescription';
+
+export type ProductFull = {
+ id: string;
+ category: string;
+ namespaceId: string;
+ name: string;
+ capacityAvailable: string[];
+ capacity: string;
+ priceRegular: number;
+ priceDiscount: number;
+ colorsAvailable: string[];
+ color: string;
+ images: string[];
+ description: ProductFullDescription[];
+ screen: string;
+ resolution: string;
+ processor: string;
+ ram: string;
+ camera: string;
+ zoom: string;
+ cell: string[];
+};
diff --git a/src/types/ProductFullDescription.ts b/src/types/ProductFullDescription.ts
new file mode 100644
index 00000000000..76c694c1f7c
--- /dev/null
+++ b/src/types/ProductFullDescription.ts
@@ -0,0 +1,4 @@
+export type ProductFullDescription = {
+ title: string;
+ text: string[];
+};
diff --git a/src/types/SelectOption.ts b/src/types/SelectOption.ts
new file mode 100644
index 00000000000..3c5757c377e
--- /dev/null
+++ b/src/types/SelectOption.ts
@@ -0,0 +1,4 @@
+export type SelectOption = {
+ value: string;
+ label: string;
+};
diff --git a/src/utils/colors.ts b/src/utils/colors.ts
new file mode 100644
index 00000000000..a961bb0117c
--- /dev/null
+++ b/src/utils/colors.ts
@@ -0,0 +1,22 @@
+export const colors: Record = {
+ black: '#1A1A1A',
+ spaceblack: '#1C1C1E',
+ graphite: '#535353',
+ spacegray: '#4A4A4C',
+ white: '#F5F5F0',
+ silver: '#C0C0C0',
+ starlight: '#F2E8D5',
+ blue: '#3478F6',
+ midnight: '#191970',
+ midnightgreen: '#004953',
+ sierrablue: '#A2B8C8',
+ skyblue: '#87CEEB',
+ green: '#31572C',
+ red: '#CC0000',
+ coral: '#FF6B6B',
+ pink: '#F4A7B9',
+ purple: '#9B59B6',
+ gold: '#D4AF37',
+ rosegold: '#B76E79',
+ yellow: '#FFD60A',
+};
diff --git a/src/utils/getDropdownParams.ts b/src/utils/getDropdownParams.ts
new file mode 100644
index 00000000000..d29805564eb
--- /dev/null
+++ b/src/utils/getDropdownParams.ts
@@ -0,0 +1,32 @@
+// utils/getDropdownParams.ts
+import type { SelectOption } from '../types/SelectOption';
+
+type GetDropdownParamsProps = {
+ paramKey: string;
+ optionValue: string;
+ options: SelectOption[];
+};
+
+export const getDropdownParams = ({
+ paramKey,
+ optionValue,
+ options,
+}: GetDropdownParamsProps) => {
+ const params: Record = {
+ [paramKey]: optionValue === options[0].value ? null : optionValue,
+ };
+
+ if (paramKey === 'perPage' && optionValue === 'all') {
+ params.page = null;
+ }
+
+ if (paramKey === 'perPage' && optionValue !== 'all') {
+ params.page = '1';
+ }
+
+ if (paramKey === 'sort') {
+ params.page = '1';
+ }
+
+ return params;
+};
diff --git a/src/utils/searchHelper.ts b/src/utils/searchHelper.ts
new file mode 100644
index 00000000000..10a56f8f507
--- /dev/null
+++ b/src/utils/searchHelper.ts
@@ -0,0 +1,26 @@
+export type SearchParams = {
+ [key: string]: string | string[] | null;
+};
+
+export function getSearchWith(
+ currentParams: URLSearchParams,
+ paramsToUpdate: SearchParams,
+): string {
+ const newParams = new URLSearchParams(currentParams.toString());
+
+ Object.entries(paramsToUpdate).forEach(([key, value]) => {
+ if (value === null) {
+ newParams.delete(key);
+ } else if (Array.isArray(value)) {
+ newParams.delete(key);
+
+ value.forEach(part => {
+ newParams.append(key, part);
+ });
+ } else {
+ newParams.set(key, value);
+ }
+ });
+
+ return newParams.toString();
+}
diff --git a/src/utils/string.ts b/src/utils/string.ts
new file mode 100644
index 00000000000..060d6726c64
--- /dev/null
+++ b/src/utils/string.ts
@@ -0,0 +1,7 @@
+export const capitalizeFirstLetter = (text: string): string => {
+ if (!text) {
+ return '';
+ }
+
+ return text.charAt(0).toUpperCase() + text.slice(1);
+};
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
deleted file mode 100644
index 11f02fe2a00..00000000000
--- a/src/vite-env.d.ts
+++ /dev/null
@@ -1 +0,0 @@
-///
diff --git a/tsconfig.json b/tsconfig.json
index cfb168bb26c..baf3e73e0a0 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,10 +1,11 @@
{
"extends": "@mate-academy/students-ts-config",
- "include": [
- "src"
- ],
+ "include": ["src"],
"compilerOptions": {
"sourceMap": false,
- "types": ["node", "cypress"]
+ "types": ["node", "cypress"],
+ "paths": {
+ "types/*": ["./src/types/*"]
+ }
}
}