diff --git a/README.md b/README.md
index 39c9a51fb72..3927e53641e 100644
--- a/README.md
+++ b/README.md
@@ -1,143 +1,122 @@
-# React Product Catalog
-
-Implement the catalog with a shopping cart and favorites page according to one of the next designs:
-
-- [Original](https://www.figma.com/file/T5ttF21UnT6RRmCQQaZc6L/Phone-catalog-(V2)-Original)
-- [Original Dark](https://www.figma.com/design/WMdJ24eHk4EkSr25mrt7Y2/Phone-catalog--V2--Original-Dark)
-- [Rounded Blue](https://www.figma.com/file/FRxncC4lfyhs6og1L6FGEU/Phone-catalog-(V2)-Rounded-Style-2?node-id=0%3A1)
-- [Rounded Purple](https://www.figma.com/file/xMK2Dy0mfBbJJSNctmOuLW/Phone-catalog-(V2)-Rounded-Style-1?node-id=0%3A1)
-- [Rounded Orange](https://www.figma.com/file/7JTa0q8n3dTSAyMNaA0u8o/Phone-catalog-(V2)-Rounded-Style-3?node-id=0%3A1)
-
-You may also implement color theme switching!
-
-## If you work in a team
-
-Follow the [Work in a team guideline](https://github.com/mate-academy/react_task-guideline/blob/master/team-flow.md#how-to-work-in-a-team)
-
-## Project Setup from scratch
-
-Follow the [Instruction](https://github.com/mate-academy/react_phone-catalog/blob/master/setup.md) to setup your project, add Eslint, Prettier, Husky and enable auto deploy.
-
-## Data
-
-Use the data from `/public/api` and images from `/public/img` folders. You can reorganize them the way you like.
-
-## App
-
-1. Put components into the `src/components` folder.
- - Each component should be a folder with `index.ts`, `ComponentName.tsx`, `ComponentName.module.scss` files.
- - Use CSS modules.
- - Keep `.module.scss` files together with their components.
-2. Advanced project structure:
- - `src/modules` folder. Inside per page modules `HomePage`, `CartPage`, etc., and `shared` folder with shared content between modules.
- - Inside each module its own `components` folder with the structure described above. And optionally other files/folders: `hooks`, `constants`, and so on.
-3. Add the sticky header with a logo, navigation, favorites, and cart.
-4. The footer with the link to the GitHub repo and `Back to top` button.
- - The content should be limited to the same width as the page content;
- - `Back to top` button should scroll to the top smoothly;
-5. Add `NotFoundPage` containing text `Page not found` for all the unknown URLs.
-6. All changes the hover effects should be smooth.
-7. Scale all image links by 10% on hover.
-8. Implement all form elements and icons according to the UI Kit.
-
-## Home page
-
-Implement Home page at available at `/`.
-
-1. `
Product Catalog ` should be visually hidden.
-2. `PicturesSlider`:
- - Find your own images to personalize the App;
- - Change pictures automatically every 5 seconds;
- - The next buttons should show the first image after the last one;
- - Dashes at the bottom should allow choosing an exact picture.
-3. `ProductsSlider` for the `Hot prices` block:
- - The products with a discount starting from the biggest absolute value;
- - `<` and `>` buttons should scroll products.
-4. `Shop by category` block with links to `/phones`, `/tablets`, and `/accessories`.
-5. Add Brand new block using ProductsSlider with products that are the newest according to the year field.
-
-## Product pages
-
-There should be 3 separate pages `/phones`, `/tablets`, and `/accessories`.
-
-1. Each page loads the data of the required `type`.
-2. Add an `h1` with `Phones/Tablets/Accessories page` (choose required).
-3. Add `ProductsList` component showing all the `products`.
-4. Implement a `Loader` to show it while waiting for the data from the server.
-5. In case of a loading error show the something went wrong message with a reload button.
-6. If there are no products available show the `There are no phones/tablets/accessories yet` message (choose required).
-7. Add a `` with the `Newest`, `Alphabetically`, and `Cheapest` options to sort products by `age`, `title`, or `price` (after discount).
- - Save the sort value in the URL `?sort=age` and apply it after the page reload.
-8. Add `Pagination` buttons and `Items on page` select element with `4`, `8`, `16`, and `all` options.
- - It should limit the products you show to the user;
- - Save pagination params in the URL `?page=2&perPage=8` (`page=1` and `perPage=all` are the default values and should not be added to the URL;
- - Hide pagination elements if they do not make sense;
- - You can use the logic explained in [the React Pagination task](https://github.com/mate-academy/react_pagination#react-pagination).
-
-## Product details page
-
-Create `ProductDetailsPage` available at `/product/:productId`.
-
-1. `ProductCard` image and title should be links to the product details page.
-2. Use `Loader` when fetching the product details.
-3. Show the details on the page:
- - Display the available colors from colorsAvailable and the capacities from capacityAvailable as radio inputs, allowing the selection of one value from the offered options;
- - `About` section should contain a subheader with description;
- - Choose `Tech specs` you want to show.
-4. Add the ability to choose a picture.
-5. Implement `You may also like` block with products chosen randomly:
- - Create `getSuggestedProducts` method fetching the suggested products.
-6. Add `Back` button working the same way as a Browser `Back` button.
-7. Add `Breadcrumbs` at the top with:
- - A Home page link;
- - A category page link (`Phones`, `Tablets`, `Accessories`);
- - The name of the product (just a text).
-8. Show `Product was not found` if there is no product with a given id on the server.
-
-## Shopping Cart page
-
-Create a Cart page with a list of `CartItem`s at `/cart`.
-Each item should have an `id`, `quantity`, and a `product`.
-Use React Context or Redux to store Items.
-
-1. `Add to cart` button in the `ProductCard` should add a product to the `Cart`.
-2. If the product is already in the `Cart` the button should say `Added to cart` and do nothing.
-3. Add the ability to remove items from the `Cart` with an `x` button next to a `CartItem`.
-4. Add a message `Your cart is empty` when there are no products in the `Cart`.
-5. Add the ability to change the item quantity in the `Cart` with `-` and `+` buttons (it should be > 0).
-6. Total amount and quantity should be calculated automatically.
-7. Show the quantity at the `Cart` icon in the header.
-8. Save the `Cart` to `localStorage` on each change and read it on page load.
-9. `Checkout` button should show a modal dialog with the text `Checkout is not implemented yet. Do you want to clear the Cart?`:
- - Clear the Cart if the user confirms the order;
- - Keep the Cart items and close the confirmation on cancel;
- - Use the `confirm` function if you don't have a better solution.
-
-## Favorites page
-
-Create `Favorites` page with a `ProductsList` showing favorite products at `/favorites`.
-
-1. Add/remove a product to favorites by pressing a heart button in the `ProductCard` element.
-2. The heart should be highlighted if the product is already added to the favorites.
-3. Use React Context or Redux to store the favorites.
-4. Show the number of favorites at the `Favorites` icon in the header.
-5. Save favorites to `localStorage` on each change and load them on page load.
-
-## Other tasks
-
-1. Add `NotFoundPage` containing text `Page not found` for all the other URLs with the link to `HomePage`.
-2. Implement the `Product was not found` state for the `ProductDetailsPage`.
-
-## (*) Advanced tasks
-
-- Implement color theme switching!
-- Use [skeletons](https://freefrontend.com/css-skeleton-loadings/) to make loading more natural.
-- Add the ability to change page language.
-
-### Search
-
-Show `input:search` in the header when a page contains a `ProductList` to search in.
-
-1. Save the `Search` value in the URL as a `?query=value` to apply on page load.
-2. Show `There are no phones/tablets/accessories/products matching the query` instead of `ProductList` when needed.
-3. Add `debounce` to the search field.
+🛍️ Product Catalog App
+
+A modern and responsive product catalog web application built with React and TypeScript.
+The app allows users to browse products, view detailed information, manage a shopping cart, and save favorite items.
+
+🔗 Live Demo
+
+👉 https://berezandiana.github.io/product_catalog/
+
+• ⚙️ Technologies Used
+• ⚛️ React + TypeScript
+• 🧠 Redux Toolkit (state management)
+• 🌐 React Router
+• 🎨 SCSS Modules
+• 🌙 CSS Variables (theme support)
+• 🌍 i18next (localization)
+• 🎞 Swiper (sliders)
+• 🔔 Sonner (notifications)
+
+
+🚀 Features
+🏠 Home Page
+Main banner slider with:
+• auto-play (every 5 seconds)
+• navigation arrows
+• pagination indicators
+
+Product sliders:
+• 🔥 Hot Prices — sorted by biggest discount
+• 🆕 Brand New Models — newest products
+
+📂 Category section with navigation to:
+• Phones
+• Tablets
+• Accessories
+
+
+📱 Catalog Pages
+
+Available routes:
+
+• /phones
+• /tablets
+• /accessories
+
+Features:
+
+• Dynamic product loading
+• Sorting:
+ ○ Newest
+ ○ Alphabetically
+ ○ Cheapest
+• Pagination with URL sync
+• Items per page selection
+• 🔍 Search with debounce
+• Empty state & error handling
+• Loading indicators
+
+
+📄 Product Details Page
+
+Route: /product/:productId
+
+• Full product information
+• Image gallery with preview selection
+• Available:
+ ○ Colors
+ ○ Capacities
+• Tech specifications & description
+• 🔙 Back button (browser-like behavior)
+• 🧭 Breadcrumbs navigation
+• 🎯 Suggested products block
+
+
+🛒 Cart Page
+
+Route: /cart
+
+• Add/remove products
+• Change quantity (+ / -)
+• Total price and item count calculation
+• Persisted via localStorage
+• Checkout modal (simulation)
+
+
+❤️ Favorites Page
+
+Route: /favorites
+
+• Add/remove items using heart icon
+• Favorites counter in header
+• Data stored in localStorage
+
+
+🎨 UI & UX
+• Sticky header with:
+ ○ navigation
+ ○ cart & favorites counters
+• Smooth hover animations
+• Image scale effect on hover
+• Fully responsive layout
+• Dark theme support 🌙
+• Clean modular architecture
+
+
+🔎 Search
+• Search input appears on catalog pages
+• Query stored in URL (?query=...)
+• Debounced input for better performance
+• Custom "no results" state
+
+
+⚠️ Error Handling
+• Global error states for API requests
+• Retry functionality
+• Custom 404 Not Found page
+
+🧩 Additional Features
+• 🌐 Language switching
+• 🎨 Theme switching
+• 🔄 State persistence (cart & favorites)
+• ⚡ Optimized rendering with memoization
diff --git a/index.html b/index.html
index 095fb3a4537..4218c4108b9 100644
--- a/index.html
+++ b/index.html
@@ -3,7 +3,9 @@
- Vite + React + TS
+
+
+ Nice Gadgets
diff --git a/package-lock.json b/package-lock.json
index 836b9e63b46..4cdeaaf5d06 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,18 +11,28 @@
"license": "GPL-3.0",
"dependencies": {
"@fortawesome/fontawesome-free": "^6.5.2",
+ "@reduxjs/toolkit": "^2.11.2",
"bulma": "^1.0.1",
"classnames": "^2.5.1",
+ "i18next": "^26.0.8",
+ "lodash.debounce": "^4.0.8",
+ "normalize-scss": "^8.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+ "react-i18next": "^17.0.6",
+ "react-redux": "^9.2.0",
"react-router-dom": "^6.25.1",
- "react-transition-group": "^4.4.5"
+ "react-select": "^5.10.2",
+ "react-transition-group": "^4.4.5",
+ "sonner": "^2.0.7",
+ "swiper": "^12.1.3"
},
"devDependencies": {
"@cypress/react18": "^2.0.1",
- "@mate-academy/scripts": "^1.8.5",
+ "@mate-academy/scripts": "^2.1.3",
"@mate-academy/students-ts-config": "*",
"@mate-academy/stylelint-config": "*",
+ "@types/lodash.debounce": "^4.0.9",
"@types/node": "^20.14.10",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
@@ -67,7 +77,6 @@
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz",
"integrity": "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==",
- "dev": true,
"dependencies": {
"@babel/highlight": "^7.24.7",
"picocolors": "^1.0.0"
@@ -128,7 +137,6 @@
"version": "7.24.9",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.9.tgz",
"integrity": "sha512-G8v3jRg+z8IwY1jHFxvCNhOPYPterE4XljNgdGTYfSTtzzwjIswIzIaSPSLs3R7yFuqnqNeay5rjICfqVr+/6A==",
- "dev": true,
"dependencies": {
"@babel/types": "^7.24.9",
"@jridgewell/gen-mapping": "^0.3.5",
@@ -168,7 +176,6 @@
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.24.7.tgz",
"integrity": "sha512-DoiN84+4Gnd0ncbBOM9AZENV4a5ZiL39HYMyZJGZ/AZEykHYdJw0wW3kdcsh9/Kn+BRXHLkkklZ51ecPKmI1CQ==",
- "dev": true,
"dependencies": {
"@babel/types": "^7.24.7"
},
@@ -180,7 +187,6 @@
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.24.7.tgz",
"integrity": "sha512-FyoJTsj/PEUWu1/TYRiXTIHc8lbw+TDYkZuoE43opPS5TrI7MyONBE1oNvfguEXAD9yhQRrVBnXdXzSLQl9XnA==",
- "dev": true,
"dependencies": {
"@babel/template": "^7.24.7",
"@babel/types": "^7.24.7"
@@ -193,7 +199,6 @@
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.24.7.tgz",
"integrity": "sha512-MJJwhkoGy5c4ehfoRyrJ/owKeMl19U54h27YYftT0o2teQ3FJ3nQUf/I3LlJsX4l3qlw7WRXUmiyajvHXoTubQ==",
- "dev": true,
"dependencies": {
"@babel/types": "^7.24.7"
},
@@ -205,7 +210,6 @@
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.7.tgz",
"integrity": "sha512-8AyH3C+74cgCVVXow/myrynrAGv+nTVg5vKu2nZph9x7RcRwzmh0VFallJuFTZ9mx6u4eSdXZfcOzSqTUm0HCA==",
- "dev": true,
"dependencies": {
"@babel/traverse": "^7.24.7",
"@babel/types": "^7.24.7"
@@ -259,7 +263,6 @@
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz",
"integrity": "sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==",
- "dev": true,
"dependencies": {
"@babel/types": "^7.24.7"
},
@@ -271,7 +274,6 @@
"version": "7.24.8",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz",
"integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==",
- "dev": true,
"engines": {
"node": ">=6.9.0"
}
@@ -280,7 +282,6 @@
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz",
"integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==",
- "dev": true,
"engines": {
"node": ">=6.9.0"
}
@@ -311,7 +312,6 @@
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz",
"integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==",
- "dev": true,
"dependencies": {
"@babel/helper-validator-identifier": "^7.24.7",
"chalk": "^2.4.2",
@@ -326,7 +326,6 @@
"version": "7.24.8",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.8.tgz",
"integrity": "sha512-WzfbgXOkGzZiXXCqk43kKwZjzwx4oulxZi3nq2TYL9mOjQv6kYwul9mz6ID36njuL7Xkp6nJEfok848Zj10j/w==",
- "dev": true,
"bin": {
"parser": "bin/babel-parser.js"
},
@@ -365,12 +364,10 @@
}
},
"node_modules/@babel/runtime": {
- "version": "7.24.8",
- "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.8.tgz",
- "integrity": "sha512-5F7SDGs1T72ZczbRwbGO9lQi0NLjQxzl6i4lJxLxfW9U5UluCSyEJeniWvnhl3/euNiqQVbo8zruhsDfid0esA==",
- "dependencies": {
- "regenerator-runtime": "^0.14.0"
- },
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
+ "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
+ "license": "MIT",
"engines": {
"node": ">=6.9.0"
}
@@ -379,7 +376,6 @@
"version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.7.tgz",
"integrity": "sha512-jYqfPrU9JTF0PmPy1tLYHW4Mp4KlgxJD9l2nP9fD6yT/ICi554DmrWBAEYpIelzjHf1msDP3PxJIRt/nFNfBig==",
- "dev": true,
"dependencies": {
"@babel/code-frame": "^7.24.7",
"@babel/parser": "^7.24.7",
@@ -393,7 +389,6 @@
"version": "7.24.8",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.8.tgz",
"integrity": "sha512-t0P1xxAPzEDcEPmjprAQq19NWum4K0EQPjMwZQZbHt+GiZqvjCHjj755Weq1YRPVzBI+3zSfvScfpnuIecVFJQ==",
- "dev": true,
"dependencies": {
"@babel/code-frame": "^7.24.7",
"@babel/generator": "^7.24.8",
@@ -414,7 +409,6 @@
"version": "7.24.9",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.9.tgz",
"integrity": "sha512-xm8XrMKz0IlUdocVbYJe0Z9xEgidU7msskG8BbhnTPK/HZ2z/7FP7ykqPgrUH+C+r414mNfNWam1f2vqOjqjYQ==",
- "dev": true,
"dependencies": {
"@babel/helper-string-parser": "^7.24.8",
"@babel/helper-validator-identifier": "^7.24.7",
@@ -596,6 +590,138 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/@emotion/babel-plugin": {
+ "version": "11.13.5",
+ "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
+ "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.16.7",
+ "@babel/runtime": "^7.18.3",
+ "@emotion/hash": "^0.9.2",
+ "@emotion/memoize": "^0.9.0",
+ "@emotion/serialize": "^1.3.3",
+ "babel-plugin-macros": "^3.1.0",
+ "convert-source-map": "^1.5.0",
+ "escape-string-regexp": "^4.0.0",
+ "find-root": "^1.1.0",
+ "source-map": "^0.5.7",
+ "stylis": "4.2.0"
+ }
+ },
+ "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": {
+ "version": "1.9.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
+ "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
+ "license": "MIT"
+ },
+ "node_modules/@emotion/babel-plugin/node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@emotion/cache": {
+ "version": "11.14.0",
+ "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz",
+ "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==",
+ "license": "MIT",
+ "dependencies": {
+ "@emotion/memoize": "^0.9.0",
+ "@emotion/sheet": "^1.4.0",
+ "@emotion/utils": "^1.4.2",
+ "@emotion/weak-memoize": "^0.4.0",
+ "stylis": "4.2.0"
+ }
+ },
+ "node_modules/@emotion/hash": {
+ "version": "0.9.2",
+ "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
+ "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
+ "license": "MIT"
+ },
+ "node_modules/@emotion/memoize": {
+ "version": "0.9.0",
+ "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz",
+ "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==",
+ "license": "MIT"
+ },
+ "node_modules/@emotion/react": {
+ "version": "11.14.0",
+ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
+ "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.18.3",
+ "@emotion/babel-plugin": "^11.13.5",
+ "@emotion/cache": "^11.14.0",
+ "@emotion/serialize": "^1.3.3",
+ "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0",
+ "@emotion/utils": "^1.4.2",
+ "@emotion/weak-memoize": "^0.4.0",
+ "hoist-non-react-statics": "^3.3.1"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@emotion/serialize": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz",
+ "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==",
+ "license": "MIT",
+ "dependencies": {
+ "@emotion/hash": "^0.9.2",
+ "@emotion/memoize": "^0.9.0",
+ "@emotion/unitless": "^0.10.0",
+ "@emotion/utils": "^1.4.2",
+ "csstype": "^3.0.2"
+ }
+ },
+ "node_modules/@emotion/sheet": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz",
+ "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==",
+ "license": "MIT"
+ },
+ "node_modules/@emotion/unitless": {
+ "version": "0.10.0",
+ "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz",
+ "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==",
+ "license": "MIT"
+ },
+ "node_modules/@emotion/use-insertion-effect-with-fallbacks": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz",
+ "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@emotion/utils": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz",
+ "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==",
+ "license": "MIT"
+ },
+ "node_modules/@emotion/weak-memoize": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz",
+ "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==",
+ "license": "MIT"
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -1069,6 +1195,31 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
+ "node_modules/@floating-ui/core": {
+ "version": "1.7.5",
+ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
+ "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/utils": "^0.2.11"
+ }
+ },
+ "node_modules/@floating-ui/dom": {
+ "version": "1.7.6",
+ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
+ "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/core": "^1.7.5",
+ "@floating-ui/utils": "^0.2.11"
+ }
+ },
+ "node_modules/@floating-ui/utils": {
+ "version": "0.2.11",
+ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
+ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
+ "license": "MIT"
+ },
"node_modules/@fortawesome/fontawesome-free": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.5.2.tgz",
@@ -1139,7 +1290,6 @@
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
"integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==",
- "dev": true,
"dependencies": {
"@jridgewell/set-array": "^1.2.1",
"@jridgewell/sourcemap-codec": "^1.4.10",
@@ -1153,7 +1303,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
- "dev": true,
"engines": {
"node": ">=6.0.0"
}
@@ -1162,7 +1311,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
"integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
- "dev": true,
"engines": {
"node": ">=6.0.0"
}
@@ -1170,24 +1318,23 @@
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
- "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
- "dev": true
+ "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.25",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
"integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
- "dev": true,
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@mate-academy/scripts": {
- "version": "1.8.5",
- "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.8.5.tgz",
- "integrity": "sha512-mHRY2FkuoYCf5U0ahIukkaRo5LSZsxrTSgMJheFoyf3VXsTvfM9OfWcZIDIDB521kdPrScHHnRp+JRNjCfUO5A==",
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-2.1.3.tgz",
+ "integrity": "sha512-a07wHTj/1QUK2Aac5zHad+sGw4rIvcNl5lJmJpAD7OxeSbnCdyI6RXUHwXhjF5MaVo9YHrJ0xVahyERS2IIyBQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@octokit/rest": "^17.11.2",
"@types/get-port": "^4.2.0",
@@ -1875,6 +2022,32 @@
"url": "https://opencollective.com/unts"
}
},
+ "node_modules/@reduxjs/toolkit": {
+ "version": "2.11.2",
+ "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
+ "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "@standard-schema/utils": "^0.3.0",
+ "immer": "^11.0.0",
+ "redux": "^5.0.1",
+ "redux-thunk": "^3.1.0",
+ "reselect": "^5.1.0"
+ },
+ "peerDependencies": {
+ "react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
+ "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-redux": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@remix-run/router": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.18.0.tgz",
@@ -2126,6 +2299,18 @@
"integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==",
"dev": true
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/utils": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
+ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
+ "license": "MIT"
+ },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -2189,6 +2374,23 @@
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"dev": true
},
+ "node_modules/@types/lodash": {
+ "version": "4.17.24",
+ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
+ "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/lodash.debounce": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/lodash.debounce/-/lodash.debounce-4.0.9.tgz",
+ "integrity": "sha512-Ma5JcgTREwpLRwMM+XwBR7DaWe96nC38uCBDFKZWbNKD+osjVzdpnUSwBcqCptrp16sSOLBAUb50Car5I0TCsQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/lodash": "*"
+ }
+ },
"node_modules/@types/minimist": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz",
@@ -2212,17 +2414,21 @@
"dev": true,
"peer": true
},
+ "node_modules/@types/parse-json": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
+ "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
+ "license": "MIT"
+ },
"node_modules/@types/prop-types": {
"version": "15.7.12",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz",
- "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==",
- "dev": true
+ "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q=="
},
"node_modules/@types/react": {
"version": "18.3.3",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz",
"integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==",
- "dev": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -2241,7 +2447,6 @@
"version": "4.4.10",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz",
"integrity": "sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q==",
- "dev": true,
"dependencies": {
"@types/react": "*"
}
@@ -2258,6 +2463,12 @@
"integrity": "sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg==",
"dev": true
},
+ "node_modules/@types/use-sync-external-store": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
+ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
+ "license": "MIT"
+ },
"node_modules/@types/yauzl": {
"version": "2.10.3",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
@@ -2568,7 +2779,6 @@
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
- "dev": true,
"dependencies": {
"color-convert": "^1.9.0"
},
@@ -2916,6 +3126,37 @@
"deep-equal": "^2.0.5"
}
},
+ "node_modules/babel-plugin-macros": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
+ "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5",
+ "cosmiconfig": "^7.0.0",
+ "resolve": "^1.19.0"
+ },
+ "engines": {
+ "node": ">=10",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/babel-plugin-macros/node_modules/cosmiconfig": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
+ "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/parse-json": "^4.0.0",
+ "import-fresh": "^3.2.1",
+ "parse-json": "^5.0.0",
+ "path-type": "^4.0.0",
+ "yaml": "^1.10.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -3112,7 +3353,6 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
- "dev": true,
"engines": {
"node": ">=6"
}
@@ -3192,7 +3432,6 @@
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
- "dev": true,
"dependencies": {
"ansi-styles": "^3.2.1",
"escape-string-regexp": "^1.0.5",
@@ -3335,7 +3574,6 @@
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
- "dev": true,
"dependencies": {
"color-name": "1.1.3"
}
@@ -3343,8 +3581,7 @@
"node_modules/color-name": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
- "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
- "dev": true
+ "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="
},
"node_modules/colord": {
"version": "2.9.3",
@@ -3754,7 +3991,6 @@
"version": "4.3.5",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz",
"integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==",
- "dev": true,
"dependencies": {
"ms": "2.1.2"
},
@@ -4018,7 +4254,6 @@
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
"integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
- "dev": true,
"dependencies": {
"is-arrayish": "^0.2.1"
}
@@ -4258,7 +4493,6 @@
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
- "dev": true,
"engines": {
"node": ">=0.8.0"
}
@@ -5188,6 +5422,12 @@
"url": "https://github.com/avajs/find-cache-dir?sponsor=1"
}
},
+ "node_modules/find-root": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
+ "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==",
+ "license": "MIT"
+ },
"node_modules/find-up": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -5311,7 +5551,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
- "dev": true,
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
@@ -5667,7 +5906,6 @@
"version": "11.12.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
"integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
- "dev": true,
"engines": {
"node": ">=4"
}
@@ -5761,7 +5999,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
- "dev": true,
"engines": {
"node": ">=4"
}
@@ -5821,7 +6058,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
- "dev": true,
"dependencies": {
"function-bind": "^1.1.2"
},
@@ -5839,6 +6075,15 @@
"he": "bin/he"
}
},
+ "node_modules/hoist-non-react-statics": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
+ "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "react-is": "^16.7.0"
+ }
+ },
"node_modules/hosted-git-info": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
@@ -5872,6 +6117,15 @@
"dev": true,
"peer": true
},
+ "node_modules/html-parse-stringify": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
+ "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
+ "license": "MIT",
+ "dependencies": {
+ "void-elements": "3.1.0"
+ }
+ },
"node_modules/html-tags": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz",
@@ -5907,6 +6161,34 @@
"node": ">=8.12.0"
}
},
+ "node_modules/i18next": {
+ "version": "26.0.8",
+ "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.8.tgz",
+ "integrity": "sha512-BRzLom0mhDhV9v0QhgUUHWQJuwFmnr1194xEcNLYD6ym8y8s542n4jXUvRLnhNTbh9PmpU6kGZamyuGHQMsGjw==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://www.locize.com/i18next"
+ },
+ {
+ "type": "individual",
+ "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
+ },
+ {
+ "type": "individual",
+ "url": "https://www.locize.com"
+ }
+ ],
+ "license": "MIT",
+ "peerDependencies": {
+ "typescript": "^5 || ^6"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -5936,6 +6218,16 @@
"node": ">= 4"
}
},
+ "node_modules/immer": {
+ "version": "11.1.4",
+ "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
+ "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/immer"
+ }
+ },
"node_modules/immutable": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.6.tgz",
@@ -5946,7 +6238,6 @@
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
"integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
- "dev": true,
"dependencies": {
"parent-module": "^1.0.0",
"resolve-from": "^4.0.0"
@@ -6061,8 +6352,7 @@
"node_modules/is-arrayish": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
- "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
- "dev": true
+ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="
},
"node_modules/is-async-function": {
"version": "2.0.0",
@@ -6147,7 +6437,6 @@
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.14.0.tgz",
"integrity": "sha512-a5dFJih5ZLYlRtDc0dZWP7RiKr6xIKzmn/oAYCDvdLThadVgyJwlaoQPmRtMSpz+rk0OGAgIu+TcM9HUF0fk1A==",
- "dev": true,
"dependencies": {
"hasown": "^2.0.2"
},
@@ -6580,7 +6869,6 @@
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
"integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==",
- "dev": true,
"bin": {
"jsesc": "bin/jsesc"
},
@@ -6597,8 +6885,7 @@
"node_modules/json-parse-even-better-errors": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
- "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
- "dev": true
+ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="
},
"node_modules/json-schema": {
"version": "0.4.0",
@@ -6751,8 +7038,7 @@
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
- "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
- "dev": true
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="
},
"node_modules/listr2": {
"version": "3.14.0",
@@ -6802,6 +7088,12 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"dev": true
},
+ "node_modules/lodash.debounce": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
+ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
+ "license": "MIT"
+ },
"node_modules/lodash.get": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
@@ -7103,6 +7395,12 @@
"integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==",
"dev": true
},
+ "node_modules/memoize-one": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
+ "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
+ "license": "MIT"
+ },
"node_modules/meow": {
"version": "13.2.0",
"resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz",
@@ -7830,8 +8128,7 @@
"node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
- "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
- "dev": true
+ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"node_modules/nanoid": {
"version": "3.3.7",
@@ -7927,6 +8224,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/normalize-scss": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-scss/-/normalize-scss-8.0.0.tgz",
+ "integrity": "sha512-C6GXIxQ2LOYWrde27xWbONavmybobxp+V6TY8BiBJw5M+yMNEg2R0WjaeDtmP5JsunFYKvFOvgMAIC0/OxZuJQ==",
+ "license": "(MIT OR GPL-2.0)"
+ },
"node_modules/npm-run-path": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
@@ -8217,7 +8520,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
"integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
- "dev": true,
"dependencies": {
"callsites": "^3.0.0"
},
@@ -8229,7 +8531,6 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
"integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
- "dev": true,
"dependencies": {
"@babel/code-frame": "^7.0.0",
"error-ex": "^1.3.1",
@@ -8273,8 +8574,7 @@
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
- "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
- "dev": true
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
},
"node_modules/path-to-regexp": {
"version": "1.8.0",
@@ -8295,7 +8595,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
- "dev": true,
"engines": {
"node": ">=8"
}
@@ -8315,8 +8614,7 @@
"node_modules/picocolors": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz",
- "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==",
- "dev": true
+ "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew=="
},
"node_modules/picomatch": {
"version": "2.3.1",
@@ -8729,11 +9027,61 @@
"react": "^18.3.1"
}
},
+ "node_modules/react-i18next": {
+ "version": "17.0.6",
+ "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.6.tgz",
+ "integrity": "sha512-WzJ6SMKF+GTD7JZZqxSR1AKKmXjaSu39sClUrNlwxS4Tl7a99O+ltFy6yhPMO+wgZuxpQjJ2PZkfrQKmAqrLhw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.29.2",
+ "html-parse-stringify": "^3.0.1",
+ "use-sync-external-store": "^1.6.0"
+ },
+ "peerDependencies": {
+ "i18next": ">= 26.0.1",
+ "react": ">= 16.8.0",
+ "typescript": "^5 || ^6"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ },
+ "react-native": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
+ "node_modules/react-redux": {
+ "version": "9.2.0",
+ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
+ "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/use-sync-external-store": "^0.0.6",
+ "use-sync-external-store": "^1.4.0"
+ },
+ "peerDependencies": {
+ "@types/react": "^18.2.25 || ^19",
+ "react": "^18.0 || ^19",
+ "redux": "^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "redux": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-refresh": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
@@ -8773,6 +9121,27 @@
"react-dom": ">=16.8"
}
},
+ "node_modules/react-select": {
+ "version": "5.10.2",
+ "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.10.2.tgz",
+ "integrity": "sha512-Z33nHdEFWq9tfnfVXaiM12rbJmk+QjFEztWLtmXqQhz6Al4UZZ9xc0wiatmGtUOCCnHN0WizL3tCMYRENX4rVQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.0",
+ "@emotion/cache": "^11.4.0",
+ "@emotion/react": "^11.8.1",
+ "@floating-ui/dom": "^1.0.1",
+ "@types/react-transition-group": "^4.4.0",
+ "memoize-one": "^6.0.0",
+ "prop-types": "^15.6.0",
+ "react-transition-group": "^4.3.0",
+ "use-isomorphic-layout-effect": "^1.2.0"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
@@ -8893,6 +9262,21 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/redux": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
+ "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
+ "license": "MIT"
+ },
+ "node_modules/redux-thunk": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
+ "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "redux": "^5.0.0"
+ }
+ },
"node_modules/reflect.getprototypeof": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.6.tgz",
@@ -8914,11 +9298,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/regenerator-runtime": {
- "version": "0.14.1",
- "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
- "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
- },
"node_modules/regexp.prototype.flags": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz",
@@ -8976,11 +9355,16 @@
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"dev": true
},
+ "node_modules/reselect": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
+ "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
+ "license": "MIT"
+ },
"node_modules/resolve": {
"version": "1.22.8",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
"integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==",
- "dev": true,
"dependencies": {
"is-core-module": "^2.13.0",
"path-parse": "^1.0.7",
@@ -8997,7 +9381,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
- "dev": true,
"engines": {
"node": ">=4"
}
@@ -9453,6 +9836,25 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true
},
+ "node_modules/sonner": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
+ "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
+ "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
+ }
+ },
+ "node_modules/source-map": {
+ "version": "0.5.7",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+ "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/source-map-js": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
@@ -9866,11 +10268,16 @@
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
+ "node_modules/stylis": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
+ "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==",
+ "license": "MIT"
+ },
"node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
- "dev": true,
"dependencies": {
"has-flag": "^3.0.0"
},
@@ -9916,7 +10323,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
- "dev": true,
"engines": {
"node": ">= 0.4"
},
@@ -9930,6 +10336,25 @@
"integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==",
"dev": true
},
+ "node_modules/swiper": {
+ "version": "12.1.3",
+ "resolved": "https://registry.npmjs.org/swiper/-/swiper-12.1.3.tgz",
+ "integrity": "sha512-XcWlVmkHFICI4fuoJKgbp8PscDcS4i7pBH8nwJRBi3dpQvhCySwsWRYm4bOf/BzKVWkHOYaFw7qz9uBSrY3oug==",
+ "funding": [
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/swiperjs"
+ },
+ {
+ "type": "open_collective",
+ "url": "http://opencollective.com/swiper"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4.7.0"
+ }
+ },
"node_modules/synckit": {
"version": "0.8.8",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.8.tgz",
@@ -10083,7 +10508,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
"integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==",
- "dev": true,
"engines": {
"node": ">=4"
}
@@ -10334,7 +10758,7 @@
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz",
"integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==",
- "dev": true,
+ "devOptional": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -10438,6 +10862,29 @@
"requires-port": "^1.0.0"
}
},
+ "node_modules/use-isomorphic-layout-effect": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
+ "integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -10542,6 +10989,15 @@
}
}
},
+ "node_modules/void-elements": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
+ "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
@@ -10903,6 +11359,15 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true
},
+ "node_modules/yaml": {
+ "version": "1.10.3",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
+ "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/yargs": {
"version": "16.2.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
diff --git a/package.json b/package.json
index ae251685c8b..7ebc25149c4 100644
--- a/package.json
+++ b/package.json
@@ -7,18 +7,28 @@
"license": "GPL-3.0",
"dependencies": {
"@fortawesome/fontawesome-free": "^6.5.2",
+ "@reduxjs/toolkit": "^2.11.2",
"bulma": "^1.0.1",
"classnames": "^2.5.1",
+ "i18next": "^26.0.8",
+ "lodash.debounce": "^4.0.8",
+ "normalize-scss": "^8.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+ "react-i18next": "^17.0.6",
+ "react-redux": "^9.2.0",
"react-router-dom": "^6.25.1",
- "react-transition-group": "^4.4.5"
+ "react-select": "^5.10.2",
+ "react-transition-group": "^4.4.5",
+ "sonner": "^2.0.7",
+ "swiper": "^12.1.3"
},
"devDependencies": {
"@cypress/react18": "^2.0.1",
- "@mate-academy/scripts": "^1.8.5",
+ "@mate-academy/scripts": "^2.1.3",
"@mate-academy/students-ts-config": "*",
"@mate-academy/stylelint-config": "*",
+ "@types/lodash.debounce": "^4.0.9",
"@types/node": "^20.14.10",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
diff --git a/public/fonts/Mont-Bold.ttf b/public/fonts/Mont-Bold.ttf
new file mode 100644
index 00000000000..878bdbf546a
Binary files /dev/null and b/public/fonts/Mont-Bold.ttf differ
diff --git a/public/fonts/Mont-Regular.ttf b/public/fonts/Mont-Regular.ttf
new file mode 100644
index 00000000000..2d6baf78644
Binary files /dev/null and b/public/fonts/Mont-Regular.ttf differ
diff --git a/public/fonts/Mont-SemiBold.ttf b/public/fonts/Mont-SemiBold.ttf
new file mode 100644
index 00000000000..034e305c1ef
Binary files /dev/null and b/public/fonts/Mont-SemiBold.ttf differ
diff --git a/public/icons/favicon.svg b/public/icons/favicon.svg
new file mode 100644
index 00000000000..b677274af9a
--- /dev/null
+++ b/public/icons/favicon.svg
@@ -0,0 +1,5 @@
+
+
+ N
+
+
\ No newline at end of file
diff --git a/public/icons/logo-icon.svg b/public/icons/logo-icon.svg
new file mode 100644
index 00000000000..7ad0b0efc03
--- /dev/null
+++ b/public/icons/logo-icon.svg
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/images/banner-slider-1-desktop.png b/public/images/banner-slider-1-desktop.png
new file mode 100644
index 00000000000..2612c351c69
Binary files /dev/null and b/public/images/banner-slider-1-desktop.png differ
diff --git a/public/images/banner-slider-1-mobile.png b/public/images/banner-slider-1-mobile.png
new file mode 100644
index 00000000000..72ddca192a8
Binary files /dev/null and b/public/images/banner-slider-1-mobile.png differ
diff --git a/public/images/banner-slider-2-desktop.png b/public/images/banner-slider-2-desktop.png
new file mode 100644
index 00000000000..ab306fdd399
Binary files /dev/null and b/public/images/banner-slider-2-desktop.png differ
diff --git a/public/images/banner-slider-2-mobile.png b/public/images/banner-slider-2-mobile.png
new file mode 100644
index 00000000000..590f5733f89
Binary files /dev/null and b/public/images/banner-slider-2-mobile.png differ
diff --git a/public/images/banner-slider-3-desktop.png b/public/images/banner-slider-3-desktop.png
new file mode 100644
index 00000000000..c8e2b5926e6
Binary files /dev/null and b/public/images/banner-slider-3-desktop.png differ
diff --git a/public/images/banner-slider-3-mobile.png b/public/images/banner-slider-3-mobile.png
new file mode 100644
index 00000000000..054f5a1c54f
Binary files /dev/null and b/public/images/banner-slider-3-mobile.png differ
diff --git a/public/img/cart-is-empty.png b/public/images/cart-empty.png
similarity index 100%
rename from public/img/cart-is-empty.png
rename to public/images/cart-empty.png
diff --git a/public/images/category-accessories-desktop.png b/public/images/category-accessories-desktop.png
new file mode 100644
index 00000000000..b9522af6d77
Binary files /dev/null and b/public/images/category-accessories-desktop.png differ
diff --git a/public/images/category-phones-desktop.png b/public/images/category-phones-desktop.png
new file mode 100644
index 00000000000..7292aeff4aa
Binary files /dev/null and b/public/images/category-phones-desktop.png differ
diff --git a/public/images/category-tablets-desktop.png b/public/images/category-tablets-desktop.png
new file mode 100644
index 00000000000..a327fcc7ce5
Binary files /dev/null and b/public/images/category-tablets-desktop.png differ
diff --git a/public/images/checkout-cart.png b/public/images/checkout-cart.png
new file mode 100644
index 00000000000..5d493ab5c1f
Binary files /dev/null and b/public/images/checkout-cart.png differ
diff --git a/public/images/error.png b/public/images/error.png
new file mode 100644
index 00000000000..2f200eaa24c
Binary files /dev/null and b/public/images/error.png differ
diff --git a/public/images/favourites-empty.png b/public/images/favourites-empty.png
new file mode 100644
index 00000000000..9a18cb9492e
Binary files /dev/null and b/public/images/favourites-empty.png differ
diff --git a/public/images/loading.png b/public/images/loading.png
new file mode 100644
index 00000000000..32d2003ef35
Binary files /dev/null and b/public/images/loading.png differ
diff --git a/public/img/page-not-found.png b/public/images/page-not-found.png
similarity index 100%
rename from public/img/page-not-found.png
rename to public/images/page-not-found.png
diff --git a/public/img/product-not-found.png b/public/images/product-not-found.png
similarity index 100%
rename from public/img/product-not-found.png
rename to public/images/product-not-found.png
diff --git a/public/img/banner-accessories.png b/public/img/banner-accessories.png
deleted file mode 100644
index ba41c4e8f0d..00000000000
Binary files a/public/img/banner-accessories.png and /dev/null differ
diff --git a/public/img/banner-phones.png b/public/img/banner-phones.png
deleted file mode 100644
index c8fea5b6ee9..00000000000
Binary files a/public/img/banner-phones.png and /dev/null differ
diff --git a/public/img/banner-tablets.png b/public/img/banner-tablets.png
deleted file mode 100644
index d8079734bcd..00000000000
Binary files a/public/img/banner-tablets.png and /dev/null differ
diff --git a/public/img/category-accessories.png b/public/img/category-accessories.png
deleted file mode 100644
index 67c5bfdb35b..00000000000
Binary files a/public/img/category-accessories.png and /dev/null differ
diff --git a/public/img/category-accessories.webp b/public/img/category-accessories.webp
deleted file mode 100644
index a9aae0a5e22..00000000000
Binary files a/public/img/category-accessories.webp and /dev/null differ
diff --git a/public/img/category-phones.png b/public/img/category-phones.png
deleted file mode 100644
index fd7616042f2..00000000000
Binary files a/public/img/category-phones.png and /dev/null differ
diff --git a/public/img/category-phones.webp b/public/img/category-phones.webp
deleted file mode 100644
index 5ba1b294b1d..00000000000
Binary files a/public/img/category-phones.webp and /dev/null differ
diff --git a/public/img/category-tablets.png b/public/img/category-tablets.png
deleted file mode 100644
index 57e33c5807e..00000000000
Binary files a/public/img/category-tablets.png and /dev/null differ
diff --git a/public/img/category-tablets.webp b/public/img/category-tablets.webp
deleted file mode 100644
index f4a267bfcb9..00000000000
Binary files a/public/img/category-tablets.webp and /dev/null differ
diff --git a/public/img/picthree.bdd2e0fc.png b/public/img/picthree.bdd2e0fc.png
deleted file mode 100644
index 28b5c4c99a6..00000000000
Binary files a/public/img/picthree.bdd2e0fc.png and /dev/null differ
diff --git a/src/App.tsx b/src/App.tsx
index 372e4b42066..1c5bfddfa5d 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,7 +1,26 @@
+import React from 'react';
+import { Provider } from 'react-redux';
+
+// @ts-expect-error why this goes crazy
+import 'swiper/css';
+// @ts-expect-error why this goes crazy
+import 'swiper/css/navigation';
+// @ts-expect-error why this goes crazy
+import 'swiper/css/pagination';
+
+import './styles/index.scss';
+import { store } from './store';
+import { Router } from './Router';
+import './i18n/i18n';
+
import './App.scss';
-export const App = () => (
-
-
Product Catalog
-
-);
+const App: React.FC = () => {
+ return (
+
+
+
+ );
+};
+
+export default App;
diff --git a/src/Router/Router.tsx b/src/Router/Router.tsx
new file mode 100644
index 00000000000..6bd35b1b83d
--- /dev/null
+++ b/src/Router/Router.tsx
@@ -0,0 +1,33 @@
+import React from 'react';
+import { HashRouter, Route, Routes } from 'react-router-dom';
+import { MainLayout } from '../modules/shared/templates/MainLayout/MainLayout';
+import { CatalogPage } from '../modules/CatalogPage';
+import { HomePage } from '../modules/HomePage';
+import { FavouritesPage } from '../modules/FavouritesPage';
+import { CartPage } from '../modules/CartPage';
+import { NotFoundPage } from '../modules/NotFoundPage';
+import { ProductPage } from '../modules/ProductPage';
+import { ScrollToTop } from './ScrollToTop';
+
+export const Router: React.FC = () => (
+
+
+
+
+ }>
+ } />
+ } />
+ } />
+ }
+ />
+ } />
+ } />
+ } />
+
+ } />
+
+
+
+);
diff --git a/src/Router/ScrollToTop.tsx b/src/Router/ScrollToTop.tsx
new file mode 100644
index 00000000000..04d8447b611
--- /dev/null
+++ b/src/Router/ScrollToTop.tsx
@@ -0,0 +1,15 @@
+import { useEffect } from 'react';
+import { useLocation } from 'react-router-dom';
+
+export const ScrollToTop = () => {
+ const { pathname } = useLocation();
+
+ useEffect(() => {
+ window.scrollTo({
+ top: 0,
+ behavior: 'smooth',
+ });
+ }, [pathname]);
+
+ return null;
+};
diff --git a/src/Router/index.tsx b/src/Router/index.tsx
new file mode 100644
index 00000000000..44f7dc8c32f
--- /dev/null
+++ b/src/Router/index.tsx
@@ -0,0 +1 @@
+export * from './Router';
diff --git a/src/assets/icons/arrow-icon.tsx b/src/assets/icons/arrow-icon.tsx
new file mode 100644
index 00000000000..3342b79111c
--- /dev/null
+++ b/src/assets/icons/arrow-icon.tsx
@@ -0,0 +1,19 @@
+/* eslint-disable max-len */
+import React from 'react';
+
+export const ArrowIcon = () => (
+
+
+
+);
diff --git a/src/assets/icons/close-icon.tsx b/src/assets/icons/close-icon.tsx
new file mode 100644
index 00000000000..dbd6355effb
--- /dev/null
+++ b/src/assets/icons/close-icon.tsx
@@ -0,0 +1,18 @@
+/* eslint-disable max-len */
+import React from 'react';
+
+export const CloseIcon = () => (
+
+
+
+);
diff --git a/src/assets/icons/gear-icon.tsx b/src/assets/icons/gear-icon.tsx
new file mode 100644
index 00000000000..fa49107b6c8
--- /dev/null
+++ b/src/assets/icons/gear-icon.tsx
@@ -0,0 +1,25 @@
+/* eslint-disable max-len */
+import React from 'react';
+
+export const GearIcon = () => (
+
+
+
+
+);
diff --git a/src/assets/icons/header-logo-icon.tsx b/src/assets/icons/header-logo-icon.tsx
new file mode 100644
index 00000000000..8ac4dcd3343
--- /dev/null
+++ b/src/assets/icons/header-logo-icon.tsx
@@ -0,0 +1,90 @@
+/* eslint-disable max-len */
+import React from 'react';
+
+type Props = React.SVGProps;
+
+export const HeaderLogo: React.FC = ({ ...props }) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/src/assets/icons/heart-counter-icon.tsx b/src/assets/icons/heart-counter-icon.tsx
new file mode 100644
index 00000000000..5ac8e09697e
--- /dev/null
+++ b/src/assets/icons/heart-counter-icon.tsx
@@ -0,0 +1,19 @@
+/* eslint-disable max-len */
+import React from 'react';
+
+export const HeartCounterIcon = () => (
+
+
+
+
+);
diff --git a/src/assets/icons/heart-filled-icon.tsx b/src/assets/icons/heart-filled-icon.tsx
new file mode 100644
index 00000000000..e5c2d3755f4
--- /dev/null
+++ b/src/assets/icons/heart-filled-icon.tsx
@@ -0,0 +1,18 @@
+/* eslint-disable max-len */
+import React from 'react';
+
+export const HeartFilledIcon = () => (
+
+
+
+);
diff --git a/src/assets/icons/heart-icon.tsx b/src/assets/icons/heart-icon.tsx
new file mode 100644
index 00000000000..1f79ef7f2d8
--- /dev/null
+++ b/src/assets/icons/heart-icon.tsx
@@ -0,0 +1,18 @@
+import React from 'react';
+
+export const HeartIcon = () => (
+
+
+
+);
diff --git a/src/assets/icons/home-icon.tsx b/src/assets/icons/home-icon.tsx
new file mode 100644
index 00000000000..d4bf88b0e30
--- /dev/null
+++ b/src/assets/icons/home-icon.tsx
@@ -0,0 +1,24 @@
+/* eslint-disable max-len */
+import React from 'react';
+
+export const HomeIcon = () => (
+
+
+
+
+);
diff --git a/src/assets/icons/menu-icon.tsx b/src/assets/icons/menu-icon.tsx
new file mode 100644
index 00000000000..aea9a348b78
--- /dev/null
+++ b/src/assets/icons/menu-icon.tsx
@@ -0,0 +1,24 @@
+/* eslint-disable max-len */
+import React from 'react';
+
+export const MenuIcon = () => (
+
+
+
+
+
+);
diff --git a/src/assets/icons/minus-icon.tsx b/src/assets/icons/minus-icon.tsx
new file mode 100644
index 00000000000..a2fab7e1e17
--- /dev/null
+++ b/src/assets/icons/minus-icon.tsx
@@ -0,0 +1,18 @@
+/* eslint-disable max-len */
+import React from 'react';
+
+export const MinusIcon = () => (
+
+
+
+);
diff --git a/src/assets/icons/moon-icon.tsx b/src/assets/icons/moon-icon.tsx
new file mode 100644
index 00000000000..ef7c26e4542
--- /dev/null
+++ b/src/assets/icons/moon-icon.tsx
@@ -0,0 +1,14 @@
+/* eslint-disable max-len */
+import React from 'react';
+
+export const MoonIcon = () => (
+
+
+
+);
diff --git a/src/assets/icons/plus-icon.tsx b/src/assets/icons/plus-icon.tsx
new file mode 100644
index 00000000000..8f55cd24942
--- /dev/null
+++ b/src/assets/icons/plus-icon.tsx
@@ -0,0 +1,18 @@
+/* eslint-disable max-len */
+import React from 'react';
+
+export const PlusIcon = () => (
+
+
+
+);
diff --git a/src/assets/icons/search-icon.tsx b/src/assets/icons/search-icon.tsx
new file mode 100644
index 00000000000..4da6908a90d
--- /dev/null
+++ b/src/assets/icons/search-icon.tsx
@@ -0,0 +1,18 @@
+/* eslint-disable max-len */
+import React from 'react';
+
+export const SearchIcon = () => (
+
+
+
+);
diff --git a/src/assets/icons/shopping-bag-counter-icon.tsx b/src/assets/icons/shopping-bag-counter-icon.tsx
new file mode 100644
index 00000000000..d0ab7141672
--- /dev/null
+++ b/src/assets/icons/shopping-bag-counter-icon.tsx
@@ -0,0 +1,32 @@
+/* eslint-disable max-len */
+import React from 'react';
+
+export const ShoppingBagCounterIcon = () => (
+
+
+
+
+
+
+);
diff --git a/src/assets/icons/shopping-bag-icon.tsx b/src/assets/icons/shopping-bag-icon.tsx
new file mode 100644
index 00000000000..417c13fd673
--- /dev/null
+++ b/src/assets/icons/shopping-bag-icon.tsx
@@ -0,0 +1,30 @@
+/* eslint-disable max-len */
+import React from 'react';
+
+export const ShoppingBagIcon = () => (
+
+
+
+
+
+);
diff --git a/src/assets/icons/sun-icon.tsx b/src/assets/icons/sun-icon.tsx
new file mode 100644
index 00000000000..b5c6715711e
--- /dev/null
+++ b/src/assets/icons/sun-icon.tsx
@@ -0,0 +1,21 @@
+/* eslint-disable max-len */
+import React from 'react';
+
+export const SunIcon = () => (
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/src/constants/navigation.ts b/src/constants/navigation.ts
new file mode 100644
index 00000000000..0971840d38c
--- /dev/null
+++ b/src/constants/navigation.ts
@@ -0,0 +1,6 @@
+export const NAV_LINKS = [
+ { label: 'Home', path: '/' },
+ { label: 'Phones', path: '/phones' },
+ { label: 'Tablets', path: '/tablets' },
+ { label: 'Accessories', path: '/accessories' },
+];
diff --git a/src/enums/Category.ts b/src/enums/Category.ts
new file mode 100644
index 00000000000..a3ec98b8068
--- /dev/null
+++ b/src/enums/Category.ts
@@ -0,0 +1,5 @@
+export enum Category {
+ Phones = 'phones',
+ Tablets = 'tablets',
+ Accessories = 'accessories',
+}
diff --git a/src/enums/DefaultValues.ts b/src/enums/DefaultValues.ts
new file mode 100644
index 00000000000..7205145d635
--- /dev/null
+++ b/src/enums/DefaultValues.ts
@@ -0,0 +1,9 @@
+import { ItemPerPage } from './ItemsPerPage';
+import { SortBy } from './SortBy';
+
+export enum DefaultValues {
+ Sort = SortBy.Newest,
+ PerPage = ItemPerPage.Eight,
+ Page = '1',
+ Query = '',
+}
diff --git a/src/enums/ItemsPerPage.ts b/src/enums/ItemsPerPage.ts
new file mode 100644
index 00000000000..9497582db71
--- /dev/null
+++ b/src/enums/ItemsPerPage.ts
@@ -0,0 +1,6 @@
+export enum ItemPerPage {
+ Four = '4',
+ Eight = '8',
+ Sixteen = '16',
+ All = 'All',
+}
diff --git a/src/enums/Language.ts b/src/enums/Language.ts
new file mode 100644
index 00000000000..648b6a03429
--- /dev/null
+++ b/src/enums/Language.ts
@@ -0,0 +1,4 @@
+export enum Language {
+ EN = 'en',
+ UA = 'ua',
+}
diff --git a/src/enums/SearchFields.ts b/src/enums/SearchFields.ts
new file mode 100644
index 00000000000..01c0044176f
--- /dev/null
+++ b/src/enums/SearchFields.ts
@@ -0,0 +1,6 @@
+export enum SearchParam {
+ Sort = 'sort',
+ PerPage = 'onpage',
+ Query = 'query',
+ Page = 'page',
+}
diff --git a/src/enums/SortBy.ts b/src/enums/SortBy.ts
new file mode 100644
index 00000000000..411787336ac
--- /dev/null
+++ b/src/enums/SortBy.ts
@@ -0,0 +1,5 @@
+export enum SortBy {
+ Newest = 'Newest',
+ Alpha = 'Alphabetically',
+ Cheap = 'Cheapest',
+}
diff --git a/src/enums/Theme.ts b/src/enums/Theme.ts
new file mode 100644
index 00000000000..9fed176633c
--- /dev/null
+++ b/src/enums/Theme.ts
@@ -0,0 +1,4 @@
+export enum Theme {
+ Dark = 'dark',
+ Light = 'light',
+}
diff --git a/src/enums/htmlDataAttribs.ts b/src/enums/htmlDataAttribs.ts
new file mode 100644
index 00000000000..15e7eb4ab83
--- /dev/null
+++ b/src/enums/htmlDataAttribs.ts
@@ -0,0 +1,6 @@
+export enum HTMLDataAttr {
+ Modal = 'modal-open',
+ Menu = 'menu-open',
+ Theme = 'theme',
+ Settings = 'settings-open',
+}
diff --git a/src/enums/localStorageKey.ts b/src/enums/localStorageKey.ts
new file mode 100644
index 00000000000..a0330af6040
--- /dev/null
+++ b/src/enums/localStorageKey.ts
@@ -0,0 +1,4 @@
+export enum LocalStorageKey {
+ favourites = 'favourites',
+ cart = 'cart',
+}
diff --git a/src/features/accessoriesSlice.ts b/src/features/accessoriesSlice.ts
new file mode 100644
index 00000000000..71ea782bd49
--- /dev/null
+++ b/src/features/accessoriesSlice.ts
@@ -0,0 +1,44 @@
+/* eslint-disable no-param-reassign */
+import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
+import { fetchData } from '../helpers/fetchData';
+import { ProductDetails } from '../types/ProductDetails';
+
+type AccessoriesState = {
+ productList: ProductDetails[];
+ loading: boolean;
+ error: boolean;
+};
+
+const initialState: AccessoriesState = {
+ productList: [],
+ loading: false,
+ error: false,
+};
+
+export const init = createAsyncThunk('accessories/fetch', () => {
+ return fetchData('/api/accessories.json');
+});
+
+export const accessoriesSlice = createSlice({
+ name: 'accessories',
+ initialState,
+ reducers: {},
+ extraReducers: builder => {
+ builder.addCase(init.pending, state => {
+ state.loading = true;
+ });
+
+ builder.addCase(init.fulfilled, (state, action) => {
+ state.productList = action.payload;
+ state.loading = false;
+ state.error = false;
+ });
+
+ builder.addCase(init.rejected, state => {
+ state.loading = false;
+ state.error = true;
+ });
+ },
+});
+
+export default accessoriesSlice.reducer;
diff --git a/src/features/cartSlice.ts b/src/features/cartSlice.ts
new file mode 100644
index 00000000000..9348c419d36
--- /dev/null
+++ b/src/features/cartSlice.ts
@@ -0,0 +1,120 @@
+/* eslint-disable no-param-reassign */
+import {
+ createAsyncThunk,
+ createSelector,
+ createSlice,
+ PayloadAction,
+} from '@reduxjs/toolkit';
+import { Product } from '../types/Product';
+import {
+ loadFromLocalStorage,
+ saveToLocalStorage,
+} from '../helpers/handleLocalStorage';
+import { LocalStorageKey } from '../enums/localStorageKey';
+import { RootState } from '../store';
+
+type CartItem = Product & { quantity: number };
+
+type CartState = {
+ cartItems: CartItem[];
+ loading: boolean;
+ error: boolean;
+};
+
+const initialState: CartState = {
+ cartItems: [],
+ loading: false,
+ error: false,
+};
+
+export const init = createAsyncThunk('cart/init', async () => {
+ return loadFromLocalStorage(LocalStorageKey.cart);
+});
+
+export const cartSlice = createSlice({
+ name: 'cart',
+ initialState,
+ reducers: {
+ add: (state, action: PayloadAction) => {
+ const existingItem = state.cartItems.find(
+ item => item.itemId === action.payload.itemId,
+ );
+
+ if (existingItem) {
+ existingItem.quantity += 1;
+ } else {
+ state.cartItems.push({ ...action.payload, quantity: 1 });
+ }
+
+ saveToLocalStorage(LocalStorageKey.cart, state.cartItems);
+ },
+
+ increase: (state, action: PayloadAction) => {
+ const item = state.cartItems.find(
+ i => i.itemId === action.payload.itemId,
+ );
+
+ if (item) {
+ item.quantity += 1;
+ saveToLocalStorage(LocalStorageKey.cart, state.cartItems);
+ }
+ },
+
+ decrease: (state, action: PayloadAction) => {
+ const item = state.cartItems.find(
+ i => i.itemId === action.payload.itemId,
+ );
+
+ if (item && item.quantity > 1) {
+ item.quantity -= 1;
+ saveToLocalStorage(LocalStorageKey.cart, state.cartItems);
+ }
+ },
+
+ remove: (state, action: PayloadAction) => {
+ state.cartItems = state.cartItems.filter(
+ i => i.itemId !== action.payload.itemId,
+ );
+ saveToLocalStorage(LocalStorageKey.cart, state.cartItems);
+ },
+
+ clearCart: state => {
+ state.cartItems = [];
+ saveToLocalStorage(LocalStorageKey.cart, state.cartItems);
+ },
+ },
+
+ extraReducers: builder => {
+ builder.addCase(init.pending, state => {
+ state.loading = true;
+ });
+
+ builder.addCase(init.fulfilled, (state, action) => {
+ state.cartItems = action.payload;
+ state.loading = false;
+ state.error = false;
+ });
+
+ builder.addCase(init.rejected, state => {
+ state.loading = false;
+ state.error = true;
+ });
+ },
+});
+
+export const { add, increase, decrease, remove, clearCart } = cartSlice.actions;
+
+export default cartSlice.reducer;
+
+export const selectCartItems = (state: RootState) => state.cart.cartItems;
+
+export const selectTotalItems = createSelector([selectCartItems], cartItems =>
+ cartItems.reduce((total: number, item: CartItem) => total + item.quantity, 0),
+);
+
+export const selectTotalPrice = createSelector([selectCartItems], cartItems =>
+ cartItems.reduce(
+ (total: number, item: CartItem) => total + item.price * item.quantity,
+ 0,
+ ),
+);
diff --git a/src/features/favouritesSlice.ts b/src/features/favouritesSlice.ts
new file mode 100644
index 00000000000..9f6cdf6e5f8
--- /dev/null
+++ b/src/features/favouritesSlice.ts
@@ -0,0 +1,65 @@
+/* eslint-disable no-param-reassign */
+import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
+import { Product } from '../types/Product';
+import {
+ loadFromLocalStorage,
+ saveToLocalStorage,
+} from '../helpers/handleLocalStorage';
+import { LocalStorageKey } from '../enums/localStorageKey';
+
+type FavouritesState = {
+ favourites: Product[];
+ loading: boolean;
+ error: boolean;
+};
+
+const initialState: FavouritesState = {
+ favourites: [],
+ loading: false,
+ error: false,
+};
+
+export const init = createAsyncThunk('favourites/init', async () => {
+ return loadFromLocalStorage(LocalStorageKey.favourites);
+});
+
+export const favouritesSlice = createSlice({
+ name: 'favourites',
+ initialState,
+ reducers: {
+ toggleFavourite: (state, action: PayloadAction) => {
+ const inFavourites = state.favourites.some(
+ fav => fav.itemId === action.payload.itemId,
+ );
+
+ if (inFavourites) {
+ state.favourites = state.favourites.filter(
+ fav => fav.itemId !== action.payload.itemId,
+ );
+ } else {
+ state.favourites.push(action.payload);
+ }
+
+ saveToLocalStorage(LocalStorageKey.favourites, state.favourites);
+ },
+ },
+ extraReducers: builder => {
+ builder.addCase(init.pending, state => {
+ state.loading = true;
+ });
+
+ builder.addCase(init.fulfilled, (state, action) => {
+ state.favourites = action.payload;
+ state.loading = false;
+ state.error = false;
+ });
+
+ builder.addCase(init.rejected, state => {
+ state.loading = false;
+ state.error = true;
+ });
+ },
+});
+
+export default favouritesSlice.reducer;
+export const { toggleFavourite } = favouritesSlice.actions;
diff --git a/src/features/i18nSlice.ts b/src/features/i18nSlice.ts
new file mode 100644
index 00000000000..b97a5f60c87
--- /dev/null
+++ b/src/features/i18nSlice.ts
@@ -0,0 +1,29 @@
+/* eslint-disable no-param-reassign */
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+import i18n from '../i18n/i18n';
+import { Language } from '../enums/Language';
+
+export interface I18nState {
+ language: Language;
+}
+
+const savedLang =
+ (localStorage.getItem('appLanguage') as Language) || Language.EN;
+
+const initialState: I18nState = {
+ language: savedLang,
+};
+
+export const i18nSlice = createSlice({
+ name: 'i18n',
+ initialState,
+ reducers: {
+ setLanguage: (state, action: PayloadAction) => {
+ state.language = action.payload;
+ localStorage.setItem('appLanguage', action.payload);
+ i18n.changeLanguage(action.payload);
+ },
+ },
+});
+
+export const { setLanguage } = i18nSlice.actions;
diff --git a/src/features/phonesSlice.ts b/src/features/phonesSlice.ts
new file mode 100644
index 00000000000..5aba7ea244d
--- /dev/null
+++ b/src/features/phonesSlice.ts
@@ -0,0 +1,44 @@
+/* eslint-disable no-param-reassign */
+import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
+import { fetchData } from '../helpers/fetchData';
+import { ProductDetails } from '../types/ProductDetails';
+
+type PhonesState = {
+ productList: ProductDetails[];
+ loading: boolean;
+ error: boolean;
+};
+
+const initialState: PhonesState = {
+ productList: [],
+ loading: false,
+ error: false,
+};
+
+export const init = createAsyncThunk('phones/fetch', () => {
+ return fetchData('/api/phones.json');
+});
+
+export const phonesSlice = createSlice({
+ name: 'phones',
+ initialState,
+ reducers: {},
+ extraReducers: builder => {
+ builder.addCase(init.pending, state => {
+ state.loading = true;
+ });
+
+ builder.addCase(init.fulfilled, (state, action) => {
+ state.productList = action.payload;
+ state.loading = false;
+ state.error = false;
+ });
+
+ builder.addCase(init.rejected, state => {
+ state.loading = false;
+ state.error = true;
+ });
+ },
+});
+
+export default phonesSlice.reducer;
diff --git a/src/features/productsSlice.ts b/src/features/productsSlice.ts
new file mode 100644
index 00000000000..5f6a7b2def6
--- /dev/null
+++ b/src/features/productsSlice.ts
@@ -0,0 +1,44 @@
+/* eslint-disable no-param-reassign */
+import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
+import { Product } from '../types/Product';
+import { fetchData } from '../helpers/fetchData';
+
+type ProductsState = {
+ products: Product[];
+ loading: boolean;
+ error: boolean;
+};
+
+const initialState: ProductsState = {
+ products: [],
+ loading: true,
+ error: false,
+};
+
+export const init = createAsyncThunk('products/fetch', () => {
+ return fetchData('/api/products.json');
+});
+
+export const productsSlice = createSlice({
+ name: 'products',
+ initialState,
+ reducers: {},
+ extraReducers: builder => {
+ builder.addCase(init.pending, state => {
+ state.loading = true;
+ });
+
+ builder.addCase(init.fulfilled, (state, action) => {
+ state.products = action.payload;
+ state.loading = false;
+ state.error = false;
+ });
+
+ builder.addCase(init.rejected, state => {
+ state.loading = false;
+ state.error = true;
+ });
+ },
+});
+
+export default productsSlice.reducer;
diff --git a/src/features/tabletsSlice.ts b/src/features/tabletsSlice.ts
new file mode 100644
index 00000000000..a55c444b31a
--- /dev/null
+++ b/src/features/tabletsSlice.ts
@@ -0,0 +1,44 @@
+/* eslint-disable no-param-reassign */
+import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
+import { fetchData } from '../helpers/fetchData';
+import { ProductDetails } from '../types/ProductDetails';
+
+type TabletsState = {
+ productList: ProductDetails[];
+ loading: boolean;
+ error: boolean;
+};
+
+const initialState: TabletsState = {
+ productList: [],
+ loading: false,
+ error: false,
+};
+
+export const init = createAsyncThunk('tablets/fetch', () => {
+ return fetchData('/api/tablets.json');
+});
+
+export const tabletsSlice = createSlice({
+ name: 'tablets',
+ initialState,
+ reducers: {},
+ extraReducers: builder => {
+ builder.addCase(init.pending, state => {
+ state.loading = true;
+ });
+
+ builder.addCase(init.fulfilled, (state, action) => {
+ state.productList = action.payload;
+ state.loading = false;
+ state.error = false;
+ });
+
+ builder.addCase(init.rejected, state => {
+ state.loading = false;
+ state.error = true;
+ });
+ },
+});
+
+export default tabletsSlice.reducer;
diff --git a/src/features/themeSlice.ts b/src/features/themeSlice.ts
new file mode 100644
index 00000000000..925418f58bc
--- /dev/null
+++ b/src/features/themeSlice.ts
@@ -0,0 +1,22 @@
+/* eslint-disable no-param-reassign */
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+import { Theme } from '../enums/Theme';
+
+const savedTheme = (localStorage.getItem('appTheme') as Theme) || Theme.Dark;
+
+const initialState = {
+ theme: savedTheme,
+};
+
+export const themeSlice = createSlice({
+ name: 'theme',
+ initialState,
+ reducers: {
+ setTheme: (state, action: PayloadAction) => {
+ state.theme = action.payload;
+ localStorage.setItem('appTheme', action.payload);
+ },
+ },
+});
+
+export const { setTheme } = themeSlice.actions;
diff --git a/src/helpers/enumToOptions.ts b/src/helpers/enumToOptions.ts
new file mode 100644
index 00000000000..0ccb70b7946
--- /dev/null
+++ b/src/helpers/enumToOptions.ts
@@ -0,0 +1,13 @@
+import { TFunction } from 'i18next';
+import { DropdownOption } from '../types/DropdownOption';
+
+export function enumToDropdownOptions>(
+ enumObj: T,
+ t: TFunction,
+ translationPrefix: string,
+): DropdownOption[] {
+ return Object.values(enumObj).map(value => ({
+ value,
+ label: t(`${translationPrefix}.${value}`),
+ }));
+}
diff --git a/src/helpers/fetchData.ts b/src/helpers/fetchData.ts
new file mode 100644
index 00000000000..b45cacaeead
--- /dev/null
+++ b/src/helpers/fetchData.ts
@@ -0,0 +1,19 @@
+export function wait(delay: number) {
+ return new Promise(resolve => {
+ setTimeout(resolve, delay);
+ });
+}
+
+export const fetchData = async (url: string): Promise => {
+ await wait(1200);
+
+ const response = await fetch(`${import.meta.env.BASE_URL}${url}`);
+
+ // throw new Error('test error');
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch ${url}: ${response.statusText}`);
+ }
+
+ return response.json();
+};
diff --git a/src/helpers/generateDeviceModel.ts b/src/helpers/generateDeviceModel.ts
new file mode 100644
index 00000000000..11a99263514
--- /dev/null
+++ b/src/helpers/generateDeviceModel.ts
@@ -0,0 +1,23 @@
+export function generateDeviceModel(id: string): string {
+ let hash = 0;
+
+ for (let i = 0; i < id.length; i++) {
+ hash = (hash << 5) - hash + id.charCodeAt(i);
+ hash |= 0;
+ }
+
+ hash = Math.abs(hash);
+ const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
+ const getLetter = (n: number) => letters[n % 26];
+ const getDigit = (n: number) => (n % 10).toString();
+
+ const l1 = getLetter(hash >> 0);
+ const l2 = getLetter(hash >> 5);
+ const d1 = getDigit(hash >> 10);
+ const d2 = getDigit(hash >> 15);
+ const d3 = getDigit(hash >> 20);
+ const l3 = getLetter(hash >> 3);
+ const l4 = getLetter(hash >> 8);
+
+ return `i${l1}${l2}${d1}${d2}${d3}${l3}${l4}/A`;
+}
diff --git a/src/helpers/getAvailabilityProducts.ts b/src/helpers/getAvailabilityProducts.ts
new file mode 100644
index 00000000000..001193827e0
--- /dev/null
+++ b/src/helpers/getAvailabilityProducts.ts
@@ -0,0 +1,76 @@
+/* eslint-disable @typescript-eslint/indent */
+import { Product } from '../types/Product';
+import { ProductDetails } from '../types/ProductDetails';
+
+type VariantEntry = {
+ product: Product | { itemId: string; color: string; capacity: string }; //fallback if not found
+ available: boolean;
+};
+
+type VariantOptions = {
+ colorOptions: { color: string; available: boolean }[];
+ capacityOptions: { capacity: string; available: boolean }[];
+};
+export const normalize = (str: string) =>
+ str.toLowerCase().replace(/\s+/g, '-');
+
+export const getVariantOptions = (
+ details: ProductDetails,
+ products: Product[],
+): VariantOptions => {
+ const colors = Array.isArray(details.colorsAvailable)
+ ? details.colorsAvailable
+ : [];
+ const capacities = Array.isArray(details.capacityAvailable)
+ ? details.capacityAvailable
+ : [];
+
+ const productMap = new Map(products.map(p => [p.itemId, p]));
+ const variantEntries: VariantEntry[] = [];
+
+ for (const color of colors) {
+ for (const capacity of capacities) {
+ const itemId = `${details.namespaceId}-${normalize(capacity)}-${normalize(color)}`;
+ const existingProduct = productMap.get(itemId);
+
+ variantEntries.push({
+ product: existingProduct
+ ? {
+ ...existingProduct,
+ color: normalize(existingProduct.color),
+ capacity: normalize(existingProduct.capacity),
+ }
+ : {
+ itemId,
+ color: normalize(color),
+ capacity: normalize(capacity),
+ },
+ available: !!existingProduct,
+ });
+ }
+ }
+
+ const colorOptions = colors.map(color => {
+ const available = variantEntries.some(
+ v =>
+ v.product.color === normalize(color) &&
+ v.product.capacity === normalize(details.capacity) &&
+ v.available,
+ );
+
+ return { color, available };
+ });
+
+ const capacityOptions = capacities.map(capacity => {
+ const available = variantEntries.some(
+ v =>
+ v.product.capacity === normalize(capacity) &&
+ v.product.color === normalize(details.color) &&
+ v.available,
+ );
+
+ return { capacity, available };
+ });
+
+ return { colorOptions, capacityOptions };
+};
diff --git a/src/helpers/getHotPricedProducts.ts b/src/helpers/getHotPricedProducts.ts
new file mode 100644
index 00000000000..6b444a62cfc
--- /dev/null
+++ b/src/helpers/getHotPricedProducts.ts
@@ -0,0 +1,15 @@
+import { Product } from '../types/Product';
+
+export function getHotPricedProducts(
+ products: Product[],
+ n: number,
+): Product[] {
+ return [...products]
+ .sort((a, b) => {
+ const discountA = a.fullPrice - a.price;
+ const discountB = b.fullPrice - b.price;
+
+ return discountB - discountA;
+ })
+ .slice(0, n);
+}
diff --git a/src/helpers/getNewestExpensiveProducts.ts b/src/helpers/getNewestExpensiveProducts.ts
new file mode 100644
index 00000000000..376dc67c48b
--- /dev/null
+++ b/src/helpers/getNewestExpensiveProducts.ts
@@ -0,0 +1,14 @@
+import { Product } from '../types/Product';
+
+export function getNewestExpensiveProducts(
+ products: Product[],
+ n: number,
+): Product[] {
+ const maxYear = Math.max(...products.map(p => p.year));
+
+ return products
+ .filter(p => p.year === maxYear)
+ .filter(p => p.price < p.fullPrice)
+ .sort((a, b) => b.price - a.price)
+ .slice(0, n);
+}
diff --git a/src/helpers/getPaginationPages.ts b/src/helpers/getPaginationPages.ts
new file mode 100644
index 00000000000..aeac86cf0d0
--- /dev/null
+++ b/src/helpers/getPaginationPages.ts
@@ -0,0 +1,21 @@
+export function getPageRange(
+ current: number,
+ total: number,
+ maxButtons: number,
+): number[] {
+ const half = Math.floor(maxButtons / 2);
+ let start = Math.max(current - half, 1);
+ const end = Math.min(start + maxButtons - 1, total);
+
+ if (end - start < maxButtons - 1) {
+ start = Math.max(end - maxButtons + 1, 1);
+ }
+
+ const pages: number[] = [];
+
+ for (let i = start; i <= end; i++) {
+ pages.push(i);
+ }
+
+ return pages;
+}
diff --git a/src/helpers/getRandomProducts.ts b/src/helpers/getRandomProducts.ts
new file mode 100644
index 00000000000..2b8686d6d18
--- /dev/null
+++ b/src/helpers/getRandomProducts.ts
@@ -0,0 +1,7 @@
+import { Product } from '../types/Product';
+
+export function getRandomProducts(products: Product[], n: number): Product[] {
+ const shuffled = [...products].sort(() => Math.random() - 0.5);
+
+ return shuffled.slice(0, n);
+}
diff --git a/src/helpers/handleLocalStorage.ts b/src/helpers/handleLocalStorage.ts
new file mode 100644
index 00000000000..a02b36835af
--- /dev/null
+++ b/src/helpers/handleLocalStorage.ts
@@ -0,0 +1,27 @@
+/* eslint-disable no-console */
+
+import { LocalStorageKey } from '../enums/localStorageKey';
+import { wait } from './fetchData';
+
+export async function loadFromLocalStorage(
+ key: LocalStorageKey,
+): Promise {
+ try {
+ await wait(1800);
+ const item = localStorage.getItem(key);
+
+ return item ? (JSON.parse(item) as T) : ([] as T);
+ } catch (error) {
+ console.error(`Error reading "${key}" from localStorage:`, error);
+
+ return [] as T;
+ }
+}
+
+export function saveToLocalStorage(key: LocalStorageKey, value: T) {
+ try {
+ localStorage.setItem(key, JSON.stringify(value));
+ } catch (error) {
+ console.error(`Error saving "${key}" to localStorage:`, error);
+ }
+}
diff --git a/src/helpers/searchHelper.ts b/src/helpers/searchHelper.ts
new file mode 100644
index 00000000000..10a56f8f507
--- /dev/null
+++ b/src/helpers/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/helpers/setHtmlDataAttr.ts b/src/helpers/setHtmlDataAttr.ts
new file mode 100644
index 00000000000..e57c4ca71e8
--- /dev/null
+++ b/src/helpers/setHtmlDataAttr.ts
@@ -0,0 +1,18 @@
+import { HTMLDataAttr } from '../enums/htmlDataAttribs';
+
+export function setElementDataAttr(
+ element: 'html' | 'body',
+ attr: HTMLDataAttr,
+ value?: string | boolean,
+) {
+ const targetElement =
+ element === 'html' ? document.documentElement : document.body;
+
+ const key = `data-${attr}`;
+
+ if (value === null || value === undefined || value === false) {
+ targetElement.removeAttribute(key);
+ } else {
+ targetElement.setAttribute(key, String(value));
+ }
+}
diff --git a/src/hooks/hooks.ts b/src/hooks/hooks.ts
new file mode 100644
index 00000000000..1ef2397c0dc
--- /dev/null
+++ b/src/hooks/hooks.ts
@@ -0,0 +1,5 @@
+import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
+import { AppDispatch, RootState } from '../store';
+
+export const useAppSelector: TypedUseSelectorHook = useSelector;
+export const useAppDispatch: () => AppDispatch = useDispatch;
diff --git a/src/hooks/useFilteredProducts.ts b/src/hooks/useFilteredProducts.ts
new file mode 100644
index 00000000000..12d48dcf299
--- /dev/null
+++ b/src/hooks/useFilteredProducts.ts
@@ -0,0 +1,34 @@
+import { useMemo } from 'react';
+import { Product } from '../types/Product';
+import { SortBy } from '../enums/SortBy';
+
+export function useFilteredProducts(
+ products: Product[],
+ category: string,
+ query: string,
+ sortBy: string,
+): Product[] {
+ return useMemo(() => {
+ const result = products.filter(
+ p =>
+ (category === '' || p.category === category) &&
+ (query === '' || p.name.toLowerCase().includes(query.toLowerCase())),
+ );
+
+ switch (sortBy) {
+ case SortBy.Alpha:
+ result.sort((a, b) => a.name.localeCompare(b.name));
+ break;
+ case SortBy.Cheap:
+ result.sort((a, b) => a.price - b.price);
+ break;
+ case SortBy.Newest:
+ result.sort((a, b) => b.year - a.year);
+ break;
+ default:
+ break;
+ }
+
+ return result;
+ }, [products, category, query, sortBy]);
+}
diff --git a/src/hooks/usePaginatedProducts.ts b/src/hooks/usePaginatedProducts.ts
new file mode 100644
index 00000000000..76e5b8354a1
--- /dev/null
+++ b/src/hooks/usePaginatedProducts.ts
@@ -0,0 +1,26 @@
+import { useMemo } from 'react';
+import { Product } from '../types/Product';
+import { ItemPerPage } from '../enums/ItemsPerPage';
+
+export function usePaginatedProducts(
+ products: Product[],
+ perPage: number | ItemPerPage.All,
+ currentPage: number,
+) {
+ const totalPages =
+ perPage === ItemPerPage.All
+ ? 1
+ : Math.ceil(products.length / Number(perPage));
+
+ const paginated = useMemo(() => {
+ if (perPage === ItemPerPage.All) {
+ return products;
+ }
+
+ const start = (currentPage - 1) * Number(perPage);
+
+ return products.slice(start, start + Number(perPage));
+ }, [products, currentPage, perPage]);
+
+ return { paginated, totalPages };
+}
diff --git a/src/i18n/i18n.ts b/src/i18n/i18n.ts
new file mode 100644
index 00000000000..7bbdda7db58
--- /dev/null
+++ b/src/i18n/i18n.ts
@@ -0,0 +1,29 @@
+import i18n from 'i18next';
+import { initReactI18next } from 'react-i18next';
+import { Language } from '../enums/Language';
+
+import en from './locales/en.json';
+import ua from './locales/ua.json';
+
+const fallbackLang =
+ (localStorage.getItem('appLanguage') as Language) || Language.EN;
+
+const resources = {
+ en: {
+ translation: en,
+ },
+ ua: {
+ translation: ua,
+ },
+};
+
+i18n.use(initReactI18next).init({
+ resources,
+ fallbackLng: fallbackLang,
+ pluralSeparator: '_',
+ interpolation: {
+ escapeValue: false,
+ },
+});
+
+export default i18n;
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
new file mode 100644
index 00000000000..ade1cce10c1
--- /dev/null
+++ b/src/i18n/locales/en.json
@@ -0,0 +1,157 @@
+{
+ "catalog": {
+ "dropdown": {
+ "sortBy": {
+ "options": {
+ "Newest": "Newest",
+ "Alphabetically": "Alphabetically",
+ "Cheapest": "Cheapest",
+ "4": "4",
+ "8": "8",
+ "16": "16",
+ "All": "All"
+ },
+ "label": "Sort by"
+ },
+ "perPage": {
+ "label": "Items per page"
+ }
+ },
+ "subtitle": {
+ "items_one": "{{count}} item",
+ "items_other": "{{count}} items"
+ },
+ "noMatching": "No products found matching your search"
+ },
+ "navlink": {
+ "Home": "Home",
+ "Phones": "Phones",
+ "Tablets": "Tablets",
+ "Accessories": "Accessories",
+
+ "GitHub": "GitHub",
+ "Contacts": "Contacts",
+ "Rights": "Rights"
+ },
+ "languages": {
+ "title": {
+ "en": "EN",
+ "ua": "UA"
+ }
+ },
+ "favourites": {
+ "title": "Favourites",
+ "empty": "You don't have favourites yet"
+ },
+ "buttons": {
+ "actions": {
+ "toTop": "Back to top",
+ "inCart": "Added",
+ "toCart": "Add to cart",
+ "back": "Back",
+ "product": {
+ "colors": "Colors",
+ "capacity": "Capacity"
+ },
+ "yes": "Yes",
+ "cancel": "Cancel"
+ }
+ },
+ "product": {
+ "specifications": {
+ "screen": "Screen",
+ "capacity": "Capacity",
+ "display": "Display",
+ "ram": "RAM",
+ "resolution": "Resolution",
+ "processor": "Processor",
+ "builtInMemory": "Built-in Memory",
+ "camera": "Camera",
+ "zoom": "Zoom",
+ "cell": "Cell",
+ "label": "Tech specs"
+ },
+ "about": {
+ "title": "About this product"
+ }
+ },
+ "home": {
+ "title": "Welcome to Nice Gadgets store!",
+ "categories": "Shop by category"
+ },
+ "slider": {
+ "title": {
+ "products": {
+ "new": "Brand new models",
+ "hot": "Hot prices",
+ "random": "You may also like"
+ }
+ }
+ },
+ "404": {
+ "title": "404",
+ "subtitle": "Page not found"
+ },
+ "cart": {
+ "title": "Cart",
+ "total": "Total for {{count}} item",
+ "total_other": "Total for {{count}} items",
+ "checkout": "Checkout",
+ "empty": "Your cart is empty",
+ "notImplemented": "Checkout is not implemented yet.",
+ "question": "Do you wan't to clear the Cart?"
+ },
+ "sortBy": {
+ "options": {
+ "Newest": "Newest",
+ "Alphabetically": "Alphabetically",
+ "Cheapest": "Cheapest",
+ "4": "4",
+ "8": "8",
+ "16": "16",
+ "All": "All"
+ },
+ "label": "Sort by"
+ },
+ "tablets": {
+ "title": "Tablets"
+ },
+ "accessories": {
+ "title": "Accessories"
+ },
+ "phones": {
+ "title": "Mobile phones"
+ },
+ "breadcrumbs": {
+ "tablets": {
+ "title": "Tablets"
+ },
+ "accessories": {
+ "title": "Accessories"
+ },
+ "phones": {
+ "title": "Phones"
+ },
+ "favourites": {
+ "title": "Favourites"
+ }
+ },
+ "search": {
+ "placeholder": "Search"
+ },
+ "error": {
+ "unknown": "Sorry, something went wrong",
+ "retry": "Try again"
+ },
+ "notification": {
+ "title": "Notification",
+ "add": {
+ "favourites": "{{name}} added to favourites",
+ "cart": "{{name}} added to cart"
+ },
+ "remove": {
+ "favourites": "{{name}} removed from favourites",
+ "cart": "{{name}} removed from cart"
+ }
+ }
+}
diff --git a/src/i18n/locales/ua.json b/src/i18n/locales/ua.json
new file mode 100644
index 00000000000..010ba67f098
--- /dev/null
+++ b/src/i18n/locales/ua.json
@@ -0,0 +1,161 @@
+{
+ "catalog": {
+ "dropdown": {
+ "sortBy": {
+ "options": {
+ "Newest": "Новіші",
+ "Alphabetically": "За алфавітом",
+ "Cheapest": "Найдешевші",
+ "4": "4",
+ "8": "8",
+ "16": "16",
+ "All": "Усі"
+ },
+ "label": "Сортувати за"
+ },
+ "perPage": {
+ "label": "Кількість на сторінці"
+ }
+ },
+ "subtitle": {
+ "items": "{{count}} товар",
+ "items_one": "{{count}} товар",
+ "items_few": "{{count}} товари",
+ "items_many": "{{count}} товарів",
+ "items_other": "{{count}} товару"
+ },
+ "noMatching": "За вашим запитом нічого не знайдено"
+ },
+ "navlink": {
+ "Home": "Головна",
+ "Phones": "Телефони",
+ "Tablets": "Планшети",
+ "Accessories": "Аксесуари",
+ "GitHub": "GitHub",
+ "Contacts": "Контакти",
+ "Rights": "Права"
+ },
+ "languages": {
+ "title": {
+ "en": "EN",
+ "ua": "UA"
+ }
+ },
+ "favourites": {
+ "title": "Обране",
+ "empty": "У вас ще немає обраних товарів"
+ },
+ "buttons": {
+ "actions": {
+ "toTop": "До початку",
+ "inCart": "Додано",
+ "toCart": "До кошика",
+ "back": "Назад",
+ "product": {
+ "colors": "Кольори",
+ "capacity": "Ємність"
+ },
+ "yes": "Так",
+ "cancel": "Скасувати"
+ }
+ },
+ "product": {
+ "specifications": {
+ "screen": "Екран",
+ "capacity": "Ємність",
+ "display": "Дисплей",
+ "ram": "ОЗП",
+ "resolution": "Роздільна здатність",
+ "processor": "Процесор",
+ "builtInMemory": "Вбудована памʼять",
+ "camera": "Камера",
+ "zoom": "Зум",
+ "cell": "Мережа",
+ "label": "Технічні характеристики"
+ },
+ "about": {
+ "title": "Про цей товар"
+ }
+ },
+ "home": {
+ "title": "Ласкаво просимо до магазину Nice Gadgets!",
+ "categories": "Обирайте за категорією"
+ },
+ "slider": {
+ "title": {
+ "products": {
+ "new": "Нові моделі",
+ "hot": "Гарячі ціни",
+ "random": "Вам також може сподобатися"
+ }
+ }
+ },
+ "404": {
+ "title": "404",
+ "subtitle": "Сторінку не знайдено"
+ },
+ "cart": {
+ "title": "Кошик",
+ "total": "Разом за {{count}} товар",
+ "total_few": "Разом за {{count}} товари",
+ "total_many": "Разом за {{count}} товари",
+ "total_other": "Разом за {{count}} товари",
+ "checkout": "Оформити замовлення",
+ "empty": "Ваш кошик порожній",
+ "notImplemented": "Оформлення замовлення ще не реалізовано.",
+ "question": "Хочете очистити кошик?"
+ },
+ "sortBy": {
+ "options": {
+ "Newest": "Новіші",
+ "Alphabetically": "За алфавітом",
+ "Cheapest": "Найдешевші",
+ "4": "4",
+ "8": "8",
+ "16": "16",
+ "All": "Усі"
+ },
+ "label": "Сортувати за"
+ },
+ "tablets": {
+ "title": "Планшети"
+ },
+ "accessories": {
+ "title": "Аксесуари"
+ },
+ "phones": {
+ "title": "Мобільні телефони"
+ },
+ "breadcrumbs": {
+ "tablets": {
+ "title": "Планшети"
+ },
+ "accessories": {
+ "title": "Аксесуари"
+ },
+ "phones": {
+ "title": "Телефони"
+ },
+ "favourites": {
+ "title": "Обране"
+ }
+ },
+ "search": {
+ "placeholder": "Пошук"
+ },
+ "error": {
+ "unknown": "Ой, щось пішло не так",
+ "retry": "Спробувати знову"
+ },
+ "notification": {
+ "title": "Сповіщення",
+ "add": {
+ "favourites": "{{name}} додано до обраних",
+ "cart": "{{name}} додано в кошик"
+ },
+ "remove": {
+ "favourites": "{{name}} видалено з обраних",
+ "cart": "{{name}} прибрано з кошика"
+ }
+ }
+}
diff --git a/src/index.tsx b/src/index.tsx
index 50470f1508d..d9736adc9cd 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,4 +1,9 @@
+import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
-import { App } from './App';
+import App from './App';
-createRoot(document.getElementById('root') as HTMLElement).render( );
+createRoot(document.getElementById('root')!).render(
+
+
+ ,
+);
diff --git a/src/modules/Breadcrumbs/Breadcrumbs.module.scss b/src/modules/Breadcrumbs/Breadcrumbs.module.scss
new file mode 100644
index 00000000000..7f8e1514cae
--- /dev/null
+++ b/src/modules/Breadcrumbs/Breadcrumbs.module.scss
@@ -0,0 +1,69 @@
+@use '@shared/styles/utils/mixins.scss' as *;
+
+// ORGANISM
+.breadcrumbs {
+ margin-bottom: 40px;
+}
+
+.navigation {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0;
+ margin: 0;
+ height: 16px;
+ max-width: 100%;
+}
+
+// MOLECULE
+.list {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 0;
+ margin: 0;
+ overflow: hidden;
+ list-style: none;
+}
+
+.item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-shrink: 0;
+
+ &:last-child {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ flex-shrink: 1;
+ }
+}
+
+.text {
+ font-weight: 800;
+ font-size: 12px;
+ line-height: 100%;
+ text-decoration: none;
+ white-space: nowrap;
+ color: inherit;
+
+ @include focus-visible;
+}
+
+.currentText {
+ font-weight: 600;
+ text-decoration: none;
+ text-overflow: ellipsis;
+ overflow: hidden;
+}
+
+.clickable {
+ color: var(--main-text-color-primary);
+
+ @include hover(color, var(--purple-accent-color));
+}
+
+.default {
+ color: var(--secondary-color);
+}
diff --git a/src/modules/Breadcrumbs/atoms/BreadcrumbArrowIcon.tsx b/src/modules/Breadcrumbs/atoms/BreadcrumbArrowIcon.tsx
new file mode 100644
index 00000000000..78463e89ed2
--- /dev/null
+++ b/src/modules/Breadcrumbs/atoms/BreadcrumbArrowIcon.tsx
@@ -0,0 +1,13 @@
+import React from 'react';
+import { Icon } from '../../shared/atoms/Icon';
+import { ArrowIcon } from '../../../assets/icons/arrow-icon';
+
+type Props = {
+ className?: string;
+};
+
+export const BreadcrumbArrowIcon: React.FC = ({ className }) => (
+
+
+
+);
diff --git a/src/modules/Breadcrumbs/atoms/BreadcrumbHomeIcon.tsx b/src/modules/Breadcrumbs/atoms/BreadcrumbHomeIcon.tsx
new file mode 100644
index 00000000000..3bbbc4a9bbc
--- /dev/null
+++ b/src/modules/Breadcrumbs/atoms/BreadcrumbHomeIcon.tsx
@@ -0,0 +1,13 @@
+import React from 'react';
+import { Icon } from '../../shared/atoms/Icon';
+import { HomeIcon } from '../../../assets/icons/home-icon';
+
+type Props = {
+ className?: string;
+};
+
+export const BreadcrumbHomeIcon: React.FC = ({ className }) => (
+
+
+
+);
diff --git a/src/modules/Breadcrumbs/atoms/BreadcrumbText.tsx b/src/modules/Breadcrumbs/atoms/BreadcrumbText.tsx
new file mode 100644
index 00000000000..f30dc406c63
--- /dev/null
+++ b/src/modules/Breadcrumbs/atoms/BreadcrumbText.tsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import classNames from 'classnames';
+import styles from '../Breadcrumbs.module.scss';
+import { Typography } from '../../shared/atoms/Typography';
+
+type Props = {
+ children: React.ReactNode;
+ isCurrent?: boolean;
+};
+
+export const BreadcrumbText: React.FC = ({ children, isCurrent }) => (
+
+ {children}
+
+);
diff --git a/src/modules/Breadcrumbs/atoms/index.ts b/src/modules/Breadcrumbs/atoms/index.ts
new file mode 100644
index 00000000000..2fcf7bd57ed
--- /dev/null
+++ b/src/modules/Breadcrumbs/atoms/index.ts
@@ -0,0 +1,3 @@
+export * from './BreadcrumbArrowIcon';
+export * from './BreadcrumbHomeIcon';
+export * from './BreadcrumbText';
diff --git a/src/modules/Breadcrumbs/index.ts b/src/modules/Breadcrumbs/index.ts
new file mode 100644
index 00000000000..26409887cb0
--- /dev/null
+++ b/src/modules/Breadcrumbs/index.ts
@@ -0,0 +1 @@
+export * from './organisms/Breadcrumbs';
diff --git a/src/modules/Breadcrumbs/molecules/BreadcrumbItem.tsx b/src/modules/Breadcrumbs/molecules/BreadcrumbItem.tsx
new file mode 100644
index 00000000000..4d1f9516ebb
--- /dev/null
+++ b/src/modules/Breadcrumbs/molecules/BreadcrumbItem.tsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import styles from '../Breadcrumbs.module.scss';
+import classNames from 'classnames';
+
+type Props = {
+ children: React.ReactNode;
+ clickable?: boolean;
+};
+
+export const BreadcrumbItem: React.FC = ({ children, clickable }) => (
+
+ {children}
+
+);
diff --git a/src/modules/Breadcrumbs/molecules/BreadcrumbLink.tsx b/src/modules/Breadcrumbs/molecules/BreadcrumbLink.tsx
new file mode 100644
index 00000000000..b80912f67c5
--- /dev/null
+++ b/src/modules/Breadcrumbs/molecules/BreadcrumbLink.tsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import { Link } from 'react-router-dom';
+import styles from '../Breadcrumbs.module.scss';
+import { Typography } from '../../shared/atoms/Typography';
+
+type Props = {
+ to: string;
+ children: React.ReactNode;
+};
+
+export const BreadcrumbLink: React.FC = ({ to, children }) => (
+
+
+ {children}
+
+
+);
diff --git a/src/modules/Breadcrumbs/molecules/index.ts b/src/modules/Breadcrumbs/molecules/index.ts
new file mode 100644
index 00000000000..8cd49567df6
--- /dev/null
+++ b/src/modules/Breadcrumbs/molecules/index.ts
@@ -0,0 +1,2 @@
+export * from './BreadcrumbItem';
+export * from './BreadcrumbLink';
diff --git a/src/modules/Breadcrumbs/organisms/Breadcrumbs.tsx b/src/modules/Breadcrumbs/organisms/Breadcrumbs.tsx
new file mode 100644
index 00000000000..346a8676d5b
--- /dev/null
+++ b/src/modules/Breadcrumbs/organisms/Breadcrumbs.tsx
@@ -0,0 +1,91 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { useLocation, useParams } from 'react-router-dom';
+import styles from './../Breadcrumbs.module.scss';
+import { SearchField } from '../../shared/molecules/SearchField';
+import { generateDeviceModel } from '../../../helpers/generateDeviceModel';
+import { useAppSelector } from '../../../hooks/hooks';
+import {
+ BreadcrumbHomeIcon,
+ BreadcrumbArrowIcon,
+ BreadcrumbText,
+} from '../atoms';
+import { BreadcrumbItem, BreadcrumbLink } from '../molecules';
+
+type Props = {
+ showSearch?: boolean;
+};
+
+export const Breadcrumbs: React.FC = ({ showSearch = false }) => {
+ const { t } = useTranslation();
+ const { pathname } = useLocation();
+ const { productId } = useParams();
+ const pathnames = pathname.split('/').filter(Boolean);
+ const isHomePage = pathname === '/';
+
+ const { products, loading } = useAppSelector(state => state.products);
+ const product = products?.find(p => p?.itemId === productId);
+ const category = product?.category || '';
+ const productModel = generateDeviceModel(productId || '');
+
+ if (['/cart', '/', '/home'].includes(pathname)) {
+ return null;
+ }
+
+ return (
+
+ {!loading && (
+
+
+ {!isHomePage && (
+
+
+
+
+
+ )}
+
+ {product ? (
+ <>
+
+
+
+ {t(`breadcrumbs.${category}.title`)}
+
+
+
+
+
+
+ {product?.name} ({productModel})
+
+
+ >
+ ) : (
+ pathnames.map((name, index) => {
+ const isLast = index === pathnames.length - 1;
+ const to = `/${pathnames.slice(0, index + 1).join('/')}`;
+
+ return (
+
+
+ {isLast ? (
+
+ {t(`breadcrumbs.${name}.title`)}
+
+ ) : (
+
+ {t(`breadcrumbs.${name}.title`)}
+
+ )}
+
+ );
+ })
+ )}
+
+ {showSearch && }
+
+ )}
+
+ );
+};
diff --git a/src/modules/CartPage/CartPage.module.scss b/src/modules/CartPage/CartPage.module.scss
new file mode 100644
index 00000000000..1f5d355e875
--- /dev/null
+++ b/src/modules/CartPage/CartPage.module.scss
@@ -0,0 +1,88 @@
+@use '@shared/styles/utils/mixins.scss' as *;
+
+.back {
+ margin-bottom: 24px;
+
+ @include on-tablet {
+ margin-bottom: 16px;
+ }
+}
+
+.cart {
+ opacity: 0;
+ animation: fade-in var(--animation-duration, 0.3s) ease forwards;
+ height: 100%;
+}
+
+@keyframes fade-in {
+ to {
+ opacity: 1;
+ }
+}
+
+.content {
+ margin-top: 32px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+
+ @include on-tablet {
+ gap: 24px;
+ }
+
+ @include on-small-desktop {
+ @include page-grid;
+
+ column-gap: 16px;
+ }
+}
+
+.list {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ width: 100%;
+
+ @include on-tablet {
+ gap: 24px;
+ flex-direction: column;
+ margin-bottom: 80px;
+ }
+
+ @include on-small-desktop {
+ grid-column: span 16;
+ }
+}
+
+.total {
+ box-sizing: border-box;
+ border: 1px solid var(--surface2-color);
+ padding: 24px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+
+ @include on-small-desktop {
+ height: fit-content;
+ grid-column: span 8;
+ }
+
+ &__info {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ }
+
+ &__price {
+ &::before {
+ content: '$';
+ }
+ }
+
+ &__button {
+ color: var(--buttons-text-color-primary-active);
+ background-color: var(--buttons-background-active);
+
+ @include hover(background-color, var(--buttons-background-active-hover));
+ }
+}
diff --git a/src/modules/CartPage/CartPage.tsx b/src/modules/CartPage/CartPage.tsx
new file mode 100644
index 00000000000..ca8c30c2e8f
--- /dev/null
+++ b/src/modules/CartPage/CartPage.tsx
@@ -0,0 +1,98 @@
+import React, { useEffect, useState } from 'react';
+import styles from './CartPage.module.scss';
+import { useTranslation } from 'react-i18next';
+import { useAppSelector } from '../../hooks/hooks';
+import {
+ clearCart,
+ selectTotalItems,
+ selectTotalPrice,
+} from '../../features/cartSlice';
+import { useDispatch } from 'react-redux';
+import { Modal } from './components/organisms/Modal';
+import { PageMessage } from '../shared/molecules/PageMessage';
+import { Heading } from '../shared/molecules/Heading';
+import { Typography } from '../shared/atoms/Typography';
+import { Button } from '../shared/atoms/Button';
+import { CartItem } from './components/organisms/CartItem';
+import { Divider } from '../shared/atoms/Divider';
+import { BackButton } from '../shared/atoms/BackButton';
+import { HTMLDataAttr } from '../../enums/htmlDataAttribs';
+import { setElementDataAttr } from '../../helpers/setHtmlDataAttr';
+
+export const CartPage: React.FC = () => {
+ const { t } = useTranslation();
+
+ const { cartItems } = useAppSelector(state => state.cart);
+ const totalItems = useAppSelector(selectTotalItems);
+ const totalPrice = useAppSelector(selectTotalPrice);
+
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const dispatch = useDispatch();
+
+ useEffect(() => {
+ setElementDataAttr('body', HTMLDataAttr.Modal, isModalOpen);
+ }, [isModalOpen]);
+
+ return (
+ <>
+
+
+
+
+ {cartItems.length > 0 ? (
+
+
+ {cartItems.map(item => (
+
+ ))}
+
+
+
+
+
+ {totalPrice}
+
+
+ {t('cart.total', { count: totalItems })}
+
+
+
+
setIsModalOpen(true)}
+ className={styles.total__button}
+ size="large"
+ fullWidth
+ >
+
+ {t('cart.checkout')}
+
+
+
+
+ ) : (
+
+ )}
+
+ {isModalOpen && (
+ setIsModalOpen(false)}
+ onConfirm={() => {
+ dispatch(clearCart());
+ setIsModalOpen(false);
+ }}
+ />
+ )}
+ >
+ );
+};
diff --git a/src/modules/CartPage/components/organisms/CartItem/CartItem.module.scss b/src/modules/CartPage/components/organisms/CartItem/CartItem.module.scss
new file mode 100644
index 00000000000..d1d47e3f8d8
--- /dev/null
+++ b/src/modules/CartPage/components/organisms/CartItem/CartItem.module.scss
@@ -0,0 +1,102 @@
+@use '@shared/styles/utils/mixins' as *;
+@use '@shared/styles/buttons' as *;
+
+.cart_item {
+ background-color: var(--surface1-color);
+ box-sizing: border-box;
+ width: 100%;
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+
+ @include on-tablet {
+ padding: 24px;
+ flex-direction: row;
+ align-items: center;
+ gap: 24px;
+ }
+
+ &__body {
+ display: flex;
+ gap: 23px;
+ align-items: center;
+ }
+
+ &__close_btn {
+ background-color: unset;
+
+ border-radius: 50%;
+
+ &__icon {
+ color: var(--icons-color);
+ }
+
+ &:hover &__icon {
+ color: var(--elements-color);
+ }
+ }
+
+ &__link {
+ display: flex;
+ align-items: center;
+ gap: 24px;
+ text-decoration: none;
+ }
+
+ &__image {
+ width: 80px;
+ aspect-ratio: 1;
+ padding: 7px;
+
+ &_el {
+ display: block;
+ width: 100%;
+ aspect-ratio: 1;
+ }
+ }
+
+ &__name {
+ @include on-tablet {
+ max-width: 176px;
+ }
+
+ @include on-small-desktop {
+ max-width: 336px;
+ }
+ }
+
+ &__footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+
+ @include on-tablet {
+ justify-content: end;
+ gap: 50px;
+ flex-grow: 1;
+ }
+ }
+
+ &__price {
+ min-width: 6ch;
+ text-align: end;
+
+ &::before {
+ content: '$';
+ }
+ }
+
+ &__count {
+ background-color: unset;
+ pointer-events: none;
+ }
+
+ &__button {
+ @include control-button;
+ }
+
+ &__controls {
+ display: flex;
+ }
+}
diff --git a/src/modules/CartPage/components/organisms/CartItem/CartItem.tsx b/src/modules/CartPage/components/organisms/CartItem/CartItem.tsx
new file mode 100644
index 00000000000..f42ee9e4ab6
--- /dev/null
+++ b/src/modules/CartPage/components/organisms/CartItem/CartItem.tsx
@@ -0,0 +1,113 @@
+import React from 'react';
+import { Link } from 'react-router-dom';
+import styles from './CartItem.module.scss';
+import { CloseIcon } from '../../../../../assets/icons/close-icon';
+import { Icon } from '../../../../shared/atoms/Icon';
+import { MinusIcon } from '../../../../../assets/icons/minus-icon';
+import { PlusIcon } from '../../../../../assets/icons/plus-icon';
+import { remove, increase, decrease } from '../../../../../features/cartSlice';
+import { useAppDispatch, useAppSelector } from '../../../../../hooks/hooks';
+import { Product } from '../../../../../types/Product';
+import { IconButton } from '../../../../shared/atoms/IconButton';
+import { Typography } from '../../../../shared/atoms/Typography';
+import { useTranslation } from 'react-i18next';
+import { showToast } from '../../../../NotificationToast';
+
+type Props = {
+ product: Product;
+};
+
+export const CartItem: React.FC = ({ product }) => {
+ const dispatch = useAppDispatch();
+ const { cartItems } = useAppSelector(state => state.cart);
+ const { t } = useTranslation();
+
+ const cartItem = cartItems.find(item => item.itemId === product.itemId);
+ const quantity = cartItem?.quantity || 1;
+
+ const removeFromCart = () => {
+ dispatch(remove(product));
+ showToast({
+ description: t('notification.remove.cart', {
+ name: product.name,
+ }),
+ });
+ };
+
+ const increaseQuantity = () => dispatch(increase(product));
+ const decreaseQuantity = () => dispatch(decrease(product));
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {product.name}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {quantity}
+
+
+
+
+
+
+
+
+
+
+ {product.price * quantity}
+
+
+
+ >
+ );
+};
diff --git a/src/modules/CartPage/components/organisms/CartItem/index.ts b/src/modules/CartPage/components/organisms/CartItem/index.ts
new file mode 100644
index 00000000000..37a05535402
--- /dev/null
+++ b/src/modules/CartPage/components/organisms/CartItem/index.ts
@@ -0,0 +1 @@
+export * from './CartItem';
diff --git a/src/modules/CartPage/components/organisms/Modal/Modal.module.scss b/src/modules/CartPage/components/organisms/Modal/Modal.module.scss
new file mode 100644
index 00000000000..4c65c732e94
--- /dev/null
+++ b/src/modules/CartPage/components/organisms/Modal/Modal.module.scss
@@ -0,0 +1,116 @@
+@use '@shared/styles/utils/mixins.scss' as *;
+
+.modal {
+ position: fixed;
+ inset: 0;
+ z-index: 1000;
+
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ @include content-padding-inline;
+
+ background-color: rgba(22, 24, 39, 0.85);
+
+ &__content {
+ position: relative;
+ z-index: 1001;
+
+ width: 100%;
+ max-width: 800px;
+
+ text-align: center;
+
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+
+ padding: 32px 16px;
+
+ background-color: var(--surface1-color);
+ border: 2px solid var(--buttons-background-active);
+
+ animation: modalfade-in var(--animation-duration, 0.3s)
+ cubic-bezier(0.22, 1, 0.36, 1);
+ will-change: transform, opacity;
+ backface-visibility: hidden;
+}
+
+ &__head {
+ display: flex;
+
+ justify-content: flex-end;
+ }
+
+ &__close {
+ background-color: var(--surface2-color);
+
+ border-radius: 50%;
+
+ @include hover(background-color, var(--icons-color));
+ }
+
+ &__loader {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ box-sizing: border-box;
+ padding: 60px 0;
+ }
+
+ &__actions {
+ display: flex;
+ gap: 20px;
+ align-items: center;
+ justify-content: space-around;
+ }
+
+ &__button {
+ flex: 1;
+
+ background-color: var(--surface2-color);
+
+ @include hover(background-color, var(--elements-color));
+
+ &--yes {
+ color: var(--buttons-text-color-primary-active);
+
+ background-color: var(--buttons-background-active);
+
+ @include hover(background-color, var(--buttons-background-active-hover));
+ }
+ }
+}
+
+.spinner {
+ width: 48px;
+ height: 48px;
+ border: 4px solid rgba(255, 255, 255, 0.3);
+ border-top-color: var(--buttons-background-active);
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes modalfade-in {
+ 0% {
+ transform: translateY(-40px) scale(0.9);
+ opacity: 0;
+ }
+
+ 60% {
+ transform: translateY(8px) scale(1.02);
+ opacity: 0.8;
+ }
+
+ 100% {
+ transform: translateY(0) scale(1);
+ opacity: 1;
+ }
+}
diff --git a/src/modules/CartPage/components/organisms/Modal/Modal.tsx b/src/modules/CartPage/components/organisms/Modal/Modal.tsx
new file mode 100644
index 00000000000..bc1de9a512f
--- /dev/null
+++ b/src/modules/CartPage/components/organisms/Modal/Modal.tsx
@@ -0,0 +1,111 @@
+import React, { useEffect, useState } from 'react';
+import styles from './Modal.module.scss';
+import ReactDOM from 'react-dom';
+import { CloseIcon } from '../../../../../assets/icons/close-icon';
+import { Icon } from '../../../../shared/atoms/Icon';
+import classNames from 'classnames';
+import { Typography } from '../../../../shared/atoms/Typography';
+import { useTranslation } from 'react-i18next';
+import { PageMessage } from '../../../../shared/molecules/PageMessage';
+import { IconButton } from '../../../../shared/atoms/IconButton';
+import { Button } from '../../../../shared/atoms/Button';
+
+type ModalProps = {
+ isOpen: boolean;
+ onClose: () => void;
+ onConfirm: () => void;
+};
+
+export const Modal: React.FC = ({
+ isOpen,
+ onClose,
+ onConfirm,
+}: ModalProps) => {
+ const [isLoading, setIsLoading] = useState(false);
+
+ useEffect(() => {
+ if (isOpen) {
+ setIsLoading(true);
+ const timeout = setTimeout(() => {
+ setIsLoading(false);
+ }, 400);
+
+ return () => clearTimeout(timeout);
+ }
+ }, [isOpen]);
+
+ const handleBackdropClick = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ onClose();
+ };
+
+ const handleModalClick = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ };
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ onClose();
+ }
+ };
+
+ document.addEventListener('keydown', handleKeyDown);
+
+ return () => document.removeEventListener('keydown', handleKeyDown);
+ }, [onClose]);
+
+ const { t } = useTranslation();
+
+ return ReactDOM.createPortal(
+
+
+ {isLoading ? (
+
+ ) : (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ {t('buttons.actions.yes')}
+
+
+
+
+
+ {t('buttons.actions.cancel')}
+
+
+
+ >
+ )}
+
+
,
+ document.body,
+ );
+};
diff --git a/src/modules/CartPage/components/organisms/Modal/index.tsx b/src/modules/CartPage/components/organisms/Modal/index.tsx
new file mode 100644
index 00000000000..cb89ee17889
--- /dev/null
+++ b/src/modules/CartPage/components/organisms/Modal/index.tsx
@@ -0,0 +1 @@
+export * from './Modal';
diff --git a/src/modules/CartPage/index.ts b/src/modules/CartPage/index.ts
new file mode 100644
index 00000000000..90c010237a0
--- /dev/null
+++ b/src/modules/CartPage/index.ts
@@ -0,0 +1 @@
+export * from './CartPage';
diff --git a/src/modules/CatalogPage/CatalogPage.tsx b/src/modules/CatalogPage/CatalogPage.tsx
new file mode 100644
index 00000000000..2feb9f88f66
--- /dev/null
+++ b/src/modules/CatalogPage/CatalogPage.tsx
@@ -0,0 +1,51 @@
+import React, { useEffect } from 'react';
+import { CategoryTemplate } from '../shared/templates/CategoryTemplate';
+import { useSearchParams } from 'react-router-dom';
+import { DefaultValues } from '../../enums/DefaultValues';
+import { ItemPerPage } from '../../enums/ItemsPerPage';
+import { SearchParam } from '../../enums/SearchFields';
+import { useAppSelector } from '../../hooks/hooks';
+import { useFilteredProducts } from '../../hooks/useFilteredProducts';
+import { usePaginatedProducts } from '../../hooks/usePaginatedProducts';
+
+type Props = {
+ category: string;
+};
+
+export const CatalogPage: React.FC = ({ category }) => {
+ const { products } = useAppSelector(state => state.products);
+ const [searchParams] = useSearchParams();
+
+ const query = searchParams.get(SearchParam.Query) || DefaultValues.Query;
+ const sortBy = searchParams.get(SearchParam.Sort) || DefaultValues.Sort;
+ const perPage =
+ searchParams.get(SearchParam.PerPage) || DefaultValues.PerPage;
+ const page = +(searchParams.get(SearchParam.Page) || DefaultValues.Page);
+
+ const filteredProducts = useFilteredProducts(
+ products,
+ category,
+ query,
+ sortBy,
+ );
+
+ const { paginated: paginatedProducts, totalPages } = usePaginatedProducts(
+ filteredProducts,
+ perPage === ItemPerPage.All ? ItemPerPage.All : parseInt(perPage, 10),
+ page,
+ );
+
+ useEffect(() => {
+ window.scrollTo({ behavior: 'smooth', top: 0 });
+ }, [page]);
+
+ return (
+
+ );
+};
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/FavouritesPage/FavouritesPage.module.scss b/src/modules/FavouritesPage/FavouritesPage.module.scss
new file mode 100644
index 00000000000..0f9aa121257
--- /dev/null
+++ b/src/modules/FavouritesPage/FavouritesPage.module.scss
@@ -0,0 +1,28 @@
+@use '@shared/styles/utils/mixins' as *;
+
+.page {
+ opacity: 0;
+ animation: fade-in var(--animation-duration, 0.3s) ease forwards;
+
+ &__content {
+ display: flex;
+ flex-direction: column;
+ gap: 32px;
+
+ @include on-tablet {
+ gap: 40px;
+ }
+ }
+}
+
+@keyframes fade-in {
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes fade-in {
+ to {
+ opacity: 1;
+ }
+}
diff --git a/src/modules/FavouritesPage/FavouritesPage.tsx b/src/modules/FavouritesPage/FavouritesPage.tsx
new file mode 100644
index 00000000000..a0ac7756b5f
--- /dev/null
+++ b/src/modules/FavouritesPage/FavouritesPage.tsx
@@ -0,0 +1,61 @@
+import React, { useEffect } from 'react';
+import styles from './FavouritesPage.module.scss';
+import { useAppSelector } from '../../hooks/hooks';
+import { useSearchParams } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+import { DefaultValues } from '../../enums/DefaultValues';
+import { SearchParam } from '../../enums/SearchFields';
+import { useFilteredProducts } from '../../hooks/useFilteredProducts';
+import { ProductList } from '../shared/organisms/ProductList';
+import { Heading } from '../shared/molecules/Heading';
+import { PageMessage } from '../shared/molecules/PageMessage';
+import { Breadcrumbs } from '../Breadcrumbs';
+
+export const FavouritesPage: React.FC = () => {
+ const { favourites: products } = useAppSelector(state => state.favourites);
+ const [searchParams] = useSearchParams();
+
+ const query = searchParams.get(SearchParam.Query) || DefaultValues.Query;
+ const favourites = useFilteredProducts(products, '', query, '');
+
+ const { t } = useTranslation();
+
+ useEffect(() => {
+ if (favourites.length === 0) {
+ document.body.style.overflow = 'hidden';
+ } else {
+ document.body.style.overflow = '';
+ }
+
+ return () => {
+ document.body.style.overflow = '';
+ };
+ }, [favourites.length]);
+
+ return (
+ <>
+ 0} />
+
+
+
0 && {
+ subtitle: t('catalog.subtitle.items', {
+ count: favourites.length,
+ }),
+ })}
+ />
+
+ {favourites.length > 0 ? (
+
+ ) : (
+
+ )}
+
+
+ >
+ );
+};
diff --git a/src/modules/FavouritesPage/index.ts b/src/modules/FavouritesPage/index.ts
new file mode 100644
index 00000000000..4663a941305
--- /dev/null
+++ b/src/modules/FavouritesPage/index.ts
@@ -0,0 +1 @@
+export * from './FavouritesPage';
diff --git a/src/modules/HomePage/HomePage.module.scss b/src/modules/HomePage/HomePage.module.scss
new file mode 100644
index 00000000000..ee4cf3c9c22
--- /dev/null
+++ b/src/modules/HomePage/HomePage.module.scss
@@ -0,0 +1,65 @@
+@use '@shared/styles/utils/mixins' as *;
+
+.page {
+ @include on-tablet {
+ padding-top: 8px;
+ }
+
+ @include on-small-desktop {
+ padding-top: 32px;
+ }
+
+ &__title {
+ margin-bottom: 24px;
+
+ @include on-tablet {
+ margin-bottom: 32px;
+ }
+
+ @include on-small-desktop {
+ margin-bottom: 56px;
+ }
+ }
+
+ &__content {
+ display: flex;
+ flex-direction: column;
+ opacity: 0;
+ animation: fade-in var(--animation-duration, 0.3s) ease forwards;
+ }
+}
+
+@keyframes fade-in {
+ to {
+ opacity: 1;
+ }
+}
+
+.home_content {
+ display: flex;
+ flex-direction: column;
+
+ gap: 56px;
+
+ @include on-tablet {
+ gap: 64px;
+ }
+
+ @include on-small-desktop {
+ gap: 80px;
+ }
+
+ &__item:first-of-type {
+ margin-inline: -16px;
+
+ @include on-tablet {
+ margin-inline: 0;
+ }
+ }
+}
+
+.hidden {
+ position: absolute;
+ opacity: 0;
+ pointer-events: none;
+}
diff --git a/src/modules/HomePage/HomePage.tsx b/src/modules/HomePage/HomePage.tsx
new file mode 100644
index 00000000000..4b93eafffd6
--- /dev/null
+++ b/src/modules/HomePage/HomePage.tsx
@@ -0,0 +1,64 @@
+/* eslint-disable max-len */
+import React, { useMemo } from 'react';
+import styles from './HomePage.module.scss';
+import { useAppSelector } from '../../hooks/hooks';
+import { useTranslation } from 'react-i18next';
+import { getHotPricedProducts } from '../../helpers/getHotPricedProducts';
+import { getNewestExpensiveProducts } from '../../helpers/getNewestExpensiveProducts';
+import { Typography } from '../shared/atoms/Typography';
+import { ProductSlider } from '../shared/organisms/ProductSlider';
+import { MainSlider } from './components/organisms/MainSlider';
+import { HomePageCategories } from './components/organisms/HomePageCategories';
+
+export const HomePage: React.FC = () => {
+ const { products, loading } = useAppSelector(state => state.products);
+ const { t } = useTranslation();
+
+ const hotPrices = useMemo(
+ () => (loading ? [] : getHotPricedProducts(products, 10)),
+ [loading, products],
+ );
+
+ const brandNew = useMemo(
+ () => (loading ? [] : getNewestExpensiveProducts(products, 10)),
+ [loading, products],
+ );
+
+ return (
+
+
Product Catalog
+
+
+ {t('home.title')}
+
+
+
+
+
+ );
+};
diff --git a/src/modules/HomePage/components/organisms/HomePageCategories/HomePageCategories.module.scss b/src/modules/HomePage/components/organisms/HomePageCategories/HomePageCategories.module.scss
new file mode 100644
index 00000000000..01e7e64ae5c
--- /dev/null
+++ b/src/modules/HomePage/components/organisms/HomePageCategories/HomePageCategories.module.scss
@@ -0,0 +1,28 @@
+@use '@shared/styles/utils/mixins.scss' as *;
+
+.categories {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+
+ &__list {
+ @include page-grid;
+
+ row-gap: 32px;
+ }
+}
+
+.category {
+ display: flex;
+ flex-direction: column;
+
+ gap: 24px;
+
+ grid-column: span 4;
+
+ @include on-small-desktop {
+ grid-column: span 8;
+ }
+
+ @include hover(transform, scale(1.05));
+}
diff --git a/src/modules/HomePage/components/organisms/HomePageCategories/HomePageCategories.tsx b/src/modules/HomePage/components/organisms/HomePageCategories/HomePageCategories.tsx
new file mode 100644
index 00000000000..549b4ff019f
--- /dev/null
+++ b/src/modules/HomePage/components/organisms/HomePageCategories/HomePageCategories.tsx
@@ -0,0 +1,60 @@
+/* eslint-disable no-param-reassign */
+import React, { useMemo } from 'react';
+import styles from './HomePageCategories.module.scss';
+import { Typography } from '../../../../shared/atoms/Typography';
+import { useTranslation } from 'react-i18next';
+import { NavLink } from 'react-router-dom';
+import { ResponsiveImage } from '../../../../shared/atoms/ResponsiveImage';
+import { useAppSelector } from '../../../../../hooks/hooks';
+import classNames from 'classnames';
+import { Heading } from '../../../../shared/molecules/Heading';
+import { CATEGORIES } from './constants';
+
+export const HomePageCategories: React.FC = () => {
+ const { products } = useAppSelector(state => state.products);
+
+ const lengths = useMemo(() => {
+ return CATEGORIES.reduce(
+ (acc, category) => {
+ acc[category] = products.filter(
+ product => product.category === category,
+ ).length;
+
+ return acc;
+ },
+ {} as Record,
+ );
+ }, [products]);
+
+ const { t } = useTranslation();
+
+ return (
+
+
+ {t('home.categories')}
+
+
+
+ {CATEGORIES.map(category => (
+
+
+
+
+ ))}
+
+
+ );
+};
diff --git a/src/modules/HomePage/components/organisms/HomePageCategories/constants.ts b/src/modules/HomePage/components/organisms/HomePageCategories/constants.ts
new file mode 100644
index 00000000000..b2b45125021
--- /dev/null
+++ b/src/modules/HomePage/components/organisms/HomePageCategories/constants.ts
@@ -0,0 +1 @@
+export const CATEGORIES = ['phones', 'tablets', 'accessories'];
diff --git a/src/modules/HomePage/components/organisms/HomePageCategories/index.ts b/src/modules/HomePage/components/organisms/HomePageCategories/index.ts
new file mode 100644
index 00000000000..573df89f1ac
--- /dev/null
+++ b/src/modules/HomePage/components/organisms/HomePageCategories/index.ts
@@ -0,0 +1 @@
+export * from './HomePageCategories';
diff --git a/src/modules/HomePage/components/organisms/MainSlider/MainSlider.module.scss b/src/modules/HomePage/components/organisms/MainSlider/MainSlider.module.scss
new file mode 100644
index 00000000000..f9777886584
--- /dev/null
+++ b/src/modules/HomePage/components/organisms/MainSlider/MainSlider.module.scss
@@ -0,0 +1,56 @@
+@use '@shared/styles/utils/mixins.scss' as *;
+
+.sliderButton {
+ width: 32px;
+}
+
+.slider {
+ @include page-grid;
+
+ row-gap: 18px;
+
+ & .sliderButton {
+ display: none;
+
+ @include on-tablet {
+ display: flex;
+ }
+ }
+
+ &__pagination {
+ grid-column: 1 / -1;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 20px;
+
+ --swiper-pagination-bullet-width: 14px !important;
+ --swiper-pagination-bullet-height: 4px;
+ --swiper-pagination-bullet-border-radius: 0;
+ --swiper-pagination-bullet-horizontal-gap: 0;
+ --swiper-pagination-color: var(--main-text-color-primary);
+ --swiper-pagination-bullet-inactive-color: var(--elements-color);
+
+ &__bullet {
+ display: block;
+ cursor: pointer;
+ transition: background-color var(--animation-duration, 0.3s);
+ }
+ }
+}
+
+.sliderContent {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+
+ grid-column: 1 / -1;
+
+ @include on-tablet {
+ grid-column: 2 / -2;
+ }
+}
+
+.swiper {
+ width: 100%;
+}
diff --git a/src/modules/HomePage/components/organisms/MainSlider/MainSlider.tsx b/src/modules/HomePage/components/organisms/MainSlider/MainSlider.tsx
new file mode 100644
index 00000000000..c61e76ead56
--- /dev/null
+++ b/src/modules/HomePage/components/organisms/MainSlider/MainSlider.tsx
@@ -0,0 +1,81 @@
+import React from 'react';
+import styles from './MainSlider.module.scss';
+import { ArrowButton } from '../../../../shared/atoms/ArrowButton';
+import { Swiper, SwiperSlide } from 'swiper/react';
+import { Navigation, Pagination, Autoplay } from 'swiper/modules';
+import { ResponsiveImage } from '../../../../shared/atoms/ResponsiveImage';
+import classNames from 'classnames';
+
+export const MainSlider: React.FC = () => {
+ return (
+
+
+
+
+ {
+ return ``;
+ },
+ }}
+ rewind
+ className={styles.swiper}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/modules/HomePage/components/organisms/MainSlider/index.ts b/src/modules/HomePage/components/organisms/MainSlider/index.ts
new file mode 100644
index 00000000000..c00b339d03a
--- /dev/null
+++ b/src/modules/HomePage/components/organisms/MainSlider/index.ts
@@ -0,0 +1 @@
+export * from './MainSlider';
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..e24ae00e754
--- /dev/null
+++ b/src/modules/NotFoundPage/NotFoundPage.module.scss
@@ -0,0 +1,11 @@
+@use '@shared/styles/utils/mixins.scss' as *;
+
+.notfoundpage {
+ &__back {
+ margin-bottom: 24px;
+
+ @include on-tablet {
+ margin-bottom: 16px;
+ }
+ }
+}
diff --git a/src/modules/NotFoundPage/NotFoundPage.tsx b/src/modules/NotFoundPage/NotFoundPage.tsx
new file mode 100644
index 00000000000..3ea538ce1cf
--- /dev/null
+++ b/src/modules/NotFoundPage/NotFoundPage.tsx
@@ -0,0 +1,24 @@
+import React from 'react';
+import styles from './NotFoundPage.module.scss';
+import { BackButton } from '../shared/atoms/BackButton';
+import { PageMessage } from '../shared/molecules/PageMessage';
+import { useTranslation } from 'react-i18next';
+import { Heading } from '../shared/molecules/Heading';
+
+export const NotFoundPage: React.FC = () => {
+ const { t } = useTranslation();
+
+ 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/NotificationToast/NotificationToast.module.scss b/src/modules/NotificationToast/NotificationToast.module.scss
new file mode 100644
index 00000000000..29deaee0293
--- /dev/null
+++ b/src/modules/NotificationToast/NotificationToast.module.scss
@@ -0,0 +1,33 @@
+@use '@shared/styles/utils/mixins.scss' as *;
+
+.toast {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ max-width: 320px;
+
+ box-sizing: border-box;
+
+ padding: 16px;
+
+ border-radius: 16px;
+
+ border: 2px solid var(--buttons-background-active);
+
+ background-color: var(--main-background-color);
+
+ &__head {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 16px;
+ }
+
+ &__dismiss {
+ background-color: transparent;
+
+ color: var(--main-text-color-primary);
+
+ @include hover(color, var(--main-text-color-secondary));
+ }
+}
diff --git a/src/modules/NotificationToast/NotificationToast.tsx b/src/modules/NotificationToast/NotificationToast.tsx
new file mode 100644
index 00000000000..59cb631eeb7
--- /dev/null
+++ b/src/modules/NotificationToast/NotificationToast.tsx
@@ -0,0 +1,48 @@
+import React from 'react';
+import styles from './NotificationToast.module.scss';
+import { toast as sonnerToast } from 'sonner';
+import { IconButton } from '../shared/atoms/IconButton';
+import { Icon } from '../shared/atoms/Icon';
+import { CloseIcon } from '../../assets/icons/close-icon';
+import { Typography } from '../shared/atoms/Typography';
+import { useTranslation } from 'react-i18next';
+
+interface ToastProps {
+ id: string | number;
+ description: string;
+}
+
+export const NotificationToast: React.FC = ({
+ description,
+ id,
+}) => {
+ const { t } = useTranslation();
+
+ return (
+
+
+ {t('notification.title')}
+
+ sonnerToast.dismiss(id)}
+ className={styles.toast__dismiss}
+ >
+
+
+
+
+
+
+
+ {description}
+
+
+ );
+};
+
+export function showToast(data: Omit) {
+ return sonnerToast.custom(id => (
+
+ ));
+}
diff --git a/src/modules/NotificationToast/index.ts b/src/modules/NotificationToast/index.ts
new file mode 100644
index 00000000000..2c867313fd3
--- /dev/null
+++ b/src/modules/NotificationToast/index.ts
@@ -0,0 +1 @@
+export * from './NotificationToast';
diff --git a/src/modules/ProductPage/ProductPage.module.scss b/src/modules/ProductPage/ProductPage.module.scss
new file mode 100644
index 00000000000..42e7e5cb4b2
--- /dev/null
+++ b/src/modules/ProductPage/ProductPage.module.scss
@@ -0,0 +1,275 @@
+@use '@shared/styles/utils/mixins.scss' as *;
+
+.page {
+ box-sizing: border-box;
+
+ &__back {
+ margin-bottom: 16px;
+ }
+}
+
+.title {
+ margin-bottom: 32px;
+
+ @include on-tablet {
+ margin-bottom: 40px;
+ }
+}
+
+.product {
+ @include page-grid;
+
+ row-gap: 56px;
+
+ @include on-tablet {
+ row-gap: 64px;
+ }
+
+ @include on-small-desktop {
+ row-gap: 80px;
+ }
+
+ &__block {
+ height: fit-content;
+ }
+
+ &__slider,
+ &__image,
+ &__interactive,
+ &__description,
+ &__gallery,
+ &__specifications,
+ &__recommendations {
+ grid-column: 1 / -1;
+ }
+
+ &__gallery {
+ row-gap: 16px;
+
+ @include page-grid;
+
+ --columns: 4;
+
+ @include on-tablet {
+ --columns: 7;
+
+ grid-column: span var(--columns);
+ }
+
+ @include on-small-desktop {
+ --columns: 12;
+
+ grid-column: span var(--columns);
+ }
+ }
+
+ &__slider {
+ display: flex;
+
+ overflow: hidden;
+
+ gap: 8px;
+
+ @include on-tablet {
+ grid-column: span 1;
+ flex-direction: column;
+ order: -2;
+ }
+
+ @include on-small-desktop {
+ grid-column: span 2;
+ }
+ &__img {
+ box-sizing: border-box;
+
+ border: 1px solid var(--main-text-color-secondary);
+
+ &--active {
+ border-color: var(--main-text-color-primary);
+ }
+
+ @include hover(border-color, var(--main-text-color-primary));
+
+ cursor: pointer;
+
+ display: block;
+
+ object-fit: contain;
+
+ width: 100%;
+ height: 100%;
+
+ aspect-ratio: 1/1;
+ }
+ }
+
+ &__image {
+ order: -2;
+
+ aspect-ratio: 1;
+
+ @include on-tablet {
+ grid-column: span 6;
+ }
+
+ @include on-small-desktop {
+ grid-column: span 10;
+ }
+ &__img {
+ display: block;
+
+ object-fit: contain;
+
+ width: 100%;
+
+ aspect-ratio: 1/1;
+ }
+ }
+
+ &__colors {
+ @include on-tablet {
+ grid-column: span 5;
+ }
+
+ @include on-small-desktop {
+ grid-column: 14 / span 11;
+ }
+ }
+
+ &__interactive {
+ @include page-grid;
+
+ --columns: 4;
+
+ @include on-tablet {
+ --columns: 5;
+
+ grid-column: span 5;
+ }
+
+ @include on-small-desktop {
+ --columns: 11;
+
+ grid-column: 14 / span 11;
+ }
+ }
+
+ &__description {
+ @include on-small-desktop {
+ grid-column: span 12;
+ }
+ }
+
+ &__specifications {
+ @include on-small-desktop {
+ grid-column: 14 / span 11;
+ }
+ }
+}
+
+.interactive {
+ @include page-grid;
+
+ @include on-tablet {
+ --columns: 5;
+ }
+
+ @include on-small-desktop {
+ --columns: 11;
+ }
+
+ height: fit-content;
+
+ row-gap: 24px;
+
+ &__heading {
+ display: flex;
+
+ justify-content: space-between;
+ }
+
+ &__colors {
+ grid-column: 1 / -1;
+
+ display: flex;
+
+ flex-direction: column;
+
+ gap: 8px;
+ }
+
+ &__buttons {
+ grid-column: 1 / -1;
+
+ @include on-small-desktop {
+ grid-column: span 7;
+ }
+
+ display: flex;
+ flex-direction: column;
+ gap: 32px;
+
+ &_up {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ }
+
+ &_down {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+}
+
+ &__list {
+ display: flex;
+
+ flex-wrap: wrap;
+
+ gap: 8px;
+ }
+
+ &__priceblock {
+ display: flex;
+
+ flex-direction: column;
+
+ gap: 16px;
+ }
+
+ &__capacity {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+}
+
+.about {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+
+ &__body {
+ display: flex;
+ flex-direction: column;
+ gap: 32px;
+ }
+
+ &__specs {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+}
+
+.article {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.color {
+ width: 32px;
+ aspect-ratio: 1;
+}
diff --git a/src/modules/ProductPage/ProductPage.tsx b/src/modules/ProductPage/ProductPage.tsx
new file mode 100644
index 00000000000..d265e07d0f3
--- /dev/null
+++ b/src/modules/ProductPage/ProductPage.tsx
@@ -0,0 +1,86 @@
+/* eslint-disable max-len */
+import React, { useEffect } from 'react';
+import styles from './ProductPage.module.scss';
+import { useParams } from 'react-router-dom';
+import { generateDeviceModel } from '../../helpers/generateDeviceModel';
+import { useAppSelector } from '../../hooks/hooks';
+import { Product } from '../../types/Product';
+import { ProductDetails } from '../../types/ProductDetails';
+import { Breadcrumbs } from '../Breadcrumbs';
+import { BackButton } from '../shared/atoms/BackButton';
+import { Typography } from '../shared/atoms/Typography';
+import { PageLoader } from '../shared/molecules/PageLoader';
+import { RetryErrorMessage } from '../shared/organisms/RetryErrorMessage';
+import { ProductRecommendations } from './components/organisms/ProductRecommendations';
+import { ProductFullSpecs } from './components/organisms/ProductFullSpecs';
+import { ProductDescription } from './components/organisms/ProductDescription';
+import { ProductInfoSection } from './components/organisms/ProductInfoSection';
+import { ProductGallery } from './components/organisms/ProductGallery';
+import { PageMessage } from '../shared/molecules/PageMessage';
+
+export const ProductPage: React.FC = () => {
+ const { productId } = useParams();
+
+ const { products, error } = useAppSelector(state => state.products);
+
+ const selectedProduct = products.find(
+ product => product.itemId === productId,
+ ) as Product;
+
+ const categoryState = useAppSelector(
+ state => state[selectedProduct?.category],
+ );
+
+ const productDetails = categoryState?.productList?.find(
+ product => product.id === productId,
+ ) as ProductDetails;
+
+ const productModel = generateDeviceModel(productId!);
+
+ useEffect(() => {
+ window.scrollTo(0, 0);
+ }, [productId]);
+
+ if (error) {
+ return ;
+ }
+
+ if (!selectedProduct || !productDetails) {
+ return ;
+ }
+
+ return (
+
+
+
+
+ {productDetails?.name} ({productModel})
+
+
+ {!productDetails ? (
+
+ ) : (
+
+ )}
+
+ );
+};
diff --git a/src/modules/ProductPage/components/atoms/CapacityButton/CapacityButton.module.scss b/src/modules/ProductPage/components/atoms/CapacityButton/CapacityButton.module.scss
new file mode 100644
index 00000000000..8ac6cc7fa78
--- /dev/null
+++ b/src/modules/ProductPage/components/atoms/CapacityButton/CapacityButton.module.scss
@@ -0,0 +1,23 @@
+@use '@shared/styles/utils/mixins.scss' as *;
+
+.capacity {
+ border: 1px solid var(--icons-color);
+ background-color: transparent;
+
+ // isolation: isolate;
+
+ &__text {
+ color: var(--buttons-text-color-primary);
+ }
+
+ &--active {
+ background-color: var(--buttons-text-color-primary);
+ }
+
+ &--active &__text,
+ &:hover &__text {
+ color: var(--main-background-color);
+ }
+
+ @include hover(background-color, var(--buttons-text-color-primary));
+}
diff --git a/src/modules/ProductPage/components/atoms/CapacityButton/CapacityButton.tsx b/src/modules/ProductPage/components/atoms/CapacityButton/CapacityButton.tsx
new file mode 100644
index 00000000000..e95dc7ef00b
--- /dev/null
+++ b/src/modules/ProductPage/components/atoms/CapacityButton/CapacityButton.tsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import styles from './CapacityButton.module.scss';
+import { Button, ButtonProps } from '../../../../shared/atoms/Button';
+import classNames from 'classnames';
+import { Typography } from '../../../../shared/atoms/Typography';
+
+type Props = ButtonProps & {
+ isActive: boolean;
+ isAvailable: boolean;
+};
+
+export const CapacityButton: React.FC = ({
+ className,
+ onClick,
+ children,
+ isActive,
+ isAvailable,
+ ...props
+}) => {
+ return (
+
+
+ {children}
+
+
+ );
+};
diff --git a/src/modules/ProductPage/components/atoms/CapacityButton/index.ts b/src/modules/ProductPage/components/atoms/CapacityButton/index.ts
new file mode 100644
index 00000000000..d4893e619b8
--- /dev/null
+++ b/src/modules/ProductPage/components/atoms/CapacityButton/index.ts
@@ -0,0 +1 @@
+export * from './CapacityButton';
diff --git a/src/modules/ProductPage/components/atoms/ColorButton/ColorButton.module.scss b/src/modules/ProductPage/components/atoms/ColorButton/ColorButton.module.scss
new file mode 100644
index 00000000000..eb083d9ce24
--- /dev/null
+++ b/src/modules/ProductPage/components/atoms/ColorButton/ColorButton.module.scss
@@ -0,0 +1,63 @@
+@use '@shared/styles/utils/mixins.scss' as *;
+
+$colors: (
+ 'black': #000,
+ 'white': #f5f5f7,
+ 'silver': #e4e4e2,
+ 'gold': #f5ddc5,
+ 'rosegold': #f6c8be,
+ 'yellow': #ffe681,
+ 'red': #ff3b30,
+ 'purple': #d1cdda,
+ 'green': #aee1cd,
+ 'coral': #ff725f,
+ 'midnightgreen': #4e5851,
+ 'graphite': #535150,
+ 'sierrablue': #9bb5ce,
+ 'blue': #007aff,
+ 'pink': #ffb6c1,
+ 'starlight': #f8f9f2,
+ 'midnight': #171e27,
+ 'skyblue': #87ceeb,
+ 'spacegray': #7d7d7d,
+ 'spaceblack': #1b1b1b,
+);
+
+.color-button {
+ border-radius: 50%;
+ padding: 2px;
+ overflow: hidden;
+ border: 2px solid;
+ background-color: transparent;
+ outline-offset: -3px;
+ border-color: var(--main-text-color-secondary);
+ position: relative;
+
+ @include hover(border-color, var(--main-text-color-primary));
+
+ &--unavailable::after {
+ content: '';
+ background-color: red;
+ position: absolute;
+ z-index: 1;
+ height: 2px;
+ width: 100%;
+ transform: rotate(-45deg);
+ }
+
+ &--active {
+ border-color: var(--main-text-color-primary);
+ }
+
+ &__color {
+ width: 100%;
+ aspect-ratio: 1;
+ border-radius: 50%;
+
+ @each $name, $color in $colors {
+ &--#{$name} {
+ background-color: $color;
+ }
+ }
+ }
+}
diff --git a/src/modules/ProductPage/components/atoms/ColorButton/ColorButton.tsx b/src/modules/ProductPage/components/atoms/ColorButton/ColorButton.tsx
new file mode 100644
index 00000000000..98c2cc55fa0
--- /dev/null
+++ b/src/modules/ProductPage/components/atoms/ColorButton/ColorButton.tsx
@@ -0,0 +1,38 @@
+import classNames from 'classnames';
+import React from 'react';
+import styles from './ColorButton.module.scss';
+import { ButtonProps } from '../../../../shared/atoms/Button';
+import { IconButton } from '../../../../shared/atoms/IconButton';
+
+type ColorButtonProps = Omit & {
+ color: string;
+ isActive: boolean;
+ isNotAvailable: boolean;
+};
+
+export const ColorButton = ({
+ color,
+ isActive,
+ isNotAvailable,
+ className,
+ ...props
+}: ColorButtonProps) => (
+
+
+
+);
diff --git a/src/modules/ProductPage/components/atoms/ColorButton/index.ts b/src/modules/ProductPage/components/atoms/ColorButton/index.ts
new file mode 100644
index 00000000000..23a32673b2c
--- /dev/null
+++ b/src/modules/ProductPage/components/atoms/ColorButton/index.ts
@@ -0,0 +1 @@
+export * from './ColorButton';
diff --git a/src/modules/ProductPage/components/organisms/ProductDescription/ProductDescription.tsx b/src/modules/ProductPage/components/organisms/ProductDescription/ProductDescription.tsx
new file mode 100644
index 00000000000..c2c406205a1
--- /dev/null
+++ b/src/modules/ProductPage/components/organisms/ProductDescription/ProductDescription.tsx
@@ -0,0 +1,55 @@
+import React, { memo } from 'react';
+import styles from './../../../ProductPage.module.scss';
+import classNames from 'classnames';
+import { Divider } from '../../../../shared/atoms/Divider';
+import { Typography } from '../../../../shared/atoms/Typography';
+import { ProductDetails } from '../../../../../types/ProductDetails';
+import { useTranslation } from 'react-i18next';
+
+type Props = {
+ productDetails: ProductDetails;
+};
+
+const ProductDescriptionComponent: React.FC = ({ productDetails }) => {
+ const { t } = useTranslation();
+
+ return (
+
+
+ {t('product.about.title')}
+
+
+
+
+
+ {productDetails?.description.map(article => (
+
+
+ {article.title}
+
+
+ {article.text.map(paragraph => (
+
+ {paragraph}
+
+ ))}
+
+ ))}
+
+
+ );
+};
+
+export const ProductDescription = memo(ProductDescriptionComponent);
diff --git a/src/modules/ProductPage/components/organisms/ProductDescription/index.ts b/src/modules/ProductPage/components/organisms/ProductDescription/index.ts
new file mode 100644
index 00000000000..bc21c762d8b
--- /dev/null
+++ b/src/modules/ProductPage/components/organisms/ProductDescription/index.ts
@@ -0,0 +1 @@
+export * from './ProductDescription';
diff --git a/src/modules/ProductPage/components/organisms/ProductFullSpecs/ProductFullSpecs.tsx b/src/modules/ProductPage/components/organisms/ProductFullSpecs/ProductFullSpecs.tsx
new file mode 100644
index 00000000000..f5e6a33c4ef
--- /dev/null
+++ b/src/modules/ProductPage/components/organisms/ProductFullSpecs/ProductFullSpecs.tsx
@@ -0,0 +1,87 @@
+import React, { memo } from 'react';
+import styles from './../../../ProductPage.module.scss';
+import classNames from 'classnames';
+import { Typography } from '../../../../shared/atoms/Typography';
+import { Divider } from '../../../../shared/atoms/Divider';
+import { ProductSpec } from '../../../../shared/molecules/ProductSpec';
+import { ProductDetails } from '../../../../../types/ProductDetails';
+import { useTranslation } from 'react-i18next';
+
+type Props = {
+ productDetails: ProductDetails;
+ selectedCategory: string;
+};
+
+const ProductFullSpecsComponent: React.FC = ({
+ productDetails,
+ selectedCategory,
+}) => {
+ const { t } = useTranslation();
+
+ return (
+
+
+ {t('product.specifications.label')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {productDetails?.camera && (
+
+ )}
+
+ {productDetails?.zoom && (
+
+ )}
+
+
+
+
+
+ );
+};
+
+export const ProductFullSpecs = memo(ProductFullSpecsComponent);
diff --git a/src/modules/ProductPage/components/organisms/ProductFullSpecs/index.ts b/src/modules/ProductPage/components/organisms/ProductFullSpecs/index.ts
new file mode 100644
index 00000000000..cd3afab3fbd
--- /dev/null
+++ b/src/modules/ProductPage/components/organisms/ProductFullSpecs/index.ts
@@ -0,0 +1 @@
+export * from './ProductFullSpecs';
diff --git a/src/modules/ProductPage/components/organisms/ProductGallery/ProductGallery.tsx b/src/modules/ProductPage/components/organisms/ProductGallery/ProductGallery.tsx
new file mode 100644
index 00000000000..4ff22e2d92c
--- /dev/null
+++ b/src/modules/ProductPage/components/organisms/ProductGallery/ProductGallery.tsx
@@ -0,0 +1,46 @@
+import React, { useEffect, useState } from 'react';
+
+import styles from './../../../ProductPage.module.scss';
+import classNames from 'classnames';
+import { ProductDetails } from '../../../../../types/ProductDetails';
+import { Image } from '../../../../shared/atoms/Image';
+
+type Props = {
+ productDetails: ProductDetails;
+};
+
+export const ProductGallery: React.FC = ({ productDetails }) => {
+ const [selectedImage, setSelectedImage] = useState(productDetails?.images[0]);
+
+ useEffect(
+ () => setSelectedImage(productDetails.images[0]),
+ [productDetails?.images],
+ );
+
+ return (
+
+
+ {productDetails?.images.map(image => (
+
setSelectedImage(image)}>
+
+
+ ))}
+
+
+
+
+
+ );
+};
diff --git a/src/modules/ProductPage/components/organisms/ProductGallery/index.ts b/src/modules/ProductPage/components/organisms/ProductGallery/index.ts
new file mode 100644
index 00000000000..b74eb50e7e5
--- /dev/null
+++ b/src/modules/ProductPage/components/organisms/ProductGallery/index.ts
@@ -0,0 +1 @@
+export * from './ProductGallery';
diff --git a/src/modules/ProductPage/components/organisms/ProductInfoSection/ProductInfoSection.tsx b/src/modules/ProductPage/components/organisms/ProductInfoSection/ProductInfoSection.tsx
new file mode 100644
index 00000000000..3a42c52998a
--- /dev/null
+++ b/src/modules/ProductPage/components/organisms/ProductInfoSection/ProductInfoSection.tsx
@@ -0,0 +1,197 @@
+/* eslint-disable max-len */
+import React, { useMemo } from 'react';
+import styles from './../../../ProductPage.module.scss';
+import classNames from 'classnames';
+import { HeartFilledIcon } from '../../../../../assets/icons/heart-filled-icon';
+import { HeartIcon } from '../../../../../assets/icons/heart-icon';
+import { Icon } from '../../../../shared/atoms/Icon';
+import { Divider } from '../../../../shared/atoms/Divider';
+import { Typography } from '../../../../shared/atoms/Typography';
+import { ProductControls } from '../../../../shared/molecules/ProductControls';
+import { ProductPrice } from '../../../../shared/molecules/ProductPrice';
+import { ProductSpec } from '../../../../shared/molecules/ProductSpec';
+import { CapacityButton } from '../../atoms/CapacityButton';
+import { ColorButton } from '../../atoms/ColorButton/ColorButton';
+import { ProductDetails } from '../../../../../types/ProductDetails';
+import { useAppDispatch, useAppSelector } from '../../../../../hooks/hooks';
+import { Product } from '../../../../../types/Product';
+import { getVariantOptions } from '../../../../../helpers/getAvailabilityProducts';
+import { toggleFavourite } from '../../../../../features/favouritesSlice';
+import { add } from '../../../../../features/cartSlice';
+import { useNavigate } from 'react-router-dom';
+import { generateDeviceModel } from '../../../../../helpers/generateDeviceModel';
+import { useTranslation } from 'react-i18next';
+import { showToast } from '../../../../NotificationToast';
+import { normalize } from '../../../../../helpers/getAvailabilityProducts';
+
+type Props = {
+ productDetails: ProductDetails;
+ selectedProduct: Product;
+};
+
+export const ProductInfoSection: React.FC = ({
+ productDetails,
+ selectedProduct,
+}) => {
+ const dispatch = useAppDispatch();
+ const navigate = useNavigate();
+
+ const { t } = useTranslation();
+
+ const { favourites } = useAppSelector(state => state.favourites);
+ const { cartItems } = useAppSelector(state => state.cart);
+ const { products } = useAppSelector(state => state.products);
+
+ const isInFavourites = useMemo(
+ () => favourites.some(fav => fav.itemId === selectedProduct.itemId),
+ [favourites, selectedProduct],
+ );
+ const isInCart = useMemo(
+ () =>
+ cartItems.some(cartItem => cartItem.itemId === selectedProduct.itemId),
+ [cartItems, selectedProduct],
+ );
+
+ const { colorOptions, capacityOptions } = useMemo(
+ () => getVariantOptions(productDetails, products),
+ [products, productDetails],
+ );
+
+ const switchVariant = (newColor: string, newCapacity: string) => {
+ const newItemId = `${productDetails.namespaceId}-${normalize(newCapacity)}-${normalize(newColor)}`;
+
+ if (products.find(product => product.itemId === newItemId)) {
+ navigate(`/product/${newItemId}`);
+ }
+ };
+
+ const handleToggle = (event: React.MouseEvent) => {
+ event.stopPropagation();
+ dispatch(toggleFavourite(selectedProduct));
+ showToast({
+ description: t(
+ `notification.${isInFavourites ? 'remove' : 'add'}.favourites`,
+ { name: selectedProduct.name },
+ ),
+ });
+ };
+
+ const addToCart = (event: React.MouseEvent) => {
+ event.stopPropagation();
+ dispatch(add(selectedProduct));
+ showToast({
+ description: t(`notification.${isInCart ? 'remove' : 'add'}.cart`, {
+ name: selectedProduct.name,
+ }),
+ });
+ };
+
+ const productModel = generateDeviceModel(selectedProduct.itemId);
+
+ const normalizeColor = (color: string) =>
+ color.toLowerCase().replace(/\s+/g, '-');
+
+ return (
+
+
+
+
+ {t('buttons.actions.product.colors')}
+
+
+
+ ID: {productModel}
+
+
+
+ {colorOptions.map(({ color, available }) => (
+ switchVariant(color, productDetails.capacity)}
+ />
+ ))}
+
+
+
+
+
+
+
+
+ {t('buttons.actions.product.capacity')}
+
+
+ {capacityOptions.map(({ capacity, available }) => (
+ switchVariant(productDetails.color, capacity)}
+ >
+ {capacity}
+
+ ))}
+
+
+
+
+
+
+
+
+ {isInFavourites ? : }
+
+ }
+ />
+
+
+
+
+
+ );
+};
diff --git a/src/modules/ProductPage/components/organisms/ProductInfoSection/index.ts b/src/modules/ProductPage/components/organisms/ProductInfoSection/index.ts
new file mode 100644
index 00000000000..3c2909bc52f
--- /dev/null
+++ b/src/modules/ProductPage/components/organisms/ProductInfoSection/index.ts
@@ -0,0 +1 @@
+export * from './ProductInfoSection';
diff --git a/src/modules/ProductPage/components/organisms/ProductRecommendations/ProductRecommendations.tsx b/src/modules/ProductPage/components/organisms/ProductRecommendations/ProductRecommendations.tsx
new file mode 100644
index 00000000000..e4e176514c0
--- /dev/null
+++ b/src/modules/ProductPage/components/organisms/ProductRecommendations/ProductRecommendations.tsx
@@ -0,0 +1,26 @@
+import React, { useMemo } from 'react';
+import styles from './../../../ProductPage.module.scss';
+import { ProductSlider } from '../../../../shared/organisms/ProductSlider';
+import { useTranslation } from 'react-i18next';
+import { useAppSelector } from '../../../../../hooks/hooks';
+import { getRandomProducts } from '../../../../../helpers/getRandomProducts';
+
+export const ProductRecommendations: React.FC = () => {
+ const { t } = useTranslation();
+ const { products } = useAppSelector(state => state.products);
+
+ const recommended = useMemo(
+ () => getRandomProducts(products, 12),
+ [products],
+ );
+
+ return (
+
+ );
+};
diff --git a/src/modules/ProductPage/components/organisms/ProductRecommendations/index.ts b/src/modules/ProductPage/components/organisms/ProductRecommendations/index.ts
new file mode 100644
index 00000000000..4f40fdb4bf8
--- /dev/null
+++ b/src/modules/ProductPage/components/organisms/ProductRecommendations/index.ts
@@ -0,0 +1 @@
+export * from './ProductRecommendations';
diff --git a/src/modules/ProductPage/index.ts b/src/modules/ProductPage/index.ts
new file mode 100644
index 00000000000..875dce3d23c
--- /dev/null
+++ b/src/modules/ProductPage/index.ts
@@ -0,0 +1 @@
+export * from './ProductPage';
diff --git a/src/modules/shared/atoms/ArrowButton/ArrowButton.module.scss b/src/modules/shared/atoms/ArrowButton/ArrowButton.module.scss
new file mode 100644
index 00000000000..03284fd35d4
--- /dev/null
+++ b/src/modules/shared/atoms/ArrowButton/ArrowButton.module.scss
@@ -0,0 +1,5 @@
+@use '@shared/styles/buttons' as *;
+
+.arrow {
+ @include control-button;
+}
diff --git a/src/modules/shared/atoms/ArrowButton/ArrowButton.tsx b/src/modules/shared/atoms/ArrowButton/ArrowButton.tsx
new file mode 100644
index 00000000000..e60d95b1b4b
--- /dev/null
+++ b/src/modules/shared/atoms/ArrowButton/ArrowButton.tsx
@@ -0,0 +1,25 @@
+import React from 'react';
+import styles from './ArrowButton.module.scss';
+import { IconButton } from '../IconButton';
+import { Icon } from '../Icon';
+import { ArrowIcon } from '../../../../assets/icons/arrow-icon';
+import { ButtonProps } from '../Button';
+import classNames from 'classnames';
+
+interface Props extends Omit {
+ direction?: 'left' | 'up' | 'right' | 'down';
+}
+
+export const ArrowButton: React.FC = ({
+ direction = 'left',
+ className,
+ ...props
+}) => {
+ return (
+
+
+
+
+
+ );
+};
diff --git a/src/modules/shared/atoms/ArrowButton/index.ts b/src/modules/shared/atoms/ArrowButton/index.ts
new file mode 100644
index 00000000000..ce91b1996e0
--- /dev/null
+++ b/src/modules/shared/atoms/ArrowButton/index.ts
@@ -0,0 +1 @@
+export * from './ArrowButton';
diff --git a/src/modules/shared/atoms/BackButton/BackButton.module.scss b/src/modules/shared/atoms/BackButton/BackButton.module.scss
new file mode 100644
index 00000000000..324c2c32dff
--- /dev/null
+++ b/src/modules/shared/atoms/BackButton/BackButton.module.scss
@@ -0,0 +1,32 @@
+@use '@shared/styles/utils/mixins.scss' as *;
+
+.back {
+ height: 16px;
+
+ &__button {
+ position: relative;
+ background-color: unset;
+ padding: 0;
+
+ .hover_effect {
+ transition: color var(--animation-duration, 0.3s);
+ }
+
+ &:hover .hover_effect {
+ color: var(--buttons-background-active);
+ cursor: pointer;
+ }
+
+ &::after {
+ position: absolute;
+ content: '';
+ inset: -8px;
+ }
+ }
+
+ &__text {
+ display: flex;
+ gap: 4px;
+ align-items: center;
+ }
+}
diff --git a/src/modules/shared/atoms/BackButton/BackButton.tsx b/src/modules/shared/atoms/BackButton/BackButton.tsx
new file mode 100644
index 00000000000..f9e80d9cef1
--- /dev/null
+++ b/src/modules/shared/atoms/BackButton/BackButton.tsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import styles from './BackButton.module.scss';
+import { useNavigate } from 'react-router-dom';
+import classNames from 'classnames';
+import { Button } from '../Button';
+import { Icon } from '../Icon';
+import { ArrowIcon } from '../../../../assets/icons/arrow-icon';
+import { Typography } from '../Typography';
+import { useTranslation } from 'react-i18next';
+
+type Props = {
+ className?: string;
+ category?: string;
+};
+
+export const BackButton: React.FC = ({ className, category }) => {
+ const navigate = useNavigate();
+ const { t } = useTranslation();
+
+ const handleClick = () => {
+ if (category) {
+ navigate(`/${category}`);
+ } else {
+ navigate(-1);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ {t('buttons.actions.back')}
+
+
+
+ );
+};
diff --git a/src/modules/shared/atoms/BackButton/index.ts b/src/modules/shared/atoms/BackButton/index.ts
new file mode 100644
index 00000000000..b68e24e8a3b
--- /dev/null
+++ b/src/modules/shared/atoms/BackButton/index.ts
@@ -0,0 +1 @@
+export * from './BackButton';
diff --git a/src/modules/shared/atoms/BadgeCounter/BadgeCounter.module.scss b/src/modules/shared/atoms/BadgeCounter/BadgeCounter.module.scss
new file mode 100644
index 00000000000..e6a1387ab16
--- /dev/null
+++ b/src/modules/shared/atoms/BadgeCounter/BadgeCounter.module.scss
@@ -0,0 +1,20 @@
+.badgecounter {
+ position: absolute;
+ top: -6px;
+ right: -6px;
+ box-sizing: border-box;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 14px;
+ height: 14px;
+ background-color: var(--red-accent-color);
+ border-radius: 50%;
+ border: 1px solid var(--main-background-color);
+
+ font-weight: 600;
+ font-size: 9px;
+ line-height: 100%;
+ letter-spacing: 0%;
+ text-align: center;
+}
diff --git a/src/modules/shared/atoms/BadgeCounter/BadgeCounter.tsx b/src/modules/shared/atoms/BadgeCounter/BadgeCounter.tsx
new file mode 100644
index 00000000000..06c6f75a248
--- /dev/null
+++ b/src/modules/shared/atoms/BadgeCounter/BadgeCounter.tsx
@@ -0,0 +1,16 @@
+import React from 'react';
+import styles from './BadgeCounter.module.scss';
+import classNames from 'classnames';
+
+type Props = {
+ children: React.ReactNode;
+ className?: string;
+};
+
+export const BadgeCounter: React.FC = ({ children, className }) => {
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/modules/shared/atoms/BadgeCounter/index.ts b/src/modules/shared/atoms/BadgeCounter/index.ts
new file mode 100644
index 00000000000..4f54e20c5a9
--- /dev/null
+++ b/src/modules/shared/atoms/BadgeCounter/index.ts
@@ -0,0 +1 @@
+export * from './BadgeCounter';
diff --git a/src/modules/shared/atoms/Button/Button.module.scss b/src/modules/shared/atoms/Button/Button.module.scss
new file mode 100644
index 00000000000..d3656a2da20
--- /dev/null
+++ b/src/modules/shared/atoms/Button/Button.module.scss
@@ -0,0 +1,53 @@
+$size-small: 32px;
+$size-medium: 40px;
+$size-large: 48px;
+
+@use '@shared/styles/utils/mixins.scss' as *;
+
+.button {
+ box-sizing: border-box;
+
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ padding-inline: 8px;
+
+ cursor: pointer;
+
+ transition: all var(--animation-duration, 0.3s) ease;
+
+ @include focus-visible;
+
+ &:disabled {
+ cursor: not-allowed;
+ }
+
+ &:active {
+ transform: scale(0.96);
+ }
+
+ &--square {
+ aspect-ratio: 1;
+ }
+
+ &--small {
+ height: $size-small;
+ }
+
+ &--medium {
+ height: $size-medium;
+ }
+
+ &--large {
+ height: $size-large;
+ }
+
+ &--fullWidth {
+ width: 100%;
+ }
+
+ &--fullHeight {
+ height: 100%;
+ }
+}
diff --git a/src/modules/shared/atoms/Button/Button.tsx b/src/modules/shared/atoms/Button/Button.tsx
new file mode 100644
index 00000000000..8774f3bc1e3
--- /dev/null
+++ b/src/modules/shared/atoms/Button/Button.tsx
@@ -0,0 +1,52 @@
+/* eslint-disable max-len */
+import classNames from 'classnames';
+import React, { forwardRef } from 'react';
+import styles from './Button.module.scss';
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes {
+ size?: 'small' | 'medium' | 'large';
+ isSquare?: boolean;
+ children: React.ReactNode;
+ className?: string;
+ fullWidth?: boolean;
+ fullHeight?: boolean;
+}
+
+export const Button = forwardRef(
+ (
+ {
+ size = 'medium',
+ disabled = false,
+ onClick,
+ isSquare = false,
+ children,
+ className,
+ fullWidth = false,
+ fullHeight = false,
+ ...rest
+ },
+ ref,
+ ) => (
+
+ {children}
+
+ ),
+);
+
+Button.displayName = 'Button';
diff --git a/src/modules/shared/atoms/Button/index.ts b/src/modules/shared/atoms/Button/index.ts
new file mode 100644
index 00000000000..8b166a86e4d
--- /dev/null
+++ b/src/modules/shared/atoms/Button/index.ts
@@ -0,0 +1 @@
+export * from './Button';
diff --git a/src/modules/shared/atoms/Divider/Divider.module.scss b/src/modules/shared/atoms/Divider/Divider.module.scss
new file mode 100644
index 00000000000..93ebf67c23f
--- /dev/null
+++ b/src/modules/shared/atoms/Divider/Divider.module.scss
@@ -0,0 +1,4 @@
+.divider {
+ height: 1px;
+ background-color: var(--elements-color, white);
+}
diff --git a/src/modules/shared/atoms/Divider/Divider.tsx b/src/modules/shared/atoms/Divider/Divider.tsx
new file mode 100644
index 00000000000..0e89ce962d4
--- /dev/null
+++ b/src/modules/shared/atoms/Divider/Divider.tsx
@@ -0,0 +1,11 @@
+import React from 'react';
+import styles from './Divider.module.scss';
+import classNames from 'classnames';
+
+type DividerProps = {
+ className?: string;
+};
+
+export const Divider: React.FC = ({ className }) => {
+ return
;
+};
diff --git a/src/modules/shared/atoms/Divider/index.ts b/src/modules/shared/atoms/Divider/index.ts
new file mode 100644
index 00000000000..1f84888dc70
--- /dev/null
+++ b/src/modules/shared/atoms/Divider/index.ts
@@ -0,0 +1 @@
+export * from './Divider';
diff --git a/src/modules/shared/atoms/Icon/Icon.module.scss b/src/modules/shared/atoms/Icon/Icon.module.scss
new file mode 100644
index 00000000000..e2d6762dc16
--- /dev/null
+++ b/src/modules/shared/atoms/Icon/Icon.module.scss
@@ -0,0 +1,33 @@
+.icon {
+ width: 16px;
+ height: 16px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ position: relative;
+
+ &--color {
+ &--primary {
+ color: var(--buttons-text-color-primary);
+ }
+ &--secondary {
+ color: var(--icons-color);
+ }
+ &--inherit {
+ color: inherit;
+ }
+ }
+ &--arrow-left {
+ transform: rotate(0deg);
+ }
+ &--arrow-up {
+ transform: rotate(90deg);
+ }
+ &--arrow-right {
+ transform: rotate(180deg);
+ }
+ &--arrow-down {
+ transform: rotate(270deg);
+ }
+}
diff --git a/src/modules/shared/atoms/Icon/Icon.tsx b/src/modules/shared/atoms/Icon/Icon.tsx
new file mode 100644
index 00000000000..de11011e4b7
--- /dev/null
+++ b/src/modules/shared/atoms/Icon/Icon.tsx
@@ -0,0 +1,32 @@
+import classNames from 'classnames';
+import React from 'react';
+import styles from './Icon.module.scss';
+
+type Direction = 'left' | 'right' | 'down' | 'up';
+
+type Color = 'primary' | 'secondary' | 'inherit';
+
+type IconProps = {
+ children: React.ReactNode;
+ className?: string;
+ direction?: Direction;
+ color?: Color;
+};
+
+export const Icon: React.FC = ({
+ className,
+ direction = 'left',
+ color = 'primary',
+ children,
+}) => (
+
+ {children}
+
+);
diff --git a/src/modules/shared/atoms/Icon/index.ts b/src/modules/shared/atoms/Icon/index.ts
new file mode 100644
index 00000000000..e263cc0e6db
--- /dev/null
+++ b/src/modules/shared/atoms/Icon/index.ts
@@ -0,0 +1 @@
+export * from './Icon';
diff --git a/src/modules/shared/atoms/IconButton/IconButton.tsx b/src/modules/shared/atoms/IconButton/IconButton.tsx
new file mode 100644
index 00000000000..fbf7c2d69a2
--- /dev/null
+++ b/src/modules/shared/atoms/IconButton/IconButton.tsx
@@ -0,0 +1,8 @@
+import React from 'react';
+import { Button, ButtonProps } from '../Button';
+
+export const IconButton: React.FC = ({ children, ...props }) => (
+
+ {children}
+
+);
diff --git a/src/modules/shared/atoms/IconButton/index.ts b/src/modules/shared/atoms/IconButton/index.ts
new file mode 100644
index 00000000000..1a85f0f7251
--- /dev/null
+++ b/src/modules/shared/atoms/IconButton/index.ts
@@ -0,0 +1 @@
+export * from './IconButton';
diff --git a/src/modules/shared/atoms/Image/Image.tsx b/src/modules/shared/atoms/Image/Image.tsx
new file mode 100644
index 00000000000..bf059950c56
--- /dev/null
+++ b/src/modules/shared/atoms/Image/Image.tsx
@@ -0,0 +1,16 @@
+import React from 'react';
+import { useState } from 'react';
+
+type Props = React.ImgHTMLAttributes & {
+ src: string;
+};
+
+export const Image: React.FC = ({ src, ...props }) => {
+ const [isValid, setIsValid] = useState(true);
+
+ if (!isValid) {
+ return null;
+ }
+
+ return setIsValid(false)} />;
+};
diff --git a/src/modules/shared/atoms/Image/index.ts b/src/modules/shared/atoms/Image/index.ts
new file mode 100644
index 00000000000..4bbac90149c
--- /dev/null
+++ b/src/modules/shared/atoms/Image/index.ts
@@ -0,0 +1 @@
+export * from './Image';
diff --git a/src/modules/shared/atoms/NavLink/NavLink.module.scss b/src/modules/shared/atoms/NavLink/NavLink.module.scss
new file mode 100644
index 00000000000..b0bea78af70
--- /dev/null
+++ b/src/modules/shared/atoms/NavLink/NavLink.module.scss
@@ -0,0 +1,31 @@
+@use '@shared/styles/utils/mixins.scss' as *;
+
+.navlink {
+ position: relative;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 100%;
+
+ box-sizing: border-box;
+
+ padding-block: 8px;
+
+ @include underline-animate;
+
+ &__text {
+ transition: color var(--animation-duration, 0.3s);
+ }
+
+ &:hover &__text {
+ color: var(--buttons-text-color-primary);
+ }
+
+ &--active::after,
+ &:hover::after {
+ transform: scaleX(1);
+ transform-origin: left;
+ }
+
+ @include focus-visible;
+}
diff --git a/src/modules/shared/atoms/NavLink/NavLink.tsx b/src/modules/shared/atoms/NavLink/NavLink.tsx
new file mode 100644
index 00000000000..a4d6e4d8dd9
--- /dev/null
+++ b/src/modules/shared/atoms/NavLink/NavLink.tsx
@@ -0,0 +1,74 @@
+/* eslint-disable @typescript-eslint/indent */
+import {
+ NavLink as RouterNavLink,
+ NavLinkProps as RouterNavLinkProps,
+ NavLinkRenderProps,
+} from 'react-router-dom';
+import styles from './NavLink.module.scss';
+import React from 'react';
+import classNames from 'classnames';
+import { Typography } from '../Typography';
+
+export type NavLinkProps = Omit<
+ RouterNavLinkProps,
+ 'className' | 'children'
+> & {
+ className?: string;
+ children: React.ReactNode;
+ external?: boolean;
+ rel?: string;
+ target?: string;
+ to: string;
+};
+
+const getNavLinkClass = (isActive: boolean, className?: string): string =>
+ classNames(
+ styles.navlink,
+ {
+ [styles['navlink--active']]: isActive,
+ },
+ className,
+ );
+
+export const NavLink: React.FC = ({
+ to,
+ external = false,
+ target,
+ rel,
+ className,
+ children,
+}) => {
+ const content = (isActive: boolean) => (
+
+ {children}
+
+ );
+
+ if (external) {
+ return (
+
+ {content(false)}
+
+ );
+ }
+
+ return (
+
+ getNavLinkClass(isActive, className)
+ }
+ >
+ {({ isActive }) => content(isActive)}
+
+ );
+};
diff --git a/src/modules/shared/atoms/NavLink/index.ts b/src/modules/shared/atoms/NavLink/index.ts
new file mode 100644
index 00000000000..d6e7e15071b
--- /dev/null
+++ b/src/modules/shared/atoms/NavLink/index.ts
@@ -0,0 +1 @@
+export * from './NavLink';
diff --git a/src/modules/shared/atoms/ResponsiveImage/ResponsiveImage.module.scss b/src/modules/shared/atoms/ResponsiveImage/ResponsiveImage.module.scss
new file mode 100644
index 00000000000..baa0515781e
--- /dev/null
+++ b/src/modules/shared/atoms/ResponsiveImage/ResponsiveImage.module.scss
@@ -0,0 +1,6 @@
+.picture {
+ &__image {
+ display: block;
+ width: 100%;
+ }
+}
diff --git a/src/modules/shared/atoms/ResponsiveImage/ResponsiveImage.tsx b/src/modules/shared/atoms/ResponsiveImage/ResponsiveImage.tsx
new file mode 100644
index 00000000000..e77254f0b2f
--- /dev/null
+++ b/src/modules/shared/atoms/ResponsiveImage/ResponsiveImage.tsx
@@ -0,0 +1,24 @@
+import React from 'react';
+import styles from './ResponsiveImage.module.scss';
+
+interface ResponsiveImageProps {
+ alt: string;
+ desktopSrc?: string;
+ tabletSrc?: string;
+ mobileSrc: string;
+}
+
+export const ResponsiveImage: React.FC = ({
+ alt,
+ desktopSrc,
+ tabletSrc,
+ mobileSrc,
+}) => {
+ return (
+
+ {desktopSrc && }
+ {tabletSrc && }
+
+
+ );
+};
diff --git a/src/modules/shared/atoms/ResponsiveImage/index.ts b/src/modules/shared/atoms/ResponsiveImage/index.ts
new file mode 100644
index 00000000000..1807d578469
--- /dev/null
+++ b/src/modules/shared/atoms/ResponsiveImage/index.ts
@@ -0,0 +1 @@
+export * from './ResponsiveImage';
diff --git a/src/modules/shared/atoms/Typography/Typography.module.scss b/src/modules/shared/atoms/Typography/Typography.module.scss
new file mode 100644
index 00000000000..3a8f4aa07d7
--- /dev/null
+++ b/src/modules/shared/atoms/Typography/Typography.module.scss
@@ -0,0 +1,100 @@
+$bold-weight: 800;
+$semibold-weight: 600;
+$regular-weight: 400;
+
+@use './../../styles/utils/mixins.scss' as *;
+
+.typography {
+ letter-spacing: 0;
+ margin: 0;
+ text-wrap: balance;
+
+ &--h1 {
+ font-size: 32px;
+ line-height: 41px;
+ letter-spacing: -0.01em;
+ font-weight: $bold-weight;
+
+ @include on-tablet {
+ font-size: 48px;
+ line-height: 56px;
+ }
+ }
+ &--h2 {
+ font-weight: $bold-weight;
+
+ font-size: 22px;
+ line-height: 31px;
+
+ @include on-tablet {
+ font-size: 32px;
+ line-height: 41px;
+ letter-spacing: -0.01em;
+ }
+ }
+ &--h3 {
+ font-size: 20px;
+ line-height: 26px;
+ font-weight: $semibold-weight;
+
+ @include on-tablet {
+ font-size: 22px;
+ line-height: 31px;
+ font-weight: $bold-weight;
+ }
+ }
+ &--h4 {
+ font-weight: $semibold-weight;
+
+ font-size: 16px;
+ line-height: 20px;
+
+ @include on-tablet {
+ font-size: 20px;
+ line-height: 26px;
+ }
+ }
+ &--uppercase {
+ font-size: 12px;
+ line-height: 11px;
+ letter-spacing: 0.04em;
+ font-weight: $bold-weight;
+ text-transform: uppercase;
+ }
+ &--buttons {
+ font-size: 14px;
+ line-height: 21px;
+ font-weight: $semibold-weight;
+ }
+ &--body {
+ font-size: 14px;
+ line-height: 21px;
+ font-weight: $regular-weight;
+ }
+ &--small {
+ font-size: 12px;
+ line-height: 15px;
+ font-weight: $semibold-weight;
+ }
+ &--label {
+ font-size: 12px;
+ font-weight: $semibold-weight;
+ }
+ &--color {
+ &--primary {
+ color: var(--main-text-color-primary);
+ }
+ &--secondary {
+ color: var(--main-text-color-secondary);
+ }
+ &--success {
+ color: var(--success-color);
+ }
+ &--error {
+ color: var(--error-color);
+ }
+ &--inherit {
+ color: inherit;
+ }
+ }
+}
diff --git a/src/modules/shared/atoms/Typography/Typography.tsx b/src/modules/shared/atoms/Typography/Typography.tsx
new file mode 100644
index 00000000000..a92ec781663
--- /dev/null
+++ b/src/modules/shared/atoms/Typography/Typography.tsx
@@ -0,0 +1,54 @@
+import React from 'react';
+import classNames from 'classnames';
+import styles from './Typography.module.scss';
+
+type Tag = 'h1' | 'h2' | 'h3' | 'h4' | 'p' | 'span' | 'label';
+
+type Variant =
+ | 'h1'
+ | 'h2'
+ | 'h3'
+ | 'h4'
+ | 'uppercase'
+ | 'buttons'
+ | 'body'
+ | 'small'
+ | 'label';
+
+type Color = 'primary' | 'secondary' | 'success' | 'error' | 'inherit';
+
+interface TypographyProps extends React.HTMLAttributes {
+ tag?: Tag;
+ variant: Variant;
+ children: React.ReactNode;
+ className?: string;
+ color?: Color;
+ htmlFor?: string;
+}
+
+export const Typography: React.FC = ({
+ tag = 'span',
+ variant,
+ children,
+ className,
+ color = 'primary',
+ htmlFor,
+ ...props
+}) => {
+ const Tag = tag;
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/modules/shared/atoms/Typography/index.ts b/src/modules/shared/atoms/Typography/index.ts
new file mode 100644
index 00000000000..d64ebba0575
--- /dev/null
+++ b/src/modules/shared/atoms/Typography/index.ts
@@ -0,0 +1 @@
+export * from './Typography';
diff --git a/src/modules/shared/molecules/Dropdown/Dropdown.module.scss b/src/modules/shared/molecules/Dropdown/Dropdown.module.scss
new file mode 100644
index 00000000000..9739af27acf
--- /dev/null
+++ b/src/modules/shared/molecules/Dropdown/Dropdown.module.scss
@@ -0,0 +1,90 @@
+@use '@shared/styles/utils/mixins.scss' as *;
+
+$bold-weight: 800;
+$semibold-weight: 600;
+$regular-weight: 400;
+$control-height: 40px;
+$border-radius: 0;
+$border-width: 1px;
+$menu-padding-block: 8px;
+$padding-inline: 12px;
+
+.component {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.dropdown .dropdown {
+ &__menu {
+ box-shadow: 0 2px 15px 0 #0000000d;
+ box-sizing: border-box;
+ border: $border-width solid var(--elements-color);
+ padding-block: $menu-padding-block;
+ background-color: var(--main-background-color);
+ border-radius: $border-radius;
+ }
+
+ &__single_value {
+ padding-right: 12px;
+ display: flex;
+ align-items: center;
+ }
+
+ &__option {
+ cursor: pointer;
+
+ display: flex;
+ align-items: center;
+ height: 32px;
+
+ background-color: transparent;
+ padding-inline: $padding-inline;
+
+ &:hover {
+ background-color: var(--surface2-color);
+ }
+ &--is-selected {
+ background-color: transparent;
+ }
+
+ &--is-focused {
+ background-color: var(--surface2-color);
+
+ @include outline-selection;
+ }
+ }
+
+ &__control {
+ outline: none;
+
+ &:has(:focus-visible) {
+ @include outline-selection(true);
+ }
+
+ cursor: pointer;
+ background-color: var(--surface2-color);
+ height: $control-height;
+ box-sizing: border-box;
+ padding-right: 12px;
+ border-radius: $border-radius;
+
+ border: 1px solid transparent;
+
+ &:hover {
+ border-color: var(--icons-color);
+ }
+
+ &--is-focused {
+ box-shadow: none;
+ }
+
+ &--menu-is-open {
+ box-shadow: inset 0 0 0 $border-width var(--buttons-background-active);
+ }
+ }
+
+ &__indicator_separator {
+ display: none;
+ }
+}
diff --git a/src/modules/shared/molecules/Dropdown/Dropdown.tsx b/src/modules/shared/molecules/Dropdown/Dropdown.tsx
new file mode 100644
index 00000000000..818081e948b
--- /dev/null
+++ b/src/modules/shared/molecules/Dropdown/Dropdown.tsx
@@ -0,0 +1,108 @@
+import React, { useId } from 'react';
+import { Typography } from '../../atoms/Typography';
+import {
+ components,
+ SingleValueProps,
+ ActionMeta,
+ DropdownIndicatorProps,
+ OptionProps,
+} from 'react-select';
+import { Icon } from '../../atoms/Icon';
+import { ArrowIcon } from '../../../../assets/icons/arrow-icon';
+import Select from 'react-select';
+import classNames from 'classnames';
+import styles from './Dropdown.module.scss';
+import { DropdownOption } from '../../../../types/DropdownOption';
+
+type Props = {
+ label: string;
+ options: DropdownOption[];
+ value: DropdownOption | null;
+ onChange: (
+ newValue: DropdownOption | null,
+ actionMeta: ActionMeta,
+ ) => void;
+};
+
+const SingleValue = (props: SingleValueProps) => (
+
+ {props.children}
+
+);
+
+const DropdownIndicator = (
+ props: DropdownIndicatorProps,
+) => {
+ const {
+ selectProps: { menuIsOpen },
+ } = props;
+
+ return (
+
+
+
+
+
+ );
+};
+
+const Option = (props: OptionProps) => {
+ return (
+
+ {props.data.label}
+
+ );
+};
+
+export const Dropdown: React.FC = ({
+ label,
+ options,
+ value,
+ onChange,
+}) => {
+ const id = useId();
+
+ return (
+
+
+ {label}
+
+
+
+ classNames(styles.dropdown__control, {
+ [styles['dropdown__control--is-focused']]: state.isFocused,
+ [styles['dropdown__control--menu-is-open']]: state.menuIsOpen,
+ }),
+ singleValue: () => styles.dropdown__single_value,
+ option: state =>
+ classNames(styles.dropdownOption, {
+ [styles['dropdown__option--is-focused']]: state.isFocused,
+ [styles['dropdown__option--is-selected']]: state.isSelected,
+ }),
+ indicatorSeparator: () => styles.dropdown__indicator_separator,
+ menu: () => styles.dropdown__menu,
+ }}
+ options={options}
+ value={value}
+ onChange={onChange}
+ isSearchable={false}
+ components={{
+ SingleValue,
+ DropdownIndicator,
+ Option,
+ }}
+ />
+
+
+ );
+};
diff --git a/src/modules/shared/molecules/Dropdown/index.ts b/src/modules/shared/molecules/Dropdown/index.ts
new file mode 100644
index 00000000000..2f29bad4e67
--- /dev/null
+++ b/src/modules/shared/molecules/Dropdown/index.ts
@@ -0,0 +1 @@
+export * from './Dropdown';
diff --git a/src/modules/shared/molecules/Heading/Heading.module.scss b/src/modules/shared/molecules/Heading/Heading.module.scss
new file mode 100644
index 00000000000..bcd1c855724
--- /dev/null
+++ b/src/modules/shared/molecules/Heading/Heading.module.scss
@@ -0,0 +1,5 @@
+.heading {
+ display: flex;
+ flex-direction: column;
+ gap: var(--field-gap, 8px);
+}
diff --git a/src/modules/shared/molecules/Heading/Heading.tsx b/src/modules/shared/molecules/Heading/Heading.tsx
new file mode 100644
index 00000000000..97c7a90083f
--- /dev/null
+++ b/src/modules/shared/molecules/Heading/Heading.tsx
@@ -0,0 +1,31 @@
+import React from 'react';
+import styles from './Heading.module.scss';
+import { Typography } from '../../atoms/Typography';
+
+type Props = {
+ title: string;
+ subtitle?: string;
+ title_tag?: 'h1' | 'h2' | 'h3' | 'h4';
+};
+
+export const Heading: React.FC = ({
+ title,
+ subtitle,
+ title_tag = 'h1',
+}) => (
+
+
+ {title}
+
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+);
diff --git a/src/modules/shared/molecules/Heading/index.ts b/src/modules/shared/molecules/Heading/index.ts
new file mode 100644
index 00000000000..6406e7b07f4
--- /dev/null
+++ b/src/modules/shared/molecules/Heading/index.ts
@@ -0,0 +1 @@
+export * from './Heading';
diff --git a/src/modules/shared/molecules/LanguageSwitcher/LanguageSwitcher.module.scss b/src/modules/shared/molecules/LanguageSwitcher/LanguageSwitcher.module.scss
new file mode 100644
index 00000000000..f3dd8bca247
--- /dev/null
+++ b/src/modules/shared/molecules/LanguageSwitcher/LanguageSwitcher.module.scss
@@ -0,0 +1,31 @@
+@use '@shared/styles/utils/mixins.scss' as *;
+
+.lang_switcher {
+ display: flex;
+
+ align-items: center;
+
+ // padding: 4px;
+
+ &__option:hover &__language {
+ color: var(--buttons-text-color-primary);
+ transition: color var(--animation-duration, 0.3s) ease;
+ }
+
+ &__option {
+ padding: 4px;
+
+ background-color: transparent;
+
+ &--active {
+ color: var(--buttons-text-color-primary);
+ transition: color var(--animation-duration, 0.3s);
+ }
+
+ @include underline-animate;
+
+ &--active::after {
+ transform: scaleX(1);
+ }
+ }
+}
diff --git a/src/modules/shared/molecules/LanguageSwitcher/LanguageSwitcher.tsx b/src/modules/shared/molecules/LanguageSwitcher/LanguageSwitcher.tsx
new file mode 100644
index 00000000000..6058e65938e
--- /dev/null
+++ b/src/modules/shared/molecules/LanguageSwitcher/LanguageSwitcher.tsx
@@ -0,0 +1,50 @@
+import classNames from 'classnames';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { useDispatch } from 'react-redux';
+import { Language } from '../../../../enums/Language';
+import { setLanguage } from '../../../../features/i18nSlice';
+import { Button } from '../../atoms/Button';
+import { Typography } from '../../atoms/Typography';
+import styles from './LanguageSwitcher.module.scss';
+
+const langMap = [Language.EN, Language.UA];
+
+type Props = {
+ className?: string;
+};
+
+export const LanguageSwitcher: React.FC = ({ className }) => {
+ const { i18n } = useTranslation();
+ const dispatch = useDispatch();
+
+ const handleLanguageChange = (lang: Language) => {
+ dispatch(setLanguage(lang));
+ };
+
+ return (
+
+ {langMap.map((lang, index, arr) => (
+
+ handleLanguageChange(lang)}
+ className={classNames(styles.lang_switcher__option, {
+ [styles['lang_switcher__option--active']]: i18n.language === lang,
+ })}
+ >
+
+ {lang}
+
+
+ {index < arr.length - 1 && (
+ /
+ )}
+
+ ))}
+
+ );
+};
diff --git a/src/modules/shared/molecules/LanguageSwitcher/index.ts b/src/modules/shared/molecules/LanguageSwitcher/index.ts
new file mode 100644
index 00000000000..de458ccf133
--- /dev/null
+++ b/src/modules/shared/molecules/LanguageSwitcher/index.ts
@@ -0,0 +1 @@
+export * from './LanguageSwitcher';
diff --git a/src/modules/shared/molecules/LogoLink/LogoLink.module.scss b/src/modules/shared/molecules/LogoLink/LogoLink.module.scss
new file mode 100644
index 00000000000..fbfc7175354
--- /dev/null
+++ b/src/modules/shared/molecules/LogoLink/LogoLink.module.scss
@@ -0,0 +1,24 @@
+@use '@shared/styles/utils/mixins.scss' as *;
+
+.logo {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+
+ box-sizing: border-box;
+
+ @include hover(transform, scale(1.1));
+ @include focus-visible;
+}
+
+.logoImage {
+ width: 64px;
+
+ color: var(--buttons-text-color-primary);
+
+ @include on-small-desktop {
+ width: 80px;
+ object-fit: contain;
+ }
+}
diff --git a/src/modules/shared/molecules/LogoLink/LogoLink.tsx b/src/modules/shared/molecules/LogoLink/LogoLink.tsx
new file mode 100644
index 00000000000..1d8fcc782a2
--- /dev/null
+++ b/src/modules/shared/molecules/LogoLink/LogoLink.tsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import styles from './LogoLink.module.scss';
+import { NavLink } from 'react-router-dom';
+import { HeaderLogo } from '../../../../assets/icons/header-logo-icon';
+import classNames from 'classnames';
+
+type Props = {
+ className?: string;
+};
+
+export const LogoLink: React.FC = ({ className }) => {
+ return (
+
+
+
+ );
+};
diff --git a/src/modules/shared/molecules/LogoLink/index.ts b/src/modules/shared/molecules/LogoLink/index.ts
new file mode 100644
index 00000000000..60120a68d30
--- /dev/null
+++ b/src/modules/shared/molecules/LogoLink/index.ts
@@ -0,0 +1 @@
+export * from './LogoLink';
diff --git a/src/modules/shared/molecules/PageLoader/PageLoader.module.scss b/src/modules/shared/molecules/PageLoader/PageLoader.module.scss
new file mode 100644
index 00000000000..ff563d0d9e7
--- /dev/null
+++ b/src/modules/shared/molecules/PageLoader/PageLoader.module.scss
@@ -0,0 +1,29 @@
+.content {
+ height: 100vh;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ overflow: hidden;
+}
+
+.astronaut {
+ animation: fly-up 2.5s ease-in-out forwards;
+ width: min(100%, 480px);
+ aspect-ratio: 1;
+}
+
+@keyframes fly-up {
+ 0% {
+ transform: translateY(100vh);
+ opacity: 0;
+ }
+ 50% {
+ transform: translateY(0);
+ opacity: 1;
+ }
+ 100% {
+ transform: translateY(-100vh);
+ opacity: 0;
+ }
+}
diff --git a/src/modules/shared/molecules/PageLoader/PageLoader.tsx b/src/modules/shared/molecules/PageLoader/PageLoader.tsx
new file mode 100644
index 00000000000..143d8dd65b8
--- /dev/null
+++ b/src/modules/shared/molecules/PageLoader/PageLoader.tsx
@@ -0,0 +1,12 @@
+import React from 'react';
+import styles from './PageLoader.module.scss';
+
+export const PageLoader: React.FC = () => (
+
+
+
+);
diff --git a/src/modules/shared/molecules/PageLoader/index.ts b/src/modules/shared/molecules/PageLoader/index.ts
new file mode 100644
index 00000000000..2428f77a9de
--- /dev/null
+++ b/src/modules/shared/molecules/PageLoader/index.ts
@@ -0,0 +1 @@
+export * from './PageLoader';
diff --git a/src/modules/shared/molecules/PageMessage/PageMessage.module.scss b/src/modules/shared/molecules/PageMessage/PageMessage.module.scss
new file mode 100644
index 00000000000..5c8153331dd
--- /dev/null
+++ b/src/modules/shared/molecules/PageMessage/PageMessage.module.scss
@@ -0,0 +1,31 @@
+@use '@shared/styles/utils/mixins.scss' as *;
+
+.content {
+ height: 100%;
+ flex-grow: 1;
+ overflow: hidden;
+ row-gap: 12px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+
+ &__image {
+ width: 50%;
+
+ @include on-tablet {
+ width: 25%;
+ }
+
+ &__img {
+ width: 100%;
+ aspect-ratio: 1;
+ object-fit: contain;
+ }
+ }
+ &__message {
+ display: flex;
+ flex-direction: column;
+ text-align: center;
+ }
+}
diff --git a/src/modules/shared/molecules/PageMessage/PageMessage.tsx b/src/modules/shared/molecules/PageMessage/PageMessage.tsx
new file mode 100644
index 00000000000..311cfa8c348
--- /dev/null
+++ b/src/modules/shared/molecules/PageMessage/PageMessage.tsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import styles from './PageMessage.module.scss';
+import { Typography } from '../../atoms/Typography';
+
+type Props = {
+ title: string;
+ subtitle?: string;
+ imgSrc?: string;
+};
+
+export const PageMessage: React.FC = ({ title, subtitle, imgSrc }) => (
+
+ {imgSrc && (
+
+
+
+ )}
+
+
+ {title}
+
+
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+);
diff --git a/src/modules/shared/molecules/PageMessage/index.ts b/src/modules/shared/molecules/PageMessage/index.ts
new file mode 100644
index 00000000000..8d7f792567a
--- /dev/null
+++ b/src/modules/shared/molecules/PageMessage/index.ts
@@ -0,0 +1 @@
+export * from './PageMessage';
diff --git a/src/modules/shared/molecules/ProductControls/ProductControls.module.scss b/src/modules/shared/molecules/ProductControls/ProductControls.module.scss
new file mode 100644
index 00000000000..546afe59cf7
--- /dev/null
+++ b/src/modules/shared/molecules/ProductControls/ProductControls.module.scss
@@ -0,0 +1,45 @@
+@use './../../styles/utils/mixins.scss' as *;
+
+.product_controls {
+ display: flex;
+ gap: 8px;
+ width: 100%;
+}
+
+.button {
+ background-color: var(--surface2-color);
+
+ &__text {
+ &--active {
+ color: var(--buttons-text-color-primary-active);
+ }
+ }
+
+ &--primary {
+ flex-grow: 1;
+
+ background-color: var(--buttons-background-active);
+
+ &:not(:disabled):hover {
+ background-color: var(--buttons-background-active-hover);
+ }
+
+ &--active {
+ background-color: var(--surface2-color);
+ }
+ }
+
+ &--favourite {
+ flex-shrink: 0;
+
+ &:hover {
+ background-color: var(--icons-color);
+ }
+
+ &--active {
+ background-color: transparent;
+
+ border: 1px solid var(--elements-color);
+ }
+ }
+}
diff --git a/src/modules/shared/molecules/ProductControls/ProductControls.tsx b/src/modules/shared/molecules/ProductControls/ProductControls.tsx
new file mode 100644
index 00000000000..58011dac772
--- /dev/null
+++ b/src/modules/shared/molecules/ProductControls/ProductControls.tsx
@@ -0,0 +1,56 @@
+import React from 'react';
+import styles from './ProductControls.module.scss';
+import { Button } from '../../atoms/Button';
+import classNames from 'classnames';
+import { Typography } from '../../atoms/Typography';
+import { IconButton } from '../../atoms/IconButton';
+
+interface Props {
+ onAddToCart: (event: React.MouseEvent) => void;
+ onToggleFavourite: (event: React.MouseEvent) => void;
+ isFavourite: boolean;
+ isInCart: boolean;
+ size?: 'medium' | 'large';
+ cartButtonText: string;
+ icon: React.ReactNode;
+}
+
+export const ProductControls: React.FC = ({
+ onAddToCart,
+ onToggleFavourite,
+ isInCart,
+ isFavourite,
+ size = 'medium',
+ cartButtonText,
+ icon,
+}) => {
+ return (
+
+
+
+ {cartButtonText}
+
+
+
+ {icon}
+
+
+ );
+};
diff --git a/src/modules/shared/molecules/ProductControls/index.ts b/src/modules/shared/molecules/ProductControls/index.ts
new file mode 100644
index 00000000000..275f4b5e25a
--- /dev/null
+++ b/src/modules/shared/molecules/ProductControls/index.ts
@@ -0,0 +1 @@
+export * from './ProductControls';
diff --git a/src/modules/shared/molecules/ProductPrice/ProductPrice.module.scss b/src/modules/shared/molecules/ProductPrice/ProductPrice.module.scss
new file mode 100644
index 00000000000..b8c0fcce45a
--- /dev/null
+++ b/src/modules/shared/molecules/ProductPrice/ProductPrice.module.scss
@@ -0,0 +1,34 @@
+.price {
+ display: flex;
+ gap: var(--field-gap, 8px);
+ align-items: center;
+
+ &__value {
+ &::before {
+ content: '$';
+ }
+
+ isolation: isolate;
+
+ &--strikethrough {
+ position: relative;
+
+ font-weight: 600;
+
+ &::after {
+ content: '';
+
+ position: absolute;
+ left: 0;
+ top: 50%;
+
+ height: 0.1rem;
+ width: 100%;
+
+ mix-blend-mode: difference;
+
+ background-color: var(--main-text-color-secondary);
+ }
+ }
+ }
+}
diff --git a/src/modules/shared/molecules/ProductPrice/ProductPrice.tsx b/src/modules/shared/molecules/ProductPrice/ProductPrice.tsx
new file mode 100644
index 00000000000..eeb76367032
--- /dev/null
+++ b/src/modules/shared/molecules/ProductPrice/ProductPrice.tsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import styles from './ProductPrice.module.scss';
+import { Typography } from '../../atoms/Typography';
+import classNames from 'classnames';
+
+type Props = {
+ fullPrice: number;
+ price: number;
+ big?: boolean;
+};
+
+export const ProductPrice: React.FC = ({ fullPrice, price, big }) => {
+ const isOnDiscount = price < fullPrice;
+
+ return (
+
+
+ {isOnDiscount ? price : fullPrice}
+
+ {isOnDiscount && (
+
+ {fullPrice}
+
+ )}
+
+ );
+};
diff --git a/src/modules/shared/molecules/ProductPrice/index.ts b/src/modules/shared/molecules/ProductPrice/index.ts
new file mode 100644
index 00000000000..061c1b420cf
--- /dev/null
+++ b/src/modules/shared/molecules/ProductPrice/index.ts
@@ -0,0 +1 @@
+export * from './ProductPrice';
diff --git a/src/modules/shared/molecules/ProductSpec/ProductSpec.module.scss b/src/modules/shared/molecules/ProductSpec/ProductSpec.module.scss
new file mode 100644
index 00000000000..5ee928ee584
--- /dev/null
+++ b/src/modules/shared/molecules/ProductSpec/ProductSpec.module.scss
@@ -0,0 +1,5 @@
+.field {
+ display: flex;
+ justify-content: space-between;
+ gap: var(--field-gap, 8px);
+}
diff --git a/src/modules/shared/molecules/ProductSpec/ProductSpec.tsx b/src/modules/shared/molecules/ProductSpec/ProductSpec.tsx
new file mode 100644
index 00000000000..96e81ecbb2d
--- /dev/null
+++ b/src/modules/shared/molecules/ProductSpec/ProductSpec.tsx
@@ -0,0 +1,26 @@
+import React, { memo } from 'react';
+import styles from './ProductSpec.module.scss';
+import { Typography } from '../../atoms/Typography';
+
+type Props = {
+ label: string;
+ value: string;
+};
+
+const ProductSpecComponent: React.FC = ({ label, value }) => (
+
+
+ {label}
+
+
+
+ {value}
+
+
+);
+
+export const ProductSpec = memo(ProductSpecComponent);
diff --git a/src/modules/shared/molecules/ProductSpec/index.ts b/src/modules/shared/molecules/ProductSpec/index.ts
new file mode 100644
index 00000000000..e8c6612f969
--- /dev/null
+++ b/src/modules/shared/molecules/ProductSpec/index.ts
@@ -0,0 +1 @@
+export * from './ProductSpec';
diff --git a/src/modules/shared/molecules/SearchField/SearchField.module.scss b/src/modules/shared/molecules/SearchField/SearchField.module.scss
new file mode 100644
index 00000000000..0a2e9322449
--- /dev/null
+++ b/src/modules/shared/molecules/SearchField/SearchField.module.scss
@@ -0,0 +1,40 @@
+@use '@shared/styles/utils/mixins.scss' as *;
+
+.search {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ height: 100%;
+ min-width: 80px;
+ max-width: 180px;
+ flex-grow: 0;
+}
+
+.input {
+ display: block;
+ width: 100%;
+ box-sizing: border-box;
+ background-color: var(--main-background-color);
+ border: 1px solid var(--icons-color);
+ border-radius: 10px;
+ color: var(--main-text-color-primary);
+ align-items: center;
+ padding-inline: 8px;
+
+ &:focus,
+ &:active {
+ outline: 1px solid var(--main-text-color-primary);
+ }
+
+ &::placeholder {
+ font-weight: 400;
+ font-size: 10px;
+ color: var(--main-text-color-secondary);
+
+ @include on-tablet {
+ font-size: 12px;
+ }
+ }
+
+ @include focus-visible;
+}
diff --git a/src/modules/shared/molecules/SearchField/SearchField.tsx b/src/modules/shared/molecules/SearchField/SearchField.tsx
new file mode 100644
index 00000000000..80ca4b7cf78
--- /dev/null
+++ b/src/modules/shared/molecules/SearchField/SearchField.tsx
@@ -0,0 +1,66 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import { Icon } from '../../atoms/Icon';
+import { SearchIcon } from '../../../../assets/icons/search-icon';
+import styles from './SearchField.module.scss';
+import { useTranslation } from 'react-i18next';
+import { useNavigate, useSearchParams } from 'react-router-dom';
+import { SearchParam } from '../../../../enums/SearchFields';
+import { DefaultValues } from '../../../../enums/DefaultValues';
+import { getSearchWith } from '../../../../helpers/searchHelper';
+import debounce from 'lodash.debounce';
+import classNames from 'classnames';
+
+export const SearchField: React.FC = () => {
+ const { t } = useTranslation();
+ const [searchParams] = useSearchParams();
+ const navigate = useNavigate();
+
+ const initialQuery =
+ searchParams.get(SearchParam.Query) || DefaultValues.Query;
+ const [query, setQuery] = useState(initialQuery);
+
+ const updateQuery = useCallback(
+ (value: string) => {
+ const updatedSearch = getSearchWith(searchParams, {
+ [SearchParam.Query]: value || null,
+ [SearchParam.Page]: null,
+ });
+
+ navigate({ search: updatedSearch });
+ },
+ [searchParams, navigate],
+ );
+
+ const debouncedUpdateQuery = useMemo(
+ () => debounce(updateQuery, 300),
+ [updateQuery],
+ );
+
+ useEffect(() => {
+ return () => {
+ debouncedUpdateQuery.cancel();
+ };
+ }, [debouncedUpdateQuery]);
+
+ const handleChange = (event: React.ChangeEvent) => {
+ setQuery(event.target.value);
+ debouncedUpdateQuery(event.target.value);
+ };
+
+ return (
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/modules/shared/molecules/SearchField/index.ts b/src/modules/shared/molecules/SearchField/index.ts
new file mode 100644
index 00000000000..9f5ef334723
--- /dev/null
+++ b/src/modules/shared/molecules/SearchField/index.ts
@@ -0,0 +1 @@
+export * from './SearchField';
diff --git a/src/modules/shared/organisms/PageStateWrapper/PageStateWrapper.tsx b/src/modules/shared/organisms/PageStateWrapper/PageStateWrapper.tsx
new file mode 100644
index 00000000000..fe8ac82cc59
--- /dev/null
+++ b/src/modules/shared/organisms/PageStateWrapper/PageStateWrapper.tsx
@@ -0,0 +1,35 @@
+import React from 'react';
+import { PageLoader } from '../../molecules/PageLoader';
+import { RetryErrorMessage } from '../RetryErrorMessage';
+
+type AsyncState = {
+ loading: boolean;
+ error?: boolean;
+};
+
+type PageStateWrapperProps = {
+ asyncStates: AsyncState[];
+ fallback?: React.ReactNode;
+ errorComponent?: React.ReactNode;
+ children: React.ReactNode;
+};
+
+export const PageStateWrapper: React.FC = ({
+ asyncStates,
+ fallback = ,
+ errorComponent = ,
+ children,
+}) => {
+ const isLoading = asyncStates.some(state => state.loading);
+ const hasError = asyncStates.some(state => state.error);
+
+ if (isLoading) {
+ return fallback;
+ }
+
+ if (hasError) {
+ return errorComponent;
+ }
+
+ return <>{children}>;
+};
diff --git a/src/modules/shared/organisms/PageStateWrapper/index.ts b/src/modules/shared/organisms/PageStateWrapper/index.ts
new file mode 100644
index 00000000000..7b5706a9f6c
--- /dev/null
+++ b/src/modules/shared/organisms/PageStateWrapper/index.ts
@@ -0,0 +1 @@
+export * from './PageStateWrapper';
diff --git a/src/modules/shared/organisms/ProductCard/ProductCard.module.scss b/src/modules/shared/organisms/ProductCard/ProductCard.module.scss
new file mode 100644
index 00000000000..e1c93cc1e3d
--- /dev/null
+++ b/src/modules/shared/organisms/ProductCard/ProductCard.module.scss
@@ -0,0 +1,51 @@
+@use '@shared/styles/utils/mixins.scss' as *;
+
+.card {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+
+ background-color: var(--surface1-color);
+
+ @include hover(transform, scale(1.1));
+ @include focus-visible;
+
+ &__content {
+ flex: 1;
+ padding: 32px;
+ display: flex;
+ flex-direction: column;
+ gap: var(--field-gap, 8px);
+ }
+
+ &__photo {
+ display: block;
+ width: 100%;
+ height: auto;
+ object-fit: contain;
+ flex-shrink: 1;
+
+ aspect-ratio: 1/1;
+ }
+
+ &__description {
+ margin-top: auto;
+ display: flex;
+ flex-direction: column;
+ gap: var(--field-gap, 8px);
+ }
+
+ &__title {
+ padding-top: 16px;
+ }
+
+ &__specs {
+ display: flex;
+ flex-direction: column;
+
+ justify-content: space-between;
+
+ gap: 8px;
+ padding-block: 8px;
+ }
+}
diff --git a/src/modules/shared/organisms/ProductCard/ProductCard.tsx b/src/modules/shared/organisms/ProductCard/ProductCard.tsx
new file mode 100644
index 00000000000..6b44725f4aa
--- /dev/null
+++ b/src/modules/shared/organisms/ProductCard/ProductCard.tsx
@@ -0,0 +1,115 @@
+import React, { memo } from 'react';
+import styles from './ProductCard.module.scss';
+import { Product } from '../../../../types/Product';
+import { useAppDispatch, useAppSelector } from '../../../../hooks/hooks';
+import { Link } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+import { toggleFavourite } from '../../../../features/favouritesSlice';
+import { add, remove } from '../../../../features/cartSlice';
+import { Typography } from '../../atoms/Typography';
+import { ProductPrice } from '../../molecules/ProductPrice';
+import { Divider } from '../../atoms/Divider';
+import { ProductSpec } from '../../molecules/ProductSpec';
+import { ProductControls } from '../../molecules/ProductControls';
+import { Icon } from '../../atoms/Icon';
+import { HeartFilledIcon } from '../../../../assets/icons/heart-filled-icon';
+import { HeartIcon } from '../../../../assets/icons/heart-icon';
+import { showToast } from '../../../NotificationToast';
+
+type Props = {
+ product: Product;
+};
+
+const ProductCardComponent: React.FC = ({ product }) => {
+ const dispatch = useAppDispatch();
+ const { favourites } = useAppSelector(state => state.favourites);
+ const { cartItems } = useAppSelector(state => state.cart);
+ const { t } = useTranslation();
+
+ const isInFavourites = favourites.some(fav => fav.itemId === product.itemId);
+ const isInCart = cartItems.some(
+ cartItem => cartItem.itemId === product.itemId,
+ );
+
+ const handleToggle = (event: React.MouseEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+ dispatch(toggleFavourite(product));
+ showToast({
+ description: t(
+ `notification.${isInFavourites ? 'remove' : 'add'}.favourites`,
+ { name: product.name },
+ ),
+ });
+ };
+
+ const addToCart = (event: React.MouseEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+
+ if (isInCart) {
+ dispatch(remove(product));
+ showToast({
+ description: t('notification.remove.cart', {
+ name: product.name,
+ }),
+ });
+ } else {
+ dispatch(add(product));
+ showToast({
+ description: t('notification.add.cart', {
+ name: product.name,
+ }),
+ });
+ }
+ };
+
+ return (
+
+
+
+
+
+ {product.name}
+
+
+
+
+
+ {isInFavourites ? : }
+
+ }
+ />
+
+
+
+ );
+};
+
+export const ProductCard = memo(ProductCardComponent);
diff --git a/src/modules/shared/organisms/ProductCard/index.ts b/src/modules/shared/organisms/ProductCard/index.ts
new file mode 100644
index 00000000000..7ce031c3820
--- /dev/null
+++ b/src/modules/shared/organisms/ProductCard/index.ts
@@ -0,0 +1 @@
+export * from './ProductCard';
diff --git a/src/modules/shared/organisms/ProductList/ProductList.module.scss b/src/modules/shared/organisms/ProductList/ProductList.module.scss
new file mode 100644
index 00000000000..34c75947976
--- /dev/null
+++ b/src/modules/shared/organisms/ProductList/ProductList.module.scss
@@ -0,0 +1,15 @@
+@use './../../styles/utils/mixins.scss' as *;
+
+.container {
+ row-gap: 40px;
+
+ @include page-grid;
+
+ &__item {
+ grid-column: span 4;
+
+ @include on-tablet {
+ grid-column: span 6;
+ }
+ }
+}
diff --git a/src/modules/shared/organisms/ProductList/ProductList.tsx b/src/modules/shared/organisms/ProductList/ProductList.tsx
new file mode 100644
index 00000000000..caf64ba6fb1
--- /dev/null
+++ b/src/modules/shared/organisms/ProductList/ProductList.tsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import styles from './ProductList.module.scss';
+import { Product } from '../../../../types/Product';
+import { ProductCard } from '../ProductCard';
+
+type Props = {
+ list: Product[];
+};
+
+export const ProductList: React.FC = ({ list }) => {
+ return (
+
+ {list.map(product => (
+
+ ))}
+
+ );
+};
diff --git a/src/modules/shared/organisms/ProductList/index.ts b/src/modules/shared/organisms/ProductList/index.ts
new file mode 100644
index 00000000000..c71910ae056
--- /dev/null
+++ b/src/modules/shared/organisms/ProductList/index.ts
@@ -0,0 +1 @@
+export * from './ProductList';
diff --git a/src/modules/shared/organisms/ProductSlider/ProductSlider.module.scss b/src/modules/shared/organisms/ProductSlider/ProductSlider.module.scss
new file mode 100644
index 00000000000..52bcb033070
--- /dev/null
+++ b/src/modules/shared/organisms/ProductSlider/ProductSlider.module.scss
@@ -0,0 +1,42 @@
+@use '@shared/styles/utils/mixins.scss' as *;
+
+.slider {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+
+ &__heading {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+
+ &__button {
+ display: flex;
+ gap: 16px;
+ }
+
+ &__controls {
+ display: flex;
+ gap: 16px;
+ }
+}
+
+.swiper__slide {
+ height: auto;
+
+ @include span-columns(3, 4);
+
+ @include on-tablet {
+ @include span-columns(5, 12);
+ }
+
+ @include on-small-desktop {
+ @include span-columns(6, 24);
+ }
+}
+
+.margin_compensation {
+ padding: 24px 16px;
+ margin: -24px -16px;
+}
diff --git a/src/modules/shared/organisms/ProductSlider/ProductSlider.tsx b/src/modules/shared/organisms/ProductSlider/ProductSlider.tsx
new file mode 100644
index 00000000000..50467ab770d
--- /dev/null
+++ b/src/modules/shared/organisms/ProductSlider/ProductSlider.tsx
@@ -0,0 +1,86 @@
+import React, { useState } from 'react';
+import classNames from 'classnames';
+import { Navigation, Pagination, Autoplay } from 'swiper/modules';
+import { SwiperSlide, Swiper } from 'swiper/react';
+import { Product } from '../../../../types/Product';
+import { ArrowButton } from '../../atoms/ArrowButton';
+import { Typography } from '../../atoms/Typography';
+import { ProductCard } from '../ProductCard';
+
+import styles from './ProductSlider.module.scss';
+
+type Props = {
+ title: string;
+ productsList: Product[];
+ id: number;
+ className?: string;
+ infinite?: boolean;
+};
+
+export const ProductSlider: React.FC = ({
+ title,
+ productsList,
+ id,
+ className,
+ infinite = false,
+}) => {
+ const [isBeginning, setIsBeginning] = useState(true);
+ const [isEnd, setIsEnd] = useState(false);
+
+ return (
+
+
+
+
{
+ setIsBeginning(swiper.isBeginning);
+ setIsEnd(swiper.isEnd);
+ }}
+ loop={infinite}
+ spaceBetween={16}
+ navigation={{
+ nextEl: `[data-swiper-next="${id}"]`,
+ prevEl: `[data-swiper-prev="${id}"]`,
+ }}
+ slidesPerView="auto"
+ className={classNames(
+ styles.swiper,
+ styles.margin_compensation,
+ className,
+ )}
+ >
+ {productsList.map(product => (
+
+
+
+ ))}
+
+
+
+ );
+};
diff --git a/src/modules/shared/organisms/ProductSlider/index.ts b/src/modules/shared/organisms/ProductSlider/index.ts
new file mode 100644
index 00000000000..53492382705
--- /dev/null
+++ b/src/modules/shared/organisms/ProductSlider/index.ts
@@ -0,0 +1 @@
+export * from './ProductSlider';
diff --git a/src/modules/shared/organisms/RetryErrorMessage/RetryErrorMessage.module.scss b/src/modules/shared/organisms/RetryErrorMessage/RetryErrorMessage.module.scss
new file mode 100644
index 00000000000..902a17f6285
--- /dev/null
+++ b/src/modules/shared/organisms/RetryErrorMessage/RetryErrorMessage.module.scss
@@ -0,0 +1,54 @@
+@use '@shared/styles/utils/mixins.scss' as *;
+
+.error {
+ padding-top: 20px;
+ box-sizing: border-box;
+ min-height: calc(100vh - 64px - 64px - 96px - 96px);
+ flex-grow: 1;
+ overflow: hidden;
+ gap: 12px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+
+ &__image {
+ max-width: 268px;
+ aspect-ratio: 1;
+ display: block;
+ flex-shrink: 1;
+
+ object-fit: contain;
+ }
+
+ &__message {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ margin: 0 auto;
+
+ color: var(--purple-accent-color);
+
+ &__text {
+ text-wrap: balance;
+ text-align: center;
+ }
+ }
+
+ &__button {
+ width: 260px;
+
+ background-color: var(--buttons-background-active);
+
+ cursor: pointer;
+
+ &:hover {
+ background-color: var(--buttons-background-active-hover);
+ }
+
+ &__text {
+ color: var(--buttons-text-color-primary-active);
+ }
+ }
+}
diff --git a/src/modules/shared/organisms/RetryErrorMessage/RetryErrorMessage.tsx b/src/modules/shared/organisms/RetryErrorMessage/RetryErrorMessage.tsx
new file mode 100644
index 00000000000..c43b885d1b6
--- /dev/null
+++ b/src/modules/shared/organisms/RetryErrorMessage/RetryErrorMessage.tsx
@@ -0,0 +1,55 @@
+import React from 'react';
+import styles from './RetryErrorMessage.module.scss';
+
+import { useLocation, useNavigate } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+import { useAppDispatch } from '../../../../hooks/hooks';
+import { init as initProducts } from '../../../../features/productsSlice';
+import { init as initFavourites } from '../../../../features/favouritesSlice';
+import { init as initCart } from '../../../../features/cartSlice';
+import { init as initPhones } from '../../../../features/phonesSlice';
+import { init as initTablets } from '../../../../features/tabletsSlice';
+import { init as initAccessories } from '../../../../features/accessoriesSlice';
+import { Typography } from '../../atoms/Typography';
+import { Button } from '../../atoms/Button';
+
+export const RetryErrorMessage = () => {
+ const dispatch = useAppDispatch();
+ const navigate = useNavigate();
+ const { pathname } = useLocation();
+ const { t } = useTranslation();
+
+ const dispatchData = async () => {
+ dispatch(initProducts());
+ dispatch(initFavourites());
+ dispatch(initCart());
+ dispatch(initPhones());
+ dispatch(initTablets());
+ dispatch(initAccessories());
+ navigate(`${pathname}`);
+ };
+
+ return (
+
+
+
+
+ {t('error.unknown')}
+
+
+
+
+ {t('error.retry')}
+
+
+
+ );
+};
diff --git a/src/modules/shared/organisms/RetryErrorMessage/index.ts b/src/modules/shared/organisms/RetryErrorMessage/index.ts
new file mode 100644
index 00000000000..188a3e8d7f1
--- /dev/null
+++ b/src/modules/shared/organisms/RetryErrorMessage/index.ts
@@ -0,0 +1 @@
+export * from './RetryErrorMessage';
diff --git a/src/modules/shared/styles/buttons/_index.scss b/src/modules/shared/styles/buttons/_index.scss
new file mode 100644
index 00000000000..70b268be02c
--- /dev/null
+++ b/src/modules/shared/styles/buttons/_index.scss
@@ -0,0 +1,14 @@
+@mixin control-button {
+ background-color: var(--surface2-color);
+ color: var(--icons-color);
+
+ &:hover {
+ background-color: var(--icons-color);
+ }
+
+ &:disabled {
+ background-color: transparent;
+ border: 1px solid var(--elements-color);
+ color: var(--icons-color);
+ }
+}
diff --git a/src/modules/shared/styles/utils/mixins.scss b/src/modules/shared/styles/utils/mixins.scss
new file mode 100644
index 00000000000..988460b4a1b
--- /dev/null
+++ b/src/modules/shared/styles/utils/mixins.scss
@@ -0,0 +1,136 @@
+@mixin on-tablet {
+ @media (min-width: 640px) {
+ @content;
+ }
+}
+
+@mixin on-small-desktop {
+ @media (min-width: 1200px) {
+ @content;
+ }
+}
+
+@mixin on-large-desktop {
+ @media (min-width: 1440px) {
+ @content;
+ }
+}
+
+@mixin hover($property, $value) {
+ transition: #{$property} var(--animation-duration, 0.3s);
+ &:hover {
+ #{$property}: $value;
+ cursor: pointer;
+ }
+}
+
+@mixin outline-selection($important: false) {
+ outline: 2px solid var(--a11y-focus-color);
+ outline-offset: 2px;
+
+ @if $important {
+ outline: 2px solid var(--a11y-focus-color) !important;
+ outline-offset: 2px !important;
+ }
+}
+
+@mixin focus-visible($important: false) {
+ &:focus-visible {
+ @include outline-selection($important);
+ }
+}
+
+@mixin page-grid {
+ --columns: 4;
+
+ display: grid;
+ column-gap: 16px;
+ grid-template-columns: repeat(var(--columns), 1fr);
+
+ @include on-tablet {
+ --columns: 12;
+ }
+
+ @include on-small-desktop {
+ --columns: 24;
+ }
+}
+
+@mixin content-padding-inline {
+ padding-inline: 16px;
+
+ @include on-tablet {
+ padding-inline: 24px;
+ }
+
+ @include on-small-desktop {
+ padding-inline: 32px;
+ }
+
+ @include on-large-desktop {
+ padding-inline: 152px;
+ }
+}
+
+@mixin span-columns($count, $total, $gap: 16px) {
+ $gap-count: $total - 1;
+ $total-gap: $gap-count * $gap;
+
+ width: calc(
+ (100% - #{$total-gap}) / #{$total} * #{$count} + (#{$count - 1} * #{$gap})
+ );
+}
+
+@mixin margin-compensation(
+ $top: 0,
+ $right: $top,
+ $bottom: $top,
+ $left: $right
+) {
+ margin: -#{$top} -#{$right} -#{$bottom} -#{$left};
+ padding: $top $right $bottom $left;
+}
+
+@mixin underline-animate(
+ $color: var(--buttons-text-color-primary),
+ $height: 3px,
+ $duration: var(--animation-duration, 0.3s)
+) {
+ position: relative;
+
+ &::after {
+ content: '';
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ height: $height;
+ background-color: $color;
+ z-index: 1;
+ transform: scaleX(0);
+ transform-origin: right;
+ transition: transform $duration ease-in-out;
+ }
+
+ &:hover::after {
+ transform: scaleX(1);
+ transform-origin: left;
+ }
+}
+
+@mixin flex-column($gap: null, $align: null, $justify: null) {
+ display: flex;
+ flex-direction: column;
+
+ @if $gap {
+ gap: $gap;
+ }
+
+ @if $align {
+ align-items: $align;
+ }
+
+ @if $justify {
+ justify-content: $justify;
+ }
+}
diff --git a/src/modules/shared/templates/CategoryTemplate/CategoryTemplate.module.scss b/src/modules/shared/templates/CategoryTemplate/CategoryTemplate.module.scss
new file mode 100644
index 00000000000..160f65af76e
--- /dev/null
+++ b/src/modules/shared/templates/CategoryTemplate/CategoryTemplate.module.scss
@@ -0,0 +1,13 @@
+@keyframes fade-in {
+ to {
+ opacity: 1;
+ }
+}
+
+.content {
+ display: flex;
+ flex-direction: column;
+ gap: 40px;
+ opacity: 0;
+ animation: fade-in var(--animation-duration, 0.3s) ease forwards;
+}
diff --git a/src/modules/shared/templates/CategoryTemplate/CategoryTemplate.tsx b/src/modules/shared/templates/CategoryTemplate/CategoryTemplate.tsx
new file mode 100644
index 00000000000..dc3fe47631a
--- /dev/null
+++ b/src/modules/shared/templates/CategoryTemplate/CategoryTemplate.tsx
@@ -0,0 +1,61 @@
+import React from 'react';
+import styles from './CategoryTemplate.module.scss';
+import { Heading } from '../../molecules/Heading';
+import { ProductList } from '../../organisms/ProductList';
+import { Product } from '../../../../types/Product';
+import { useTranslation } from 'react-i18next';
+
+import { Breadcrumbs } from '../../../Breadcrumbs';
+import { CatalogControls } from './components/organisms/CatalogControls';
+import { CatalogPagination } from './components/organisms/CatalogPagination';
+import { PageMessage } from '../../molecules/PageMessage';
+
+type Props = {
+ category: string;
+ paginatedProducts: Product[];
+ filteredProducts: Product[];
+ totalPages: number;
+ currentPage: number;
+};
+export const CategoryTemplate: React.FC = ({
+ category,
+ paginatedProducts,
+ filteredProducts,
+ totalPages,
+ currentPage,
+}) => {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+
+
+
+
+
+ {filteredProducts.length === 0 ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+ );
+};
diff --git a/src/modules/shared/templates/CategoryTemplate/components/organisms/CatalogControls/CatalogControls.module.scss b/src/modules/shared/templates/CategoryTemplate/components/organisms/CatalogControls/CatalogControls.module.scss
new file mode 100644
index 00000000000..74cef0ef74c
--- /dev/null
+++ b/src/modules/shared/templates/CategoryTemplate/components/organisms/CatalogControls/CatalogControls.module.scss
@@ -0,0 +1,21 @@
+@use '@shared/styles/utils/mixins.scss' as *;
+
+.controls {
+ @include page-grid;
+
+ &__sort {
+ grid-column: span 2;
+
+ @include on-tablet {
+ grid-column: span 4;
+ }
+ }
+
+ &__quantity {
+ grid-column: span 2;
+
+ @include on-tablet {
+ grid-column: span 3;
+ }
+ }
+}
diff --git a/src/modules/shared/templates/CategoryTemplate/components/organisms/CatalogControls/CatalogControls.tsx b/src/modules/shared/templates/CategoryTemplate/components/organisms/CatalogControls/CatalogControls.tsx
new file mode 100644
index 00000000000..e57064aaf56
--- /dev/null
+++ b/src/modules/shared/templates/CategoryTemplate/components/organisms/CatalogControls/CatalogControls.tsx
@@ -0,0 +1,82 @@
+/* eslint-disable max-len */
+import React from 'react';
+import styles from './CatalogControls.module.scss';
+
+import { useTranslation } from 'react-i18next';
+import { useSearchParams, useNavigate } from 'react-router-dom';
+import { DefaultValues } from '../../../../../../../enums/DefaultValues';
+import { ItemPerPage } from '../../../../../../../enums/ItemsPerPage';
+import { SearchParam } from '../../../../../../../enums/SearchFields';
+import { SortBy } from '../../../../../../../enums/SortBy';
+import { enumToDropdownOptions } from '../../../../../../../helpers/enumToOptions';
+import { getSearchWith } from '../../../../../../../helpers/searchHelper';
+import { Dropdown } from '../../../../../molecules/Dropdown';
+import { DropdownOption } from '../../../../../../../types/DropdownOption';
+
+export const CatalogControls: React.FC = () => {
+ const [searchParams] = useSearchParams();
+ const navigate = useNavigate();
+ const { t } = useTranslation();
+
+ const optionsSort: DropdownOption[] = enumToDropdownOptions(
+ SortBy,
+ t,
+ 'catalog.dropdown.sortBy.options',
+ );
+ const optionsProdPerPage: DropdownOption[] = enumToDropdownOptions(
+ ItemPerPage,
+ t,
+ 'catalog.dropdown.sortBy.options',
+ );
+
+ const currentSortBy = searchParams.get(SearchParam.Sort);
+ const currentPerPage = searchParams.get(SearchParam.PerPage);
+
+ const selectedSortBy = optionsSort.find(
+ option => option.value === (currentSortBy || DefaultValues.Sort),
+ )!;
+ const selectedPerPage = optionsProdPerPage.find(
+ option => option.value === (currentPerPage || DefaultValues.PerPage),
+ )!;
+
+ const handleSortByChange = (newValue: DropdownOption | null) => {
+ const updatedSearch = getSearchWith(searchParams, {
+ [SearchParam.Sort]:
+ (newValue?.value !== DefaultValues.Sort && newValue?.value) || null,
+ [SearchParam.Page]: null,
+ });
+
+ navigate({ search: updatedSearch });
+ };
+
+ const handlePerPageChange = (newValue: DropdownOption | null) => {
+ const updatedSearch = getSearchWith(searchParams, {
+ [SearchParam.PerPage]:
+ (newValue?.value !== DefaultValues.PerPage && newValue?.value) || null,
+ [SearchParam.Page]: null,
+ });
+
+ navigate({ search: updatedSearch });
+ };
+
+ return (
+
+ );
+};
diff --git a/src/modules/shared/templates/CategoryTemplate/components/organisms/CatalogControls/index.ts b/src/modules/shared/templates/CategoryTemplate/components/organisms/CatalogControls/index.ts
new file mode 100644
index 00000000000..47d28e224c6
--- /dev/null
+++ b/src/modules/shared/templates/CategoryTemplate/components/organisms/CatalogControls/index.ts
@@ -0,0 +1 @@
+export * from './CatalogControls';
diff --git a/src/modules/shared/templates/CategoryTemplate/components/organisms/CatalogPagination/CatalogPagination.module.scss b/src/modules/shared/templates/CategoryTemplate/components/organisms/CatalogPagination/CatalogPagination.module.scss
new file mode 100644
index 00000000000..f2424662d39
--- /dev/null
+++ b/src/modules/shared/templates/CategoryTemplate/components/organisms/CatalogPagination/CatalogPagination.module.scss
@@ -0,0 +1,37 @@
+$gap: 8px;
+
+.container {
+ display: flex;
+ gap: var(--field-gap, $gap);
+
+ justify-content: center;
+
+ &__page {
+ --bgcolor: var(--surface1-color);
+
+ background-color: var(--bgcolor);
+
+ &:hover {
+ --bgcolor: var(--elements-color);
+ }
+
+ &--active {
+ --bgcolor: var(--purple-accent-color);
+
+ &:hover {
+ --bgcolor: var(--purple-hover-color);
+ }
+ }
+
+ &--active &-text {
+ color: var(--buttons-text-color-primary-active);
+ }
+ }
+
+ &__pages {
+ display: flex;
+ gap: var(--field-gap, $gap);
+
+ justify-content: space-between;
+ }
+}
diff --git a/src/modules/shared/templates/CategoryTemplate/components/organisms/CatalogPagination/CatalogPagination.tsx b/src/modules/shared/templates/CategoryTemplate/components/organisms/CatalogPagination/CatalogPagination.tsx
new file mode 100644
index 00000000000..185a30751f7
--- /dev/null
+++ b/src/modules/shared/templates/CategoryTemplate/components/organisms/CatalogPagination/CatalogPagination.tsx
@@ -0,0 +1,84 @@
+import React from 'react';
+import styles from './CatalogPagination.module.scss';
+
+import { useNavigate, useSearchParams } from 'react-router-dom';
+
+import classNames from 'classnames';
+import { SearchParam } from '../../../../../../../enums/SearchFields';
+import { getPageRange } from '../../../../../../../helpers/getPaginationPages';
+import { getSearchWith } from '../../../../../../../helpers/searchHelper';
+import { ArrowButton } from '../../../../../atoms/ArrowButton';
+import { IconButton } from '../../../../../atoms/IconButton';
+import { Typography } from '../../../../../atoms/Typography';
+
+type Props = {
+ currentPage: number;
+ totalPages: number;
+};
+
+export const CatalogPagination: React.FC = ({
+ currentPage,
+ totalPages,
+}) => {
+ const [searchParams] = useSearchParams();
+ const navigate = useNavigate();
+
+ if (totalPages <= 1) {
+ return null;
+ }
+
+ const handlePageClick = (newPage: number) => {
+ if (newPage === currentPage) {
+ return;
+ }
+
+ const scrollY = window.scrollY;
+
+ const updatedSearch = getSearchWith(searchParams, {
+ [SearchParam.Page]: newPage.toString(),
+ });
+
+ navigate({ search: updatedSearch });
+
+ setTimeout(() => {
+ window.scrollTo(0, scrollY);
+ }, 0);
+ };
+
+ const range = getPageRange(currentPage, totalPages, 4);
+
+ return (
+
+
handlePageClick(currentPage - 1)}
+ disabled={currentPage === 1}
+ className={styles.container__arrow}
+ direction="left"
+ />
+
+ {range.map(page => (
+ handlePageClick(page)}
+ className={classNames(styles.container__page, {
+ [styles['container__page--active']]: page === currentPage,
+ })}
+ >
+
+ {page}
+
+
+ ))}
+
+ handlePageClick(currentPage + 1)}
+ className={styles.container__arrow}
+ disabled={currentPage === totalPages}
+ direction="right"
+ />
+
+ );
+};
diff --git a/src/modules/shared/templates/CategoryTemplate/components/organisms/CatalogPagination/index.ts b/src/modules/shared/templates/CategoryTemplate/components/organisms/CatalogPagination/index.ts
new file mode 100644
index 00000000000..58fead74de9
--- /dev/null
+++ b/src/modules/shared/templates/CategoryTemplate/components/organisms/CatalogPagination/index.ts
@@ -0,0 +1 @@
+export * from './CatalogPagination';
diff --git a/src/modules/shared/templates/CategoryTemplate/index.ts b/src/modules/shared/templates/CategoryTemplate/index.ts
new file mode 100644
index 00000000000..a0ad2577d4c
--- /dev/null
+++ b/src/modules/shared/templates/CategoryTemplate/index.ts
@@ -0,0 +1 @@
+export * from './CategoryTemplate';
diff --git a/src/modules/shared/templates/MainLayout/Footer/Footer.module.scss b/src/modules/shared/templates/MainLayout/Footer/Footer.module.scss
new file mode 100644
index 00000000000..c3016c1cef6
--- /dev/null
+++ b/src/modules/shared/templates/MainLayout/Footer/Footer.module.scss
@@ -0,0 +1,94 @@
+@use '@shared/styles/utils/mixins.scss' as *;
+
+.footer {
+ box-sizing: border-box;
+ padding-block: 32px;
+
+ @include content-padding-inline;
+
+ background-color: var(--main-background-color);
+ border-top: 1px solid var(--elements-color);
+
+ display: flex;
+
+ align-items: center;
+ justify-content: center;
+
+ &__content {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-direction: column;
+ row-gap: 32px;
+
+ height: 100%;
+ width: min(1200px, 100%);
+
+ @include on-tablet {
+ flex-direction: row;
+ height: 96px;
+ }
+ }
+
+ &__logo {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ width: 100%;
+ height: 32px;
+ color: var(--main-text-color-primary);
+ }
+
+ &__img {
+ display: block;
+ width: 89px;
+ height: 32px;
+ }
+
+ &__nav {
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ gap: 16px;
+ width: 100%;
+
+ @include on-tablet {
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+ }
+ }
+
+ &__link {
+ width: fit-content;
+ }
+
+ &__item {
+ flex-grow: 1;
+ width: 100%;
+ min-height: 50%;
+
+ display: flex;
+ align-items: center;
+
+ &:first-child {
+ justify-content: flex-start;
+ }
+
+ &:last-child {
+ justify-content: center;
+
+ @include on-tablet {
+ justify-content: flex-end;
+ }
+ }
+ }
+
+ &__button {
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ }
+}
diff --git a/src/modules/shared/templates/MainLayout/Footer/Footer.tsx b/src/modules/shared/templates/MainLayout/Footer/Footer.tsx
new file mode 100644
index 00000000000..34b681e1c12
--- /dev/null
+++ b/src/modules/shared/templates/MainLayout/Footer/Footer.tsx
@@ -0,0 +1,61 @@
+import React from 'react';
+import styles from './Footer.module.scss';
+import { LogoLink } from '../../../molecules/LogoLink';
+import classNames from 'classnames';
+import { useTranslation } from 'react-i18next';
+import { FOOTER_LINKS } from './constants';
+import { NavLink } from '../../../atoms/NavLink';
+import { Typography } from '../../../atoms/Typography';
+import { ArrowButton } from '../../../atoms/ArrowButton';
+
+type Props = {
+ className?: string;
+};
+
+export const Footer: React.FC = ({ className }) => {
+ const { t } = useTranslation();
+
+ return (
+
+ );
+};
diff --git a/src/modules/shared/templates/MainLayout/Footer/constants.ts b/src/modules/shared/templates/MainLayout/Footer/constants.ts
new file mode 100644
index 00000000000..86a5d3e3df1
--- /dev/null
+++ b/src/modules/shared/templates/MainLayout/Footer/constants.ts
@@ -0,0 +1,24 @@
+/* eslint-disable max-len */
+export const FOOTER_LINKS = [
+ {
+ label: 'GitHub',
+ path: 'https://github.com/berezandiana/react_phone-catalog/tree/develop',
+ external: true,
+ target: '_blank',
+ rel: 'noopener noreferrer',
+ },
+ {
+ label: 'Contacts',
+ path: 'https://github.com/berezandiana',
+ external: true,
+ target: '_blank',
+ rel: 'noopener noreferrer',
+ },
+ {
+ label: 'Rights',
+ path: 'https://github.com/berezandiana/react_phone-catalog/blob/develop/LICENSE',
+ external: true,
+ target: '_blank',
+ rel: 'noopener noreferrer',
+ },
+];
diff --git a/src/modules/shared/templates/MainLayout/Footer/index.ts b/src/modules/shared/templates/MainLayout/Footer/index.ts
new file mode 100644
index 00000000000..ddcc5a9cd18
--- /dev/null
+++ b/src/modules/shared/templates/MainLayout/Footer/index.ts
@@ -0,0 +1 @@
+export * from './Footer';
diff --git a/src/modules/shared/templates/MainLayout/Header/Header.module.scss b/src/modules/shared/templates/MainLayout/Header/Header.module.scss
new file mode 100644
index 00000000000..90b0e8336ff
--- /dev/null
+++ b/src/modules/shared/templates/MainLayout/Header/Header.module.scss
@@ -0,0 +1,133 @@
+@use '@shared/styles/utils/mixins.scss' as *;
+
+.header {
+ box-sizing: border-box;
+ display: flex;
+ justify-content: center;
+ height: 48px;
+ background-color: var(--main-background-color);
+ border-bottom: 1px solid var(--elements-color);
+ position: sticky;
+ top: 0;
+ z-index: 10;
+
+ // overflow: hidden;
+
+ @include on-small-desktop {
+ height: 64px;
+ }
+
+ &__logo {
+ padding-inline: 16px;
+
+ @include on-tablet {
+ padding-inline: 24px;
+ }
+ }
+
+ &__content {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ height: 100%;
+ width: min(100%, var(--maxwidth-header-footer, 100%));
+ }
+
+ &__icon {
+ aspect-ratio: 1;
+
+ height: 100%;
+
+ background-color: unset;
+
+ &--theme {
+ @include underline-animate;
+ }
+ }
+
+ & &__hideable {
+ display: none;
+
+ height: 100%;
+
+ @include on-tablet {
+ display: flex;
+ }
+ }
+
+ & &__menu {
+ @include on-tablet {
+ display: none;
+ }
+ }
+
+ &__icons {
+ height: 100%;
+ display: flex;
+
+ & > :not(:first-child) {
+ border-left: 1px solid var(--elements-color);
+ }
+ }
+
+ & &__language {
+ display: none;
+
+ @include on-tablet {
+ display: flex;
+ }
+ }
+}
+
+.nav {
+ display: none;
+
+ flex-grow: 1;
+
+ height: 100%;
+
+ @include on-tablet {
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ }
+}
+
+.list {
+ display: none;
+
+ @include on-tablet {
+ display: flex;
+ gap: 32px;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ }
+
+ @include on-small-desktop {
+ gap: 64px;
+ }
+}
+
+.item {
+ display: none;
+
+ @include on-tablet {
+ display: flex;
+ align-items: center;
+ height: 48px;
+ }
+
+ @include on-small-desktop {
+ height: 64px;
+ }
+}
+
+.link {
+ display: none;
+
+ @include on-tablet {
+ display: flex;
+ }
+}
diff --git a/src/modules/shared/templates/MainLayout/Header/Header.tsx b/src/modules/shared/templates/MainLayout/Header/Header.tsx
new file mode 100644
index 00000000000..77a160e4fd6
--- /dev/null
+++ b/src/modules/shared/templates/MainLayout/Header/Header.tsx
@@ -0,0 +1,111 @@
+import React, { useEffect, useState } from 'react';
+import styles from './Header.module.scss';
+import classNames from 'classnames';
+
+import { useTranslation } from 'react-i18next';
+import { HeartIcon } from '../../../../../assets/icons/heart-icon';
+import { Icon } from '../../../atoms/Icon';
+import { MenuIcon } from '../../../../../assets/icons/menu-icon';
+import { ShoppingBagIcon } from '../../../../../assets/icons/shopping-bag-icon';
+import { SideMenu } from '../SideMenu';
+import { NAV_LINKS } from '../../../../../constants/navigation';
+import { HTMLDataAttr } from '../../../../../enums/htmlDataAttribs';
+import { selectTotalItems } from '../../../../../features/cartSlice';
+import { setElementDataAttr } from '../../../../../helpers/setHtmlDataAttr';
+import { useAppSelector } from '../../../../../hooks/hooks';
+import { NavLink as TextNavLink } from '../../../atoms/NavLink';
+import { IconButton } from '../../../atoms/IconButton';
+import { BadgeCounter } from '../../../atoms/BadgeCounter';
+import { LanguageSwitcher } from '../../../molecules/LanguageSwitcher';
+import { ThemeToggleButton } from './components/ThemeToggleButton';
+import { LogoLink } from '../../../molecules/LogoLink';
+import { SettingsMenu } from './components/molecules/SettingsMenu';
+
+type Props = {
+ className?: string;
+};
+
+export const Header: React.FC = ({ className = '' }) => {
+ const { t } = useTranslation();
+
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+
+ const { favourites } = useAppSelector(state => state.favourites);
+ const { cartItems } = useAppSelector(state => state.cart);
+ const totalItems = useAppSelector(selectTotalItems);
+
+ const toggleMenu = () => setIsMenuOpen(prev => !prev);
+ const closeMenu = () => setIsMenuOpen(false);
+
+ useEffect(() => {
+ setElementDataAttr('body', HTMLDataAttr.Menu, isMenuOpen);
+ }, [isMenuOpen]);
+
+ return (
+
+ );
+};
diff --git a/src/modules/shared/templates/MainLayout/Header/components/ThemeToggleButton/ThemeToggleButton.module.scss b/src/modules/shared/templates/MainLayout/Header/components/ThemeToggleButton/ThemeToggleButton.module.scss
new file mode 100644
index 00000000000..1efaf5ed358
--- /dev/null
+++ b/src/modules/shared/templates/MainLayout/Header/components/ThemeToggleButton/ThemeToggleButton.module.scss
@@ -0,0 +1,7 @@
+@use '@shared/styles/utils/mixins.scss' as *;
+
+.icon {
+ background-color: transparent;
+
+ @include underline-animate;
+}
diff --git a/src/modules/shared/templates/MainLayout/Header/components/ThemeToggleButton/ThemeToggleButton.tsx b/src/modules/shared/templates/MainLayout/Header/components/ThemeToggleButton/ThemeToggleButton.tsx
new file mode 100644
index 00000000000..a8989ae6b98
--- /dev/null
+++ b/src/modules/shared/templates/MainLayout/Header/components/ThemeToggleButton/ThemeToggleButton.tsx
@@ -0,0 +1,37 @@
+import React from 'react';
+import styles from './ThemeToggleButton.module.scss';
+import { useDispatch } from 'react-redux';
+import { Icon } from '../../../../../atoms/Icon';
+import { MoonIcon } from '../../../../../../../assets/icons/moon-icon';
+import { SunIcon } from '../../../../../../../assets/icons/sun-icon';
+import { Theme } from '../../../../../../../enums/Theme';
+import { setTheme } from '../../../../../../../features/themeSlice';
+import { useAppSelector } from '../../../../../../../hooks/hooks';
+import { IconButton } from '../../../../../atoms/IconButton';
+import classNames from 'classnames';
+
+type Props = {
+ className?: string;
+};
+
+export const ThemeToggleButton: React.FC = ({ className }) => {
+ const { theme } = useAppSelector(state => state.theme);
+ const dispatch = useDispatch();
+
+ const toggleTheme = () => {
+ dispatch(setTheme(theme === Theme.Dark ? Theme.Light : Theme.Dark));
+ };
+
+ return (
+
+
+ {theme === Theme.Dark ? : }
+
+
+ );
+};
diff --git a/src/modules/shared/templates/MainLayout/Header/components/ThemeToggleButton/index.ts b/src/modules/shared/templates/MainLayout/Header/components/ThemeToggleButton/index.ts
new file mode 100644
index 00000000000..e4fc821bef9
--- /dev/null
+++ b/src/modules/shared/templates/MainLayout/Header/components/ThemeToggleButton/index.ts
@@ -0,0 +1 @@
+export * from './ThemeToggleButton';
diff --git a/src/modules/shared/templates/MainLayout/Header/components/molecules/SettingsMenu/SettingsMenu.module.scss b/src/modules/shared/templates/MainLayout/Header/components/molecules/SettingsMenu/SettingsMenu.module.scss
new file mode 100644
index 00000000000..3b80809d270
--- /dev/null
+++ b/src/modules/shared/templates/MainLayout/Header/components/molecules/SettingsMenu/SettingsMenu.module.scss
@@ -0,0 +1,29 @@
+@use '@shared/styles/utils/mixins.scss' as *;
+
+.icon {
+ background-color: transparent;
+
+ margin-left: -24px;
+
+ @include underline-animate;
+
+ &--active::after {
+ transform: scaleX(1);
+ transform-origin: left;
+ }
+}
+
+.settingsmenu {
+ position: fixed;
+ z-index: 20;
+
+ display: flex;
+ flex-direction: column;
+
+ gap: 8px;
+
+ padding: 8px;
+
+ background-color: var(--surface1-color);
+ border: 1px solid var(--elements-color);
+}
diff --git a/src/modules/shared/templates/MainLayout/Header/components/molecules/SettingsMenu/SettingsMenu.tsx b/src/modules/shared/templates/MainLayout/Header/components/molecules/SettingsMenu/SettingsMenu.tsx
new file mode 100644
index 00000000000..509ce23299c
--- /dev/null
+++ b/src/modules/shared/templates/MainLayout/Header/components/molecules/SettingsMenu/SettingsMenu.tsx
@@ -0,0 +1,89 @@
+import React, { useEffect, useRef, useState } from 'react';
+import styles from './SettingsMenu.module.scss';
+import classNames from 'classnames';
+import { createPortal } from 'react-dom';
+import { Button } from '../../../../../../atoms/Button';
+import { Icon } from '../../../../../../atoms/Icon';
+import { GearIcon } from '../../../../../../../../assets/icons/gear-icon';
+
+type Props = {
+ className?: string;
+ children: React.ReactNode;
+};
+
+export const SettingsMenu: React.FC = ({ className, children }) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const buttonRef = useRef(null);
+ const [popupStyles, setPopupStyles] = useState({});
+
+ useEffect(() => {
+ function handleClickOutside(event: MouseEvent) {
+ if (
+ buttonRef.current &&
+ !buttonRef.current.contains(event.target as Node) &&
+ !document
+ .getElementById('settings-popup')
+ ?.contains(event.target as Node)
+ ) {
+ setIsOpen(false);
+ }
+ }
+
+ if (isOpen) {
+ document.addEventListener('mousedown', handleClickOutside);
+ } else {
+ document.removeEventListener('mousedown', handleClickOutside);
+ }
+
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ };
+ }, [isOpen]);
+
+ useEffect(() => {
+ const updatePopupPosition = () => {
+ if (isOpen && buttonRef.current) {
+ const rect = buttonRef.current.getBoundingClientRect();
+
+ setPopupStyles({
+ top: rect.bottom + 5,
+ left: rect.left,
+ });
+ }
+ };
+
+ updatePopupPosition();
+
+ window.addEventListener('resize', updatePopupPosition);
+
+ return () => window.removeEventListener('resize', updatePopupPosition);
+ }, [isOpen]);
+
+ return (
+ <>
+ setIsOpen(prev => !prev)}
+ isSquare
+ >
+
+
+
+
+
+ {isOpen &&
+ createPortal(
+ ,
+ document.body,
+ )}
+ >
+ );
+};
diff --git a/src/modules/shared/templates/MainLayout/Header/components/molecules/SettingsMenu/index.ts b/src/modules/shared/templates/MainLayout/Header/components/molecules/SettingsMenu/index.ts
new file mode 100644
index 00000000000..b41c178064c
--- /dev/null
+++ b/src/modules/shared/templates/MainLayout/Header/components/molecules/SettingsMenu/index.ts
@@ -0,0 +1 @@
+export * from './SettingsMenu';
diff --git a/src/modules/shared/templates/MainLayout/Header/index.tsx b/src/modules/shared/templates/MainLayout/Header/index.tsx
new file mode 100644
index 00000000000..266dec8a1bc
--- /dev/null
+++ b/src/modules/shared/templates/MainLayout/Header/index.tsx
@@ -0,0 +1 @@
+export * from './Header';
diff --git a/src/modules/shared/templates/MainLayout/MainLayout.module.scss b/src/modules/shared/templates/MainLayout/MainLayout.module.scss
new file mode 100644
index 00000000000..61dd654b110
--- /dev/null
+++ b/src/modules/shared/templates/MainLayout/MainLayout.module.scss
@@ -0,0 +1,30 @@
+@use '@shared/styles/utils/mixins' as *;
+
+.main {
+ flex: 1;
+
+ &__content {
+ margin-inline: auto;
+
+ max-width: 1200px;
+
+ @include content-padding-inline;
+
+ padding-top: 24px;
+ padding-bottom: 56px;
+
+ @include on-tablet {
+ padding-bottom: 64px;
+ }
+
+ @include on-small-desktop {
+ padding-bottom: 80px;
+ }
+ }
+}
+
+.container {
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+}
diff --git a/src/modules/shared/templates/MainLayout/MainLayout.tsx b/src/modules/shared/templates/MainLayout/MainLayout.tsx
new file mode 100644
index 00000000000..24a61fd6e4d
--- /dev/null
+++ b/src/modules/shared/templates/MainLayout/MainLayout.tsx
@@ -0,0 +1,56 @@
+import React, { useEffect } from 'react';
+import { Outlet } from 'react-router-dom';
+import { Header } from './Header';
+
+import styles from './MainLayout.module.scss';
+import { Footer } from './Footer';
+import { HTMLDataAttr } from '../../../../enums/htmlDataAttribs';
+import { setElementDataAttr } from '../../../../helpers/setHtmlDataAttr';
+import { useAppDispatch, useAppSelector } from '../../../../hooks/hooks';
+import { init as initProducts } from '../../../../features/productsSlice';
+import { init as initFavourites } from '../../../../features/favouritesSlice';
+import { init as initCart } from '../../../../features/cartSlice';
+import { init as initPhones } from '../../../../features/phonesSlice';
+import { init as initTablets } from '../../../../features/tabletsSlice';
+import { init as initAccessories } from '../../../../features/accessoriesSlice';
+import { PageStateWrapper } from '../../organisms/PageStateWrapper';
+import { RetryErrorMessage } from '../../organisms/RetryErrorMessage';
+import { Toaster } from 'sonner';
+
+export const MainLayout: React.FC = () => {
+ const dispatch = useAppDispatch();
+ const { theme } = useAppSelector(state => state.theme);
+ const { loading, error } = useAppSelector(state => state.products);
+
+ useEffect(() => {
+ dispatch(initProducts());
+ dispatch(initFavourites());
+ dispatch(initCart());
+ dispatch(initPhones());
+ dispatch(initTablets());
+ dispatch(initAccessories());
+ }, [dispatch]);
+
+ useEffect(() => {
+ setElementDataAttr('html', HTMLDataAttr.Theme, theme);
+ }, [theme]);
+
+ return (
+
+
+
+ {error ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+ );
+};
diff --git a/src/modules/shared/templates/MainLayout/SideMenu/SideMenu.module.scss b/src/modules/shared/templates/MainLayout/SideMenu/SideMenu.module.scss
new file mode 100644
index 00000000000..81abb9c44c7
--- /dev/null
+++ b/src/modules/shared/templates/MainLayout/SideMenu/SideMenu.module.scss
@@ -0,0 +1,212 @@
+@use '@shared/styles/utils/mixins.scss' as *;
+
+.menu {
+ position: fixed;
+ top: 0;
+ right: 0;
+ left: 0;
+ height: 100vh;
+ background-color: var(--main-background-color);
+ z-index: 1;
+
+ transition: all var(--animation-duration, 0.3s) ease-in-out;
+ opacity: 0;
+ transform: translateX(100%);
+ pointer-events: none;
+
+ @include on-tablet {
+ display: none;
+ }
+}
+
+.menuOpen {
+ opacity: 1;
+ transform: translateX(0);
+ pointer-events: all;
+}
+
+.top {
+ box-sizing: border-box;
+ display: flex;
+ justify-content: space-between;
+ width: 100%;
+ height: 48px;
+ padding: 0;
+ border-bottom: 1px solid var(--elements-color);
+ gap: 16px;
+}
+
+.logo {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 96px;
+ height: 48px;
+
+ @include hover(transform, scale(1.1));
+}
+
+.logoImage {
+ width: 64px;
+ height: 22px;
+ padding: 13px 16px;
+ color: var(--main-text-color-primary);
+}
+
+.close {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 48px;
+ cursor: pointer;
+ aspect-ratio: 1;
+ border-left: 1px solid var(--elements-color);
+ background-color: var(--main-background-color);
+ height: 100%;
+}
+
+.content {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+}
+
+.list {
+ width: 288px;
+ list-style: none;
+ gap: 16px;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding-top: 24px;
+ margin-bottom: 16px;
+}
+
+.link {
+ font-size: 12px;
+ text-transform: uppercase;
+ font-weight: 800;
+ text-decoration: none;
+ line-height: 11px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ color: var(--secondary-color);
+ height: 27px;
+ position: relative;
+
+ &:hover {
+ color: var(--main-text-color-primary);
+ }
+}
+
+.activeLink {
+ color: var(--main-text-color-primary);
+
+ &::after {
+ content: '';
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ height: 2px;
+ background-color: var(--main-text-color-primary);
+ transition: opacity var(--animation-duration, 0.3s) ease;
+ }
+
+ &:focus::after,
+ &:active::after {
+ opacity: 1;
+ }
+}
+
+.langSwitcher {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 4px;
+ font-weight: 400;
+ color: var(--main-text-color-secondary);
+}
+
+.langOption {
+ font-size: 12px;
+ text-transform: uppercase;
+ font-weight: 400;
+ line-height: 11px;
+ color: var(--main-text-color-secondary);
+ background-color: var(--main-background-color);
+ padding: 4px;
+ cursor: pointer;
+
+ &--active {
+ color: var(--main-text-color-primary);
+ text-decoration: underline;
+ font-weight: 800;
+ }
+}
+
+.bottom {
+ box-sizing: border-box;
+ display: flex;
+ width: 100%;
+ height: 64px;
+ border-top: 1px solid var(--elements-color);
+ flex-direction: row;
+ position: fixed;
+ bottom: 0;
+}
+
+.icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex: 1;
+ height: 100%;
+ position: relative;
+ &:first-child {
+ border-right: 1px solid var(--elements-color);
+ }
+
+ &::after {
+ content: '';
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 100%;
+ height: 2px;
+ opacity: 0;
+ background-color: var(--buttons-text-color-primary);
+ transition: opacity var(--animation-duration, 0.3s) ease;
+ }
+
+ &:focus::after,
+ &:active::after {
+ opacity: 1;
+ }
+}
+
+.counter {
+ position: absolute;
+ top: -6px;
+ right: -6px;
+ box-sizing: border-box;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 14px;
+ height: 14px;
+ background-color: var(--red-accent-color);
+ border-radius: 50%;
+ border: 1px solid var(--main-background-color);
+
+ font-weight: 600;
+ font-size: 9px;
+ line-height: 100%;
+ letter-spacing: 0%;
+ text-align: center;
+}
diff --git a/src/modules/shared/templates/MainLayout/SideMenu/SideMenu.tsx b/src/modules/shared/templates/MainLayout/SideMenu/SideMenu.tsx
new file mode 100644
index 00000000000..b1e5a3fbabe
--- /dev/null
+++ b/src/modules/shared/templates/MainLayout/SideMenu/SideMenu.tsx
@@ -0,0 +1,87 @@
+import classNames from 'classnames';
+import React from 'react';
+import { NavLink } from 'react-router-dom';
+import styles from './SideMenu.module.scss';
+import { NAV_LINKS } from '../../../../../constants/navigation';
+import { Icon } from '../../../atoms/Icon';
+import { HeartIcon } from '../../../../../assets/icons/heart-icon';
+import { ShoppingBagIcon } from '../../../../../assets/icons/shopping-bag-icon';
+import { CloseIcon } from '../../../../../assets/icons/close-icon';
+import { useAppSelector } from '../../../../../hooks/hooks';
+import { selectTotalItems } from '../../../../../features/cartSlice';
+import { useTranslation } from 'react-i18next';
+import { LanguageSwitcher } from '../../../molecules/LanguageSwitcher';
+import { HeaderLogo } from '../../../../../assets/icons/header-logo-icon';
+
+type Props = {
+ onClose: () => void;
+ isOpen: boolean;
+};
+
+export const SideMenu: React.FC = ({ onClose, isOpen }: Props) => {
+ const getMenuNavLinkClass = ({ isActive }: { isActive: boolean }) =>
+ classNames(styles.link, { [styles.activeLink]: isActive });
+
+ const { favourites } = useAppSelector(state => state.favourites);
+ const { cartItems } = useAppSelector(state => state.cart);
+ const totalItems = useAppSelector(selectTotalItems);
+
+ const { t } = useTranslation();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {NAV_LINKS.map(({ label, path }) => (
+
+
+ {t(`navlink.${label}`)}
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+ {favourites.length > 0 && (
+ {favourites.length}
+ )}
+
+
+
+
+
+ {cartItems.length > 0 && (
+ {totalItems}
+ )}
+
+
+
+
+ );
+};
diff --git a/src/modules/shared/templates/MainLayout/SideMenu/index.tsx b/src/modules/shared/templates/MainLayout/SideMenu/index.tsx
new file mode 100644
index 00000000000..6b65bcfbfe8
--- /dev/null
+++ b/src/modules/shared/templates/MainLayout/SideMenu/index.tsx
@@ -0,0 +1 @@
+export * from './SideMenu';
diff --git a/src/store/index.ts b/src/store/index.ts
new file mode 100644
index 00000000000..d4068169bcd
--- /dev/null
+++ b/src/store/index.ts
@@ -0,0 +1 @@
+export * from './store';
diff --git a/src/store/store.ts b/src/store/store.ts
new file mode 100644
index 00000000000..0a48ba32b03
--- /dev/null
+++ b/src/store/store.ts
@@ -0,0 +1,27 @@
+import { combineSlices, configureStore } from '@reduxjs/toolkit';
+import { productsSlice } from '../features/productsSlice';
+import { favouritesSlice } from '../features/favouritesSlice';
+import { cartSlice } from '../features/cartSlice';
+import { phonesSlice } from '../features/phonesSlice';
+import { tabletsSlice } from '../features/tabletsSlice';
+import { accessoriesSlice } from '../features/accessoriesSlice';
+import { i18nSlice } from '../features/i18nSlice';
+import { themeSlice } from '../features/themeSlice';
+
+const rootReducer = combineSlices({
+ products: productsSlice.reducer,
+ favourites: favouritesSlice.reducer,
+ cart: cartSlice.reducer,
+ phones: phonesSlice.reducer,
+ tablets: tabletsSlice.reducer,
+ accessories: accessoriesSlice.reducer,
+ i18n: i18nSlice.reducer,
+ theme: themeSlice.reducer,
+});
+
+export const store = configureStore({
+ reducer: rootReducer,
+});
+
+export type RootState = ReturnType;
+export type AppDispatch = typeof store.dispatch;
diff --git a/src/styles/index.scss b/src/styles/index.scss
new file mode 100644
index 00000000000..06dab1d04fc
--- /dev/null
+++ b/src/styles/index.scss
@@ -0,0 +1,60 @@
+@use './utils/font.scss';
+@use 'normalize-scss' as *;
+@use './themes/light';
+@use './themes/dark';
+@use './themes/variables';
+@use './utils/normalize.scss';
+@include normalize;
+
+:root {
+ font-family: Mont, system-ui, Ubuntu, sans-serif;
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+.page {
+ line-height: 1.5;
+
+ &__body {
+ min-height: 100vh;
+ min-width: 320px;
+
+ justify-content: center;
+
+ background-color: var(--main-background-color);
+
+ &[data-modal-open='true'],
+ // &[data-settings-open='true'],
+ &[data-menu-open='true'] {
+ overflow: hidden;
+ }
+ }
+}
+
+html,
+body {
+ height: 100%;
+ margin: 0;
+}
+
+body {
+ background-color: var(--main-background-color);
+}
+
+input {
+ background-color: var(--surface1-color);
+ color: var(--main-text-color-primary);
+}
+
+input:-webkit-autofill,
+input:-webkit-autofill:hover,
+input:-webkit-autofill:focus,
+textarea:-webkit-autofill {
+ -webkit-box-shadow: 0 0 0 1000px var(--surface1-color) inset;
+ -webkit-text-fill-color: var(--main-text-color-primary) !important;
+ caret-color: var(--main-text-color-primary);
+ background-color: var(--surface1-color) !important;
+ transition: background-color 5000s ease-in-out 0s;
+}
diff --git a/src/styles/themes/_dark.scss b/src/styles/themes/_dark.scss
new file mode 100644
index 00000000000..200e00aef1a
--- /dev/null
+++ b/src/styles/themes/_dark.scss
@@ -0,0 +1,23 @@
+:root {
+ --icons-color: #4a4d58;
+ --secondary-color: #75767f;
+ --white-accent-color: #f1f2f9;
+ --purple-accent-color: #905bff;
+ --purple-hover-color: #a378ff;
+ --green-accent-color: #27ae60;
+ --red-accent-color: #eb5757;
+ --main-background-color: #0f1121;
+ --buttons-text-color-primary: #f1f2f9;
+ --buttons-text-color-primary-active: var(--white-accent-color);
+ --buttons-text-color-secondary: #75767f;
+ --main-text-color-primary: #f1f2f9;
+ --main-text-color-secondary: #75767f;
+ --surface1-color: #161827;
+ --surface2-color: #323542;
+ --elements-color: #3b3e4a;
+ --buttons-background-active: var(--purple-accent-color);
+ --buttons-background-active-hover: var(--purple-hover-color);
+ --success-color: var(--green-accent-color);
+ --error-color: var(--red-accent-color);
+ --a11y-focus-color: var(--purple-hover-color);
+}
\ No newline at end of file
diff --git a/src/styles/themes/_light.scss b/src/styles/themes/_light.scss
new file mode 100644
index 00000000000..f9d106762ac
--- /dev/null
+++ b/src/styles/themes/_light.scss
@@ -0,0 +1,23 @@
+:root[data-theme='light'] {
+ --icons-color: #6b6f7a;
+ --secondary-color: #9a9ba3;
+ --white-accent-color: #f1f2f9;
+ --purple-accent-color: #905bff;
+ --purple-hover-color: #a378ff;
+ --green-accent-color: #27ae60;
+ --red-accent-color: #eb5757;
+ --main-background-color: #f7f8fc;
+ --buttons-text-color-primary: #2c2d33;
+ --buttons-text-color-primary-active: var(--white-accent-color);
+ --buttons-text-color-secondary: #6b6f7a;
+ --main-text-color-primary: #2c2d33;
+ --main-text-color-secondary: #6b6f7a;
+ --surface1-color: #fff;
+ --surface2-color: #e2e6e9;
+ --elements-color: #d9dbe0;
+ --buttons-background-active: var(--purple-accent-color);
+ --buttons-background-active-hover: var(--purple-hover-color);
+ --success-color: var(--green-accent-color);
+ --error-color: var(--red-accent-color);
+ --a11y-focus-color: var(--purple-hover-color);
+}
\ No newline at end of file
diff --git a/src/styles/themes/_variables.scss b/src/styles/themes/_variables.scss
new file mode 100644
index 00000000000..d2cd5124a57
--- /dev/null
+++ b/src/styles/themes/_variables.scss
@@ -0,0 +1,4 @@
+:root {
+ --animation-duration: 0.3s;
+ --maxwidth-header-footer: 1490px;
+}
diff --git a/src/styles/utils/font.scss b/src/styles/utils/font.scss
new file mode 100644
index 00000000000..65c118081f9
--- /dev/null
+++ b/src/styles/utils/font.scss
@@ -0,0 +1,23 @@
+@font-face {
+ font-family: Mont;
+ src: url('/fonts/Mont-Bold.ttf') format('truetype');
+ font-weight: 800;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: Mont;
+ src: url('/fonts/Mont-SemiBold.ttf') format('truetype');
+ font-weight: 600;
+ font-style: normal;
+ font-display: swap;
+}
+
+@font-face {
+ font-family: Mont;
+ src: url('/fonts/Mont-Regular.ttf') format('truetype');
+ font-weight: 400;
+ font-style: normal;
+ font-display: swap;
+}
diff --git a/src/styles/utils/normalize.scss b/src/styles/utils/normalize.scss
new file mode 100644
index 00000000000..d2a15b7df73
--- /dev/null
+++ b/src/styles/utils/normalize.scss
@@ -0,0 +1,30 @@
+a {
+ text-decoration: none;
+ color: white;
+}
+
+button {
+ border-radius: 0;
+ border: 0;
+ padding: 0;
+}
+
+h1,
+h2,
+h3,
+h4 {
+ margin: 0;
+}
+
+p {
+ margin: 0;
+}
+
+img {
+ display: block;
+}
+
+li {
+ text-decoration: none;
+ list-style: none;
+}
diff --git a/src/types/DropdownOption.ts b/src/types/DropdownOption.ts
new file mode 100644
index 00000000000..1f20b42c161
--- /dev/null
+++ b/src/types/DropdownOption.ts
@@ -0,0 +1,4 @@
+export type DropdownOption = {
+ value: string;
+ label: string;
+};
diff --git a/src/types/Product.ts b/src/types/Product.ts
new file mode 100644
index 00000000000..e5ae479782a
--- /dev/null
+++ b/src/types/Product.ts
@@ -0,0 +1,16 @@
+type Category = 'phones' | 'tablets' | 'accessories';
+
+export interface Product {
+ id: number;
+ category: Category;
+ itemId: string;
+ name: string;
+ fullPrice: number;
+ price: number;
+ screen: string;
+ capacity: string;
+ color: string;
+ ram: string;
+ year: number;
+ image: string;
+}
diff --git a/src/types/ProductDetails.ts b/src/types/ProductDetails.ts
new file mode 100644
index 00000000000..ffbb9c5bebd
--- /dev/null
+++ b/src/types/ProductDetails.ts
@@ -0,0 +1,36 @@
+import { Category } from '../enums/Category';
+
+type Specification = string;
+type ImagePath = string;
+
+export type ProductDetails = {
+ id: string;
+ namespaceId: string;
+
+ name: string;
+ category: Category;
+
+ priceRegular: number;
+ priceDiscount: number;
+
+ capacityAvailable: string[];
+ capacity: string;
+
+ colorsAvailable: string[];
+ color: string;
+
+ images: ImagePath[];
+
+ description: {
+ title: string;
+ text: string[];
+ }[];
+
+ screen: Specification;
+ resolution: Specification;
+ processor: Specification;
+ ram: Specification;
+ cell: Specification[];
+ zoom: Specification;
+ camera: Specification;
+};
diff --git a/src/types/react-i18next.d.ts b/src/types/react-i18next.d.ts
new file mode 100644
index 00000000000..37aa85c8d0a
--- /dev/null
+++ b/src/types/react-i18next.d.ts
@@ -0,0 +1,9 @@
+/* eslint-disable import/extensions */
+import 'react-i18next';
+import { TranslationKey } from '../enums/i18n/Keys';
+
+declare module 'react-i18next' {
+ interface DefaultResources {
+ translation: Record;
+ }
+}
diff --git a/vite.config.ts b/vite.config.ts
index 5a33944a9b4..91d674fd6ce 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,7 +1,20 @@
-import { defineConfig } from 'vite'
-import react from '@vitejs/plugin-react'
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+import path from 'path';
-// https://vitejs.dev/config/
export default defineConfig({
+ base: '/react_phone-catalog/',
plugins: [react()],
-})
+ resolve: {
+ alias: {
+ '@shared': path.resolve(__dirname, 'src/modules/shared'),
+ },
+ },
+ css: {
+ preprocessorOptions: {
+ scss: {
+ additionalData: '',
+ },
+ },
+ },
+});