diff --git a/README.md b/README.md
index 39c9a51fb72..a2e5336f2c6 100644
--- a/README.md
+++ b/README.md
@@ -1,143 +1,200 @@
-# 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.
+
+
+
+
+Nice Gadgets โ Phone Catalog
+
+
+ A multilingual, theme-switchable e-commerce catalog for phones, tablets, and accessories โ built with React 18, TypeScript, Redux Toolkit, and SCSS.
+
+
+
+ ๐ Live Demo |
+ ๐ป GitHub Repo
+
+
+---
+
+## ๐ Project Overview
+
+**Nice Gadgets** is a full-featured product catalog single-page application built with **React 18** and **TypeScript**, modeled after a real e-commerce storefront.
+Users can browse phones, tablets, and accessories, view per-product detail pages with color and capacity variants, manage a shopping cart and favourites list, and switch between **English** and **Ukrainian** translations as well as **light** and **dark** themes โ all with state persisted to **localStorage**.
+
+The app uses **Redux Toolkit** for global state (cart, favourites, products, theme, i18n), **React Router v6** for navigation, and **i18next** for localization. The UI follows an **atomic design** structure (atoms / molecules / organisms) under `src/modules/`, styled with **SCSS modules** on top of **Bulma**. Built as part of the **Mate Academy** React course.
+
+---
+
+## ๐ Live Preview
+
+๐ **[https://bcheban.github.io/react_phone-catalog/](https://bcheban.github.io/react_phone-catalog/)**
+
+---
+
+## ๐จ Design Reference
+
+The UI follows the official **Mate Academy** Figma design โ **Original Dark** variant with custom theme switching.
+
+- ๐จ [Original Design](https://www.figma.com/file/T5ttF21UnT6RRmCQQaZc6L/Phone-catalog-(V2)-Original)
+- ๐ [Original Dark Design](https://www.figma.com/design/WMdJ24eHk4EkSr25mrt7Y2/Phone-catalog--V2--Original-Dark)
+
+---
+
+## ๐ Technologies Used
+
+### Core Framework & Build Tools
+
+
+
+
+
+
+### State Management
+
+
+
+
+
+### Routing
+
+
+
+
+### Internationalization
+
+
+
+
+
+### Styling
+
+
+
+
+
+
+
+### UI & Interaction
+
+
+
+
+
+
+
+
+### Utilities
+
+
+
+
+### Testing
+
+
+
+
+### Linting & Formatting
+
+
+
+
+
+
+### CI / CD
+
+
+
+
+### Deployment
+
+
+
+
+---
+
+## โ๏ธ Getting Started
+
+Follow these steps to run the project locally.
+
+### ๐ Prerequisites
+
+- **Node.js** `v20.x` (see `mateAcademy.nodejsMajorVersion` in `package.json`)
+- **npm** `v10.x` or higher
+- **Git**
+
+### ๐ฆ Installation
+
+1. **Clone the repository:**
+ ```bash
+ git clone https://github.com/bcheban/react_phone-catalog.git phone-catalog_react
+ cd phone-catalog_react
+ ```
+
+2. **Install dependencies:**
+ ```bash
+ npm install
+ ```
+
+3. **Start the dev server:**
+ ```bash
+ npm start
+ ```
+ The app will open at [http://localhost:3000](http://localhost:3000).
+
+### ๐งฐ Available Scripts
+
+| Script | Description |
+| ------------------- | ------------------------------------------------------ |
+| `npm start` | Start the local dev server |
+| `npm run build` | Create a production build in `/build` |
+| `npm test` | Run Cypress E2E tests |
+| `npm run lint` | Run Stylelint, Prettier, ESLint, and CSS lint together |
+| `npm run lint-js` | Run ESLint on `.ts` / `.tsx` files |
+| `npm run lint-css` | Run Stylelint on `.scss` files |
+| `npm run format` | Format source files with Prettier |
+| `npm run deploy` | Build and deploy to GitHub Pages |
+
+---
+
+## ๐ Features
+
+- **๐ Home Page** โ Hero slideshow (Swiper), brand-new products, hot prices, and category cards.
+- **๐ฑ Phones / ๐ป Tablets / ๐ง Accessories Catalogs** โ Dedicated catalog pages with sorting, filtering, and pagination.
+- **๐ Product Detail Pages** โ Image gallery, color and capacity variant switching, full specs, and "About" section.
+- **๐ Shopping Cart** โ Add/remove items, adjust quantity, total price calculation, with confirmation modal on checkout.
+- **โค๏ธ Favourites** โ Save products to a wishlist with persistent state.
+- **๐ Search with Debounce** โ Filtered search via `lodash.debounce` across catalog pages.
+- **๐ Pagination** โ Configurable items-per-page with navigable page controls.
+- **๐ Breadcrumbs** โ Atomic-design breadcrumb navigation across detail pages.
+- **๐ Light / Dark Theme** โ Theme toggle with sun/moon icons, persisted to localStorage.
+- **๐ i18n (EN / UA)** โ Full localization via `react-i18next` with English and Ukrainian dictionaries.
+- **๐ Toast Notifications** โ User feedback via **Sonner**.
+- **๐ฌ Smooth Transitions** โ Page and component animations via **React Transition Group**.
+- **๐ซ Custom 404 / Not Found Pages** โ Dedicated "Page Not Found" and "Product Not Found" screens.
+- **๐พ LocalStorage Persistence** โ Cart, favourites, theme, and language survive page reloads.
+- **๐งช E2E Tested** โ Cypress integration tests (`cypress/integration/page.spec.js`).
+- **๐ค CI Workflows** โ Automated lint & test runs via GitHub Actions.
+- **๐ค Custom Typography** โ Bundled Mont font (Bold / Regular / SemiBold).
+- **๐ฑ Fully Responsive** โ Mobile-first design, optimized for all screen sizes.
+
+---
+
+## โ
README Checklist
+
+- [x] **Project Name** with a clear, descriptive title
+- [x] **Project Overview** with a short description of what the app does
+- [x] **Live Preview** link to the deployed version
+- [x] **Design Reference** with links to the Figma mockups
+- [x] **Technologies Used** with all major tools and libraries listed
+- [x] **Getting Started** with prerequisites, installation, and run instructions
+- [x] **Available Scripts** documented for local development
+- [x] **Features** described in a readable, scannable list
+- [x] **Responsive design** verified on mobile, tablet, and desktop
+- [x] **Deployed** to GitHub Pages and link verified to work
+- [x] **Linted & formatted** โ `npm run lint` passes cleanly
+- [x] **CI workflows** pass on the `develop` branch
+
+---
+
+
+ Made with โค๏ธ by @bcheban ยท Mate Academy React Course
+
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..9590361a728 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"
@@ -90,6 +99,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.9.tgz",
"integrity": "sha512-5e3FI4Q3M3Pbr21+5xJwCv6ZT6KmGkI0vw3Tozy5ODAQFTIWe37iT8Cr7Ice2Ntb+M3iSKCEWMB1MBgKrW3whg==",
"dev": true,
+ "peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.24.7",
@@ -128,7 +138,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 +177,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 +188,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 +200,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 +211,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 +264,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 +275,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 +283,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 +313,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 +327,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 +365,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 +377,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 +390,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 +410,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",
@@ -449,6 +444,7 @@
"url": "https://opencollective.com/csstools"
}
],
+ "peer": true,
"engines": {
"node": "^14 || ^16 || >=18"
},
@@ -471,6 +467,7 @@
"url": "https://opencollective.com/csstools"
}
],
+ "peer": true,
"engines": {
"node": "^14 || ^16 || >=18"
}
@@ -596,6 +593,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 +1198,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 +1293,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 +1306,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 +1314,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 +1321,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",
@@ -1224,15 +1374,13 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-2.0.0.tgz",
"integrity": "sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==",
- "dev": true,
- "peer": true
+ "dev": true
},
"node_modules/@mate-academy/stylelint-config/node_modules/cosmiconfig": {
"version": "8.3.6",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz",
"integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==",
"dev": true,
- "peer": true,
"dependencies": {
"import-fresh": "^3.3.0",
"js-yaml": "^4.1.0",
@@ -1259,7 +1407,6 @@
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-5.0.1.tgz",
"integrity": "sha512-VfxadyCECXgQlkoEAjeghAr5gY3Hf+IKjKb+X8tGVDtveCjN+USwprd2q3QXBR9T1+x2DG0XZF5/w+7HAtSaXA==",
"dev": true,
- "peer": true,
"engines": {
"node": ">=10"
},
@@ -1272,7 +1419,6 @@
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-7.0.2.tgz",
"integrity": "sha512-TfW7/1iI4Cy7Y8L6iqNdZQVvdXn0f8B4QcIXmkIbtTIe/Okm/nSlHb4IwGzRVOd3WfSieCgvf5cMzEfySAIl0g==",
"dev": true,
- "peer": true,
"dependencies": {
"flat-cache": "^3.2.0"
},
@@ -1291,7 +1437,6 @@
"resolved": "https://registry.npmjs.org/meow/-/meow-10.1.5.tgz",
"integrity": "sha512-/d+PQ4GKmGvM9Bee/DPa8z3mXs/pkvJE2KEThngVNOqtmljC6K7NMPxtc2JeZYTmpWb9k/TmxjeL18ez3h7vCw==",
"dev": true,
- "peer": true,
"dependencies": {
"@types/minimist": "^1.2.2",
"camelcase-keys": "^7.0.0",
@@ -1318,7 +1463,6 @@
"resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-6.0.0.tgz",
"integrity": "sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==",
"dev": true,
- "peer": true,
"engines": {
"node": ">=12.0"
},
@@ -1335,7 +1479,6 @@
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
"integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
"dev": true,
- "peer": true,
"engines": {
"node": ">=8"
}
@@ -1486,7 +1629,6 @@
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz",
"integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==",
"dev": true,
- "peer": true,
"engines": {
"node": ">=10"
},
@@ -1534,7 +1676,6 @@
"resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-5.1.1.tgz",
"integrity": "sha512-rh3G3wDO8J9wSjfI436JUKzHIxq8NaiL0tVeB2aXmG6p/9859aUOAjA9pmSPNGGZxfwmaJ9ozOJImuNVJdpvbA==",
"dev": true,
- "peer": true,
"engines": {
"node": ">= 18"
}
@@ -1563,7 +1704,6 @@
"resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.1.tgz",
"integrity": "sha512-JYjh5rMOwXMJyUpj028cu0Gbp7qe/ihxfJMLc8VZBMMqSwLgOxDI1911gV4Enl1QSavAQNJcwmwBF9M0VvLh6Q==",
"dev": true,
- "peer": true,
"dependencies": {
"@octokit/types": "^13.0.0",
"universal-user-agent": "^7.0.2"
@@ -1577,7 +1717,6 @@
"resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-8.1.1.tgz",
"integrity": "sha512-ukiRmuHTi6ebQx/HFRCXKbDlOh/7xEV6QUXaE7MJEKGNAncGI/STSbOkl12qVXZrfZdpXctx5O9X1AIaebiDBg==",
"dev": true,
- "peer": true,
"dependencies": {
"@octokit/request": "^9.0.0",
"@octokit/types": "^13.0.0",
@@ -1591,8 +1730,7 @@
"version": "22.2.0",
"resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-22.2.0.tgz",
"integrity": "sha512-QBhVjcUa9W7Wwhm6DBFu6ZZ+1/t/oYxqc2tp81Pi41YNuJinbFRx8B133qVOrAaBbF7D/m0Et6f9/pZt9Rc+tg==",
- "dev": true,
- "peer": true
+ "dev": true
},
"node_modules/@octokit/plugin-paginate-rest": {
"version": "2.21.3",
@@ -1654,7 +1792,6 @@
"resolved": "https://registry.npmjs.org/@octokit/request/-/request-9.1.3.tgz",
"integrity": "sha512-V+TFhu5fdF3K58rs1pGUJIDH5RZLbZm5BI+MNF+6o/ssFNT4vWlCh/tVpF3NxGtP15HUxTTMUbsG5llAuU2CZA==",
"dev": true,
- "peer": true,
"dependencies": {
"@octokit/endpoint": "^10.0.0",
"@octokit/request-error": "^6.0.1",
@@ -1670,7 +1807,6 @@
"resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.4.tgz",
"integrity": "sha512-VpAhIUxwhWZQImo/dWAN/NpPqqojR6PSLgLYAituLM6U+ddx9hCioFGwBr5Mi+oi5CLeJkcAs3gJ0PYYzU6wUg==",
"dev": true,
- "peer": true,
"dependencies": {
"@octokit/types": "^13.0.0"
},
@@ -1858,7 +1994,6 @@
"resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.5.0.tgz",
"integrity": "sha512-HdqWTf5Z3qwDVlzCrP8UJquMwunpDiMPt5er+QjGzL4hqr/vBVY/MauQgS1xWxCDT1oMx1EULyqxncdCY/NVSQ==",
"dev": true,
- "peer": true,
"dependencies": {
"@octokit/openapi-types": "^22.2.0"
}
@@ -1875,6 +2010,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 +2287,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,12 +2362,28 @@
"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",
"integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==",
- "dev": true,
- "peer": true
+ "dev": true
},
"node_modules/@types/node": {
"version": "20.14.10",
@@ -2209,20 +2398,24 @@
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz",
"integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==",
- "dev": true,
- "peer": true
+ "dev": 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,
+ "peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -2233,6 +2426,7 @@
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz",
"integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==",
"dev": true,
+ "peer": true,
"dependencies": {
"@types/react": "*"
}
@@ -2241,7 +2435,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 +2451,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",
@@ -2307,6 +2506,7 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.16.0.tgz",
"integrity": "sha512-ar9E+k7CU8rWi2e5ErzQiC93KKEFAXA2Kky0scAlPcxYblLt8+XZuHUZwlyfXILyQa95P6lQg+eZgh/dDs3+Vw==",
"dev": true,
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "7.16.0",
"@typescript-eslint/types": "7.16.0",
@@ -2352,7 +2552,6 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.16.0.tgz",
"integrity": "sha512-j0fuUswUjDHfqV/UdW6mLtOQQseORqfdmoBNDFOqs9rvNVR2e+cmu6zJu/Ku4SDuqiJko6YnhwcL8x45r8Oqxg==",
"dev": true,
- "peer": true,
"dependencies": {
"@typescript-eslint/typescript-estree": "7.16.0",
"@typescript-eslint/utils": "7.16.0",
@@ -2421,7 +2620,6 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.16.0.tgz",
"integrity": "sha512-PqP4kP3hb4r7Jav+NiRCntlVzhxBNWq6ZQ+zQwII1y/G/1gdIPeYDCKr2+dH6049yJQsWZiHU6RlwvIFBXXGNA==",
"dev": true,
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"@typescript-eslint/scope-manager": "7.16.0",
@@ -2486,6 +2684,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz",
"integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==",
"dev": true,
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2568,7 +2767,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"
},
@@ -2809,7 +3007,6 @@
"resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz",
"integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==",
"dev": true,
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -2916,6 +3113,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",
@@ -2955,8 +3183,7 @@
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-3.0.2.tgz",
"integrity": "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==",
- "dev": true,
- "peer": true
+ "dev": true
},
"node_modules/binary-extensions": {
"version": "2.3.0",
@@ -3007,8 +3234,7 @@
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz",
"integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==",
- "dev": true,
- "peer": true
+ "dev": true
},
"node_modules/browserslist": {
"version": "4.23.2",
@@ -3029,6 +3255,7 @@
"url": "https://github.com/sponsors/ai"
}
],
+ "peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001640",
"electron-to-chromium": "^1.4.820",
@@ -3112,7 +3339,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"
}
@@ -3122,7 +3348,6 @@
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz",
"integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
"dev": true,
- "peer": true,
"engines": {
"node": ">=10"
},
@@ -3135,7 +3360,6 @@
"resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-7.0.2.tgz",
"integrity": "sha512-Rjs1H+A9R+Ig+4E/9oyB66UC5Mj9Xq3N//vcLf2WzgdTi/3gUu3Z9KoqmlrEG4VuuLK8wJHofxzdQXz/knhiYg==",
"dev": true,
- "peer": true,
"dependencies": {
"camelcase": "^6.3.0",
"map-obj": "^4.1.0",
@@ -3154,7 +3378,6 @@
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz",
"integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==",
"dev": true,
- "peer": true,
"engines": {
"node": ">=10"
},
@@ -3192,7 +3415,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",
@@ -3324,7 +3546,6 @@
"resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
"integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==",
"dev": true,
- "peer": true,
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
@@ -3335,7 +3556,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 +3563,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",
@@ -3521,6 +3740,7 @@
"integrity": "sha512-ou/MQUDq4tcDJI2FsPaod2FZpex4kpIK43JJlcBgWrX8WX7R/05ZxGTuxedOuZBfxjZxja+fbijZGyxiLP6CFA==",
"dev": true,
"hasInstallScript": true,
+ "peer": true,
"dependencies": {
"@cypress/request": "^3.0.0",
"@cypress/xvfb": "^1.2.4",
@@ -3754,7 +3974,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"
},
@@ -3772,7 +3991,6 @@
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz",
"integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==",
"dev": true,
- "peer": true,
"engines": {
"node": ">=10"
},
@@ -3785,7 +4003,6 @@
"resolved": "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.1.tgz",
"integrity": "sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==",
"dev": true,
- "peer": true,
"dependencies": {
"decamelize": "^1.1.0",
"map-obj": "^1.0.0"
@@ -3802,7 +4019,6 @@
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"dev": true,
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -3812,7 +4028,6 @@
"resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz",
"integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==",
"dev": true,
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -3997,6 +4212,7 @@
"resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz",
"integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==",
"dev": true,
+ "peer": true,
"dependencies": {
"ansi-colors": "^4.1.1",
"strip-ansi": "^6.0.1"
@@ -4018,7 +4234,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 +4473,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"
}
@@ -4268,6 +4482,7 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz",
"integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==",
"dev": true,
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -4365,6 +4580,7 @@
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz",
"integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==",
"dev": true,
+ "peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -4462,6 +4678,7 @@
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz",
"integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==",
"dev": true,
+ "peer": true,
"dependencies": {
"array-includes": "^3.1.7",
"array.prototype.findlastindex": "^1.2.3",
@@ -5188,6 +5405,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",
@@ -5209,7 +5432,6 @@
"resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz",
"integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==",
"dev": true,
- "peer": true,
"bin": {
"flat": "cli.js"
}
@@ -5311,7 +5533,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"
}
@@ -5564,7 +5785,6 @@
"integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"dev": true,
- "peer": true,
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
@@ -5596,7 +5816,6 @@
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"dev": true,
- "peer": true,
"dependencies": {
"brace-expansion": "^2.0.1"
},
@@ -5667,7 +5886,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"
}
@@ -5743,7 +5961,6 @@
"resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz",
"integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==",
"dev": true,
- "peer": true,
"engines": {
"node": ">=6"
}
@@ -5761,7 +5978,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 +6037,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"
},
@@ -5834,17 +6049,24 @@
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
"dev": true,
- "peer": true,
"bin": {
"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",
"integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==",
"dev": true,
- "peer": true,
"dependencies": {
"lru-cache": "^6.0.0"
},
@@ -5857,7 +6079,6 @@
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
- "peer": true,
"dependencies": {
"yallist": "^4.0.0"
},
@@ -5869,8 +6090,16 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
- "dev": true,
- "peer": true
+ "dev": 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",
@@ -5907,6 +6136,35 @@
"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",
+ "peer": true,
+ "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 +6194,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 +6214,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"
@@ -5963,7 +6230,6 @@
"resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz",
"integrity": "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==",
"dev": true,
- "peer": true,
"engines": {
"node": ">=8"
}
@@ -6061,8 +6327,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 +6412,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"
},
@@ -6338,7 +6602,6 @@
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
"integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
"dev": true,
- "peer": true,
"engines": {
"node": ">=8"
}
@@ -6580,7 +6843,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 +6859,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 +7012,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 +7062,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",
@@ -7079,7 +7345,6 @@
"resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz",
"integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==",
"dev": true,
- "peer": true,
"engines": {
"node": ">=8"
},
@@ -7103,6 +7368,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",
@@ -7178,7 +7449,6 @@
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
"dev": true,
- "peer": true,
"engines": {
"node": ">=4"
}
@@ -7212,7 +7482,6 @@
"resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz",
"integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==",
"dev": true,
- "peer": true,
"dependencies": {
"arrify": "^1.0.1",
"is-plain-obj": "^1.1.0",
@@ -7227,7 +7496,6 @@
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz",
"integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==",
"dev": true,
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -7237,7 +7505,6 @@
"resolved": "https://registry.npmjs.org/mocha/-/mocha-10.6.0.tgz",
"integrity": "sha512-hxjt4+EEB0SA0ZDygSS015t65lJw/I2yRCS3Ae+SJ5FrbzrXgfYwJr96f0OvIXdj7h4lv/vLCrH3rkiuizFSvw==",
"dev": true,
- "peer": true,
"dependencies": {
"ansi-colors": "^4.1.3",
"browser-stdout": "^1.3.1",
@@ -7273,7 +7540,6 @@
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
- "peer": true,
"engines": {
"node": ">=10"
},
@@ -7286,7 +7552,6 @@
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
- "peer": true,
"engines": {
"node": ">=8"
}
@@ -7296,7 +7561,6 @@
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"dev": true,
- "peer": true,
"dependencies": {
"brace-expansion": "^2.0.1"
},
@@ -7308,15 +7572,13 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "dev": true,
- "peer": true
+ "dev": true
},
"node_modules/mocha/node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
- "peer": true,
"dependencies": {
"has-flag": "^4.0.0"
},
@@ -7830,8 +8092,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",
@@ -7907,7 +8168,6 @@
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz",
"integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==",
"dev": true,
- "peer": true,
"dependencies": {
"hosted-git-info": "^4.0.1",
"is-core-module": "^2.5.0",
@@ -7927,6 +8187,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 +8483,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 +8494,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 +8537,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 +8558,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 +8577,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",
@@ -8452,6 +8713,7 @@
"url": "https://github.com/sponsors/ai"
}
],
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.7",
"picocolors": "^1.0.1",
@@ -8530,6 +8792,7 @@
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.1.tgz",
"integrity": "sha512-b4dlw/9V8A71rLIDsSwVmak9z2DuBUB7CA1/wSdelNEzqsjoSPeADTWNO09lpH49Diy3/JIZ2bSPB1dI3LJCHg==",
"dev": true,
+ "peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -8558,6 +8821,7 @@
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
"integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==",
"dev": true,
+ "peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -8688,7 +8952,6 @@
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
"integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
"dev": true,
- "peer": true,
"engines": {
"node": ">=10"
},
@@ -8701,7 +8964,6 @@
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
"dev": true,
- "peer": true,
"dependencies": {
"safe-buffer": "^5.1.0"
}
@@ -8710,6 +8972,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -8721,6 +8984,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -8729,11 +8993,62 @@
"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",
+ "peer": true,
+ "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 +9088,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",
@@ -8793,7 +9129,6 @@
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-6.0.0.tgz",
"integrity": "sha512-X1Fu3dPuk/8ZLsMhEj5f4wFAF0DWoK7qhGJvgaijocXxBmSToKfbFtqbxMO7bVjNA1dmE5huAzjXj/ey86iw9Q==",
"dev": true,
- "peer": true,
"dependencies": {
"@types/normalize-package-data": "^2.4.0",
"normalize-package-data": "^3.0.2",
@@ -8812,7 +9147,6 @@
"resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-8.0.0.tgz",
"integrity": "sha512-snVCqPczksT0HS2EC+SxUndvSzn6LRCwpfSvLrIfR5BKDQQZMaI6jPRC9dYvYFDRAuFEAnkwww8kBBNE/3VvzQ==",
"dev": true,
- "peer": true,
"dependencies": {
"find-up": "^5.0.0",
"read-pkg": "^6.0.0",
@@ -8830,7 +9164,6 @@
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz",
"integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==",
"dev": true,
- "peer": true,
"engines": {
"node": ">=10"
},
@@ -8843,7 +9176,6 @@
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz",
"integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==",
"dev": true,
- "peer": true,
"engines": {
"node": ">=10"
},
@@ -8868,7 +9200,6 @@
"resolved": "https://registry.npmjs.org/redent/-/redent-4.0.0.tgz",
"integrity": "sha512-tYkDkVVtYkSVhuQ4zBgfvciymHaeuel+zFKXShfDnFP5SyVEP7qo70Rf1jTOTCx3vGNAbnEi/xFkcfQVMIBWag==",
"dev": true,
- "peer": true,
"dependencies": {
"indent-string": "^5.0.0",
"strip-indent": "^4.0.0"
@@ -8885,7 +9216,6 @@
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz",
"integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==",
"dev": true,
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -8893,6 +9223,22 @@
"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",
+ "peer": true
+ },
+ "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 +9260,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 +9317,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 +9343,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"
}
@@ -9223,6 +9568,7 @@
"resolved": "https://registry.npmjs.org/sass/-/sass-1.77.8.tgz",
"integrity": "sha512-4UHg6prsrycW20fqLGPShtEvo/WyHRVRHwOP4DzkUrObWoWI05QBSfzU71TVB7PFaL104TwNaHpjlWXAZbQiNQ==",
"dev": true,
+ "peer": true,
"dependencies": {
"chokidar": ">=3.0.0 <4.0.0",
"immutable": "^4.0.0",
@@ -9260,7 +9606,6 @@
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
"integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
"dev": true,
- "peer": true,
"dependencies": {
"randombytes": "^2.1.0"
}
@@ -9453,6 +9798,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",
@@ -9467,7 +9831,6 @@
"resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz",
"integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==",
"dev": true,
- "peer": true,
"dependencies": {
"spdx-expression-parse": "^3.0.0",
"spdx-license-ids": "^3.0.0"
@@ -9477,15 +9840,13 @@
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz",
"integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==",
- "dev": true,
- "peer": true
+ "dev": true
},
"node_modules/spdx-expression-parse": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz",
"integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==",
"dev": true,
- "peer": true,
"dependencies": {
"spdx-exceptions": "^2.1.0",
"spdx-license-ids": "^3.0.0"
@@ -9495,8 +9856,7 @@
"version": "3.0.18",
"resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.18.tgz",
"integrity": "sha512-xxRs31BqRYHwiMzudOrpSiHtZ8i/GeionCBDSilhYRj+9gIcI8wCZTlXZKu9vZIVqViP3dcp9qE5G6AlIaD+TQ==",
- "dev": true,
- "peer": true
+ "dev": true
},
"node_modules/sshpk": {
"version": "1.18.0",
@@ -9694,7 +10054,6 @@
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.0.0.tgz",
"integrity": "sha512-mnVSV2l+Zv6BLpSD/8V87CW/y9EmmbYzGCIavsnsI6/nwn26DwffM/yztm30Z/I2DY9wdS3vXVCMnHDgZaVNoA==",
"dev": true,
- "peer": true,
"dependencies": {
"min-indent": "^1.0.1"
},
@@ -9733,8 +10092,7 @@
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/style-search/-/style-search-0.1.0.tgz",
"integrity": "sha512-Dj1Okke1C3uKKwQcetra4jSuk0DqbzbYtXipzFlFMZtowbF1x7BKJwB9AayVMyFARvU8EDrZdcax4At/452cAg==",
- "dev": true,
- "peer": true
+ "dev": true
},
"node_modules/stylelint": {
"version": "16.7.0",
@@ -9866,11 +10224,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 +10279,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 +10292,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 +10464,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"
}
@@ -10144,7 +10524,6 @@
"resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-4.1.1.tgz",
"integrity": "sha512-jRKj0n0jXWo6kh62nA5TEh3+4igKDXLvzBJcPpiizP7oOolUrYIxmVBG9TOtHYFHoddUk6YvAkGeGoSVTXfQXQ==",
"dev": true,
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -10334,7 +10713,8 @@
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz",
"integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==",
- "dev": true,
+ "devOptional": true,
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -10368,8 +10748,7 @@
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.2.tgz",
"integrity": "sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q==",
- "dev": true,
- "peer": true
+ "dev": true
},
"node_modules/universalify": {
"version": "2.0.1",
@@ -10438,6 +10817,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",
@@ -10458,7 +10860,6 @@
"resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
"integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==",
"dev": true,
- "peer": true,
"dependencies": {
"spdx-correct": "^3.0.0",
"spdx-expression-parse": "^3.0.0"
@@ -10492,6 +10893,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-5.3.3.tgz",
"integrity": "sha512-NPQdeCU0Dv2z5fu+ULotpuq5yfCS1BzKUIPhNbP3YBfAMGJXbt2nS+sbTFu+qchaqWTD+H3JK++nRwr6XIcp6A==",
"dev": true,
+ "peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.39",
@@ -10542,6 +10944,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",
@@ -10804,8 +11215,7 @@
"version": "6.5.1",
"resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz",
"integrity": "sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==",
- "dev": true,
- "peer": true
+ "dev": true
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
@@ -10903,12 +11313,20 @@
"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",
"integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==",
"dev": true,
- "peer": true,
"dependencies": {
"cliui": "^7.0.2",
"escalade": "^3.1.1",
@@ -10927,7 +11345,6 @@
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz",
"integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==",
"dev": true,
- "peer": true,
"engines": {
"node": ">=10"
}
@@ -10937,7 +11354,6 @@
"resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz",
"integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==",
"dev": true,
- "peer": true,
"dependencies": {
"camelcase": "^6.0.0",
"decamelize": "^4.0.0",
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/setup.md b/setup.md
index 7a3835e07b0..db99c0a748e 100644
--- a/setup.md
+++ b/setup.md
@@ -155,7 +155,7 @@ jobs:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
- node-version: [20.x]
+ node-version: "12.x"
- run: npm ci
- run: npm run build
- name: Deploy
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..33fb63b6d03
--- /dev/null
+++ b/src/hooks/useFilteredProducts.ts
@@ -0,0 +1,44 @@
+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 queryTerms = query.toLowerCase().split(/\s+/).filter(Boolean);
+
+ const result = products.filter(p => {
+ if (category !== '' && p.category !== category) {
+ return false;
+ }
+
+ if (queryTerms.length === 0) {
+ return true;
+ }
+
+ const name = p.name.toLowerCase();
+
+ return queryTerms.every(term => name.includes(term));
+ });
+
+ 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/descriptions.ua.ts b/src/i18n/descriptions.ua.ts
new file mode 100644
index 00000000000..daddb6b1365
--- /dev/null
+++ b/src/i18n/descriptions.ua.ts
@@ -0,0 +1,352 @@
+/* eslint-disable max-len */
+type DescriptionArticle = {
+ title: string;
+ text: string[];
+};
+
+const sharedIphoneClassic: DescriptionArticle[] = [
+ {
+ title: 'ะ ะฟะพััะผ ะทสผัะฒะธะฒัั Pro',
+ text: [
+ 'ะ ะตะฒะพะปัััะนะฝะฐ ัะธััะตะผะฐ ะท ัััะพั
ะบะฐะผะตั, ัะบะฐ ะดะพะดะฐั ะฑะตะทะปัั ะผะพะถะปะธะฒะพััะตะน ะฑะตะท ะทะฐะนะฒะพั ัะบะปะฐะดะฝะพััั.',
+ 'ะะตะทะฟัะตัะตะดะตะฝัะฝะธะน ะฟัะธัััั ัะฐัั ะฐะฒัะพะฝะพะผะฝะพั ัะพะฑะพัะธ. ะ ะฟัะธะณะพะปะพะผัะปะธะฒะธะน ัะธะฟ, ัะบะธะน ะฒะธะฒะพะดะธัั ะผะฐัะธะฝะฝะต ะฝะฐะฒัะฐะฝะฝั ะฝะฐ ะฝะพะฒะธะน ััะฒะตะฝั ัะฐ ัะพะทัะธััั ะผะตะถั ะผะพะถะปะธะฒะพะณะพ ะดะปั ัะผะฐัััะพะฝะฐ. ะะฝะฐะนะพะผัะตัั ะท ะฟะตััะธะผ iPhone, ะดะพััะฐัะฝัะพ ะฟะพััะถะฝะธะผ, ัะพะฑ ะฝะฐะทะธะฒะฐัะธัั Pro.',
+ ],
+ },
+ {
+ title: 'ะะฐะผะตัะฐ',
+ text: [
+ 'ะะฝะฐะนะพะผัะตัั ะท ะฟะตััะพั ัะธััะตะผะพั ะท ัััะพั
ะบะฐะผะตั, ัะพ ะฟะพัะดะฝัั ะฟะตัะตะดะพะฒั ัะตั
ะฝะพะปะพะณัั ะท ะปะตะณะตะฝะดะฐัะฝะพั ะฟัะพััะพัะพั iPhone. ะะฐั
ะพะฟะปัะนัะต ั ะบะฐะดั ะดะพ ัะพัะธััะพั
ัะฐะทัะฒ ะฑัะปััะต ััะตะฝะธ. ะััะธะผัะนัะต ััะดะพะฒั ะทะฝัะผะบะธ ะทะฐ ัะปะฐะฑะบะพะณะพ ะพัะฒััะปะตะฝะฝั. ะะฝัะผะฐะนัะต ะฒัะดะตะพ ะฝะฐะนะฒะธัะพั ัะบะพััั ัะตัะตะด ัะผะฐัััะพะฝัะฒ โ ั ะพะดัะฐะทั ัะตะดะฐะณัะนัะต ะนะพะณะพ ัะธะผะธ ะถ ัะฝััััะผะตะฝัะฐะผะธ, ัะพ ะน ัะพัะพ. ะะธ ัะต ะฝะต ะทะฝัะผะฐะปะธ ะฝััะพะณะพ ะฟะพะดัะฑะฝะพะณะพ.',
+ ],
+ },
+ {
+ title:
+ 'ะะฝัะผะฐะน. ะะตัะตะฒะตััะฐะน. ะะฑัะปัััะน. ะะฑััะทะฐะน. ะะพะฝััะน. ะัะฒััะปัะน. ะะพะพะฟัะฐััะพะฒัะน. ะะพั
ะฐะน.',
+ text: [
+ 'iPhone 11 Pro ะดะพะทะฒะพะปัั ะทะฝัะผะฐัะธ ะฒัะดะตะพ, ัะพ ััะดะพะฒะพ ะฟะตัะตะดะฐััั ัะตะฐะปัะฝัััั, ะท ะฑัะปััะพั ะดะตัะฐะปัะทะฐัััั ัะฐ ะฟะปะฐะฒะฝััะธะผ ััั
ะพะผ. ะะฟััะฝะฐ ะฟะพััะถะฝัััั ะฟัะพัะตัะพัะฐ ะดะฐั ะทะผะพะณั ะทะฝัะผะฐัะธ 4K-ะฒัะดะตะพ ะท ัะพะทัะธัะตะฝะธะผ ะดะธะฝะฐะผััะฝะธะผ ะดัะฐะฟะฐะทะพะฝะพะผ ั ะบัะฝะตะผะฐัะพะณัะฐัััะฝะพั ััะฐะฑัะปัะทะฐัััั โ ั ะฒัะต ัะต ั 60 ะบะฐะดัะฐั
ะฝะฐ ัะตะบัะฝะดั. ะะธ ะพััะธะผัััะต ะฑัะปััะต ัะฒะพััะพะณะพ ะบะพะฝััะพะปั ัะฐ ะฟะพััะถะฝั ะฝะพะฒั ัะฝััััะผะตะฝัะธ ะดะปั ัะตะดะฐะณัะฒะฐะฝะฝั.',
+ ],
+ },
+];
+
+export const descriptionsUa: Record = {
+ 'apple-iphone-11': sharedIphoneClassic,
+ 'apple-iphone-11-pro': sharedIphoneClassic,
+ 'apple-iphone-11-pro-max': sharedIphoneClassic,
+ 'apple-iphone-7': sharedIphoneClassic,
+ 'apple-iphone-7-plus': sharedIphoneClassic,
+ 'apple-iphone-8': sharedIphoneClassic,
+ 'apple-iphone-xr': sharedIphoneClassic,
+ 'apple-iphone-xs': sharedIphoneClassic,
+ 'apple-iphone-xs-max': sharedIphoneClassic,
+
+ 'apple-iphone-14': [
+ {
+ title: 'ะัะตะบัะฐัะฝะธะน',
+ text: sharedIphoneClassic[0].text,
+ },
+ sharedIphoneClassic[1],
+ sharedIphoneClassic[2],
+ ],
+
+ 'apple-iphone-14-pro': [
+ {
+ title: 'ะ ะพัั ะฑัะฒ Pro',
+ text: sharedIphoneClassic[0].text,
+ },
+ sharedIphoneClassic[1],
+ sharedIphoneClassic[2],
+ ],
+
+ 'apple-iphone-12': [
+ {
+ title: 'ะจะฒะธะดัะต ะทะฐ ัะฒะธะดะบัััั.',
+ text: [
+ 'ะจะฒะธะดะบัััั 5G. A14 Bionic โ ะฝะฐะนัะฒะธะดัะธะน ัะธะฟ ั ัะผะฐัััะพะฝั. OLED-ะดะธัะฟะปะตะน ะฒัะด ะบัะฐั ะดะพ ะบัะฐั. Ceramic Shield ัะท ัะดะฒััั ะบัะฐัะพั ัััะนะบัััั ะดะพ ะฟะฐะดัะฝั. ะ ะฝััะฝะธะน ัะตะถะธะผ ั ะบะพะถะฝัะน ะบะฐะผะตัั. iPhone 12 ะผะฐั ะฒัะต โ ั ะดะฒะพั
ัะดะตะฐะปัะฝะธั
ัะพะทะผััะฐั
.',
+ ],
+ },
+ {
+ title: 'ะงะธะฟ A14 Bionic. ะะดะธะฝะต, ัะพ ะฑัะปะพ ะฑะปะธะทัะบะพ, โ ัะต ะฝะฐั ะฟะพะฟะตัะตะดะฝัะน ัะธะฟ.',
+ text: [
+ 'A14 Bionic โ ะฟะตััะธะน ั ะณะฐะปัะทั 5-ะฝะฐะฝะพะผะตััะพะฒะธะน ัะธะฟ ะท ะบะพะผะฟะพะฝะตะฝัะฐะผะธ ะทะฐะฒัะธััะบะธ ั ะฑัะบะฒะฐะปัะฝะพะผั ัะตะฝัั ะบัะปัะบะฐ ะฐัะพะผัะฒ. ะะฐ 40% ะฑัะปััะต ััะฐะฝะทะธััะพััะฒ ะฟัะธัะฒะธะดััััั ัะพะฑะพัั ะน ะฟัะดะฒะธััััั ะตัะตะบัะธะฒะฝัััั ะทะฐะดะปั ััะดะพะฒะพะณะพ ัะฐัั ะฐะฒัะพะฝะพะผะฝะพั ัะพะฑะพัะธ. ะ ะฝะพะฒะธะน ISP ะทะฐะฑะตะทะฟะตััั ะทะฐะฟะธั ั Dolby Vision โ ัะต, ัะพะณะพ ะฝะต ะผะพะถะต ะถะพะดะฝะฐ ะฟัะพัะตััะนะฝะฐ ะบัะฝะพะบะฐะผะตัะฐ, ะฝะต ะบะฐะถััะธ ะฒะถะต ะฟัะพ ัะฝัะธะน ัะตะปะตัะพะฝ.',
+ ],
+ },
+ {
+ title: 'ะััะฝะธะน ัะตะถะธะผ ัะตะฟะตั ั ะฒััั
ะฒะฐัะธั
ะบะฐะผะตัะฐั
.',
+ text: [
+ 'ะกะปะฐะฑะบะต ะพัะฒััะปะตะฝะฝั ัะตะฟะตั โ ัะต ัะธะปัะฝะฐ ััะพัะพะฝะฐ. ะัะด ะฝะฐะฟัะฒัะตะผะฝะธั
ัะตััะพัะฐะฝัะฒ ะดะพ ะฟะปัะถัะฒ ั ะผััััะฝะพะผั ัะฒััะปั โ ะฝะพะฒะธะน ะฝััะฝะธะน ัะตะถะธะผ ะฐะฒัะพะผะฐัะธัะฝะพ ัะพะฑะธัั ะฟัะธัะพะดะฝั ะทะฝัะผะบะธ ะฟัะธ ะฝะธะทัะบะพะผั ะพัะฒััะปะตะฝะฝั.',
+ ],
+ },
+ ],
+
+ 'apple-iphone-13-mini': [
+ {
+ title: 'ะะฐัะฐ ะฝะพะฒะฐ ััะฟะตััะธะปะฐ.',
+ text: [
+ 'A15 Bionic โ ะฝะฐะนัะฒะธะดัะธะน ัะธะฟ, ัะพ ะบะพะปะธ-ะฝะตะฑัะดั ััะพัะฒ ั ัะผะฐัััะพะฝั. ะะธัะฟะปะตะน Super Retina XDR ะท ProMotion. ะััะฝะธะน Ceramic Shield ะท ัะดะฒััั ะบัะฐัะพั ัััะนะบัััั ะดะพ ะฟะฐะดัะฝั. ะะพะฒะธะน ะฝััะฝะธะน ัะตะถะธะผ ะฒ ัััั
ะบะฐะผะตัะฐั
. ะฆะต ะฝะฐะนะบัะฐัะธะน iPhone, ัะบะธะน ะฟัะฐััั ะฝะฐ ะฝะฐะนะบัะฐัะพะผั ัะธะฟั.',
+ ],
+ },
+ {
+ title: 'ะงะธะฟ A15 Bionic. ะกัะฟะตั ัะธะปะฐ. ะัะฝั ัะพะทะผัั.',
+ text: [
+ 'ะงะธะฟ A15 Bionic โ ะฝะฐะนัะฒะธะดัะธะน ัะธะฟ, ัะพ ะบะพะปะธ-ะฝะตะฑัะดั ะฑัะฒ ั ัะผะฐัััะพะฝั. ะัะฝ ะทะฐะฑะตะทะฟะตััั ะฒัะต โ ะฒัะด ัะพัะพะณัะฐััั ะดะพ ัะณะพั. ะ ะฝะตะนะผะพะฒััะฝั ะฟัะธัะบะพััะฒะฐัั ะผะฐัะธะฝะฝะพะณะพ ะฝะฐะฒัะฐะฝะฝั ะดะพะทะฒะพะปััั ะฒัะดัััะธ ะดะพะฟะพะฒะฝะตะฝั ัะตะฐะปัะฝัััั ัะฐะบ, ัะบ ะฝัะบะพะปะธ ัะฐะฝััะต.',
+ ],
+ },
+ {
+ title: 'ะะธะฒะพะฒะธะถะฝะฐ ะบะฐะผะตัะฐ. Pro ะฝะต ะฟะพัััะฑะตะฝ.',
+ text: [
+ 'iPhone 13 Mini ัะพะฑะธัั ะฒัะฐะถะฐััั ัะพัะพ ัะฐ ะฒัะดะตะพ ะทะฐะฒะดัะบะธ ัะฒะพัะน ะฟะตัะตะดะพะฒัะน ะฟะพะดะฒัะนะฝัะน ะบะฐะผะตัั ะท ะฝะพะฒะธะผ ะฝััะฝะธะผ ัะตะถะธะผะพะผ ะดะปั ะฒััั
ะบะฐะผะตั. ะ ะคะพัะพะณัะฐัััะฝั ััะธะปั ะฟะตััะพะฝะฐะปัะทัััั ะฒะฐัั ะทะฝัะผะบะธ ะฟััะผะพ ะฒ ะผะพะผะตะฝั ะทะนะพะผะบะธ, ะฝะฐะดะฐััะธ ัะผ ะฝะตะฟะพะฒัะพัะฝะธะน ะฒะธะณะปัะด.',
+ ],
+ },
+ ],
+
+ 'apple-iphone-13-pro-max': [
+ {
+ title: 'ะะฐะนะบัะฐัะฐ ะฟัะพัะตััะนะฝะฐ ัะธััะตะผะฐ ะบะฐะผะตั',
+ text: [
+ 'ะะฝะฐะนะพะผัะตัั ะท ะฟะตััะพั ะฟัะพัะตััะนะฝะพั ัะธััะตะผะพั ะบะฐะผะตั, ัะบะฐ ัะฐะบะพะถ ั iPhone โ ะท 3-ะบัะฐัะฝะธะผ ะพะฟัะธัะฝะธะผ ะทัะผะพะผ, ะผะฐะบัะพะทะนะพะผะบะพั, ะฝััะฝะธะผ ัะตะถะธะผะพะผ ะฟะพัััะตััะฒ ัะฐ ะฝะพะฒะธะผ ัะพัะผะฐัะพะผ ProRes ะดะปั ะฒัะดะตะพ ะฝะฐะนะฒะธัะพั ัะบะพััั ัะตัะตะด ัะผะฐัััะพะฝัะฒ. ะะพััะนัะตัั ะทะฝัะผะฐัะธ ัะบ ะฟัะพัั.',
+ ],
+ },
+ {
+ title: 'ะัะฝะตะผะฐัะพะณัะฐัััะฝะธะน ัะตะถะธะผ',
+ text: [
+ 'ะะฝัะผะฐะนัะต ัะบ ะฟัะพัะตััะพะฝะฐะป ะท ะตะบัะบะปัะทะธะฒะฝะธะผ ะดะพัััะฟะพะผ ะดะพ ะตัะตะบัั ะณะปะธะฑะธะฝะธ ััะทะบะพััั ะัะฝะพ-ัะตะถะธะผั, ัะพ ัะตะฟะตั ะฟัะฐััั ั ะฝะฐ ัะธัะพะบะพะบััะฝัะน, ั ะฝะฐ ัะตะปะตัะพัะพ ะบะฐะผะตัั. ะะพะดะฐะฒะฐะนัะต ะฟะปะฐะฒะฝั ะฟะตัะตั
ะพะดะธ ัะพะบััั ะดะปั ัะฒะพััะพะณะพ ะตัะตะบัั ัะฐ ะปะตะณะบะพ ะฟะตัะตะผะธะบะฐะนัะตัั ะผัะถ ะบะฐะผะตัะฐะผะธ, ัะพะฑ ะทะปะพะฒะธัะธ ะฑัะปััะต ะผะพะผะตะฝัั.',
+ ],
+ },
+ {
+ title: 'A15 Bionic',
+ text: [
+ 'A15 Bionic โ ะฝะฐะนัะฒะธะดัะธะน ัะธะฟ, ัะพ ะบะพะปะธ-ะฝะตะฑัะดั ััะพัะฒ ั ัะผะฐัััะพะฝั. ะัะฝ ะดะฐััั ะฝะตะนะผะพะฒััะฝั ะฒัะฐะถะตะฝะฝั ั ัะพัะพะณัะฐััั, ะฒัะดะตะพ, ัะณัะฐั
ั ะฝะต ััะปัะบะธ โ ั ะฒัะต ัะต ะท ััะดะพะฒะธะผ ัะฐัะพะผ ะฐะฒัะพะฝะพะผะฝะพั ัะพะฑะพัะธ. ะ ัะต ะฒัะฝ ะฟัะดััะธะผัั ะฝะฐะนัััะฐัะฝััั ะผะพะถะปะธะฒะพััั ะผะฐัะธะฝะฝะพะณะพ ะฝะฐะฒัะฐะฝะฝั ัะตัะตะด ัััั
ัะผะฐัััะพะฝัะฒ ะดะปั ะทะพะฒััะผ ะฝะพะฒะพะณะพ ััะฒะฝั ะฒัะฐะถะตะฝั.',
+ ],
+ },
+ {
+ title: 'ProMotion',
+ text: [
+ 'ะขะตั
ะฝะพะปะพะณัั ProMotion ะฐะฒัะพะผะฐัะธัะฝะพ ะฟัะดะปะฐััะพะฒัั ะดะธัะฟะปะตะน ะฟัะด ััั
ะธ ะฝะฐ ะตะบัะฐะฝั ะดะปั ะฟะปะฐะฒะฝััะพะณะพ ัะบัะพะปั, ะบัะฐัะพั ัััะปะธะฒะพััั ัะฐ ะณะปะฐะดัะพะณะพ ััั
ั โ ั ะฑะฐัะฐัะตั, ัะบะพั ะฒะธััะฐัะธัั ะฝะฐ ััะปะธะน ะดะตะฝั.',
+ ],
+ },
+ ],
+
+ 'apple-ipad-pro-11-2021': [
+ {
+ title: 'ะะพััะถะฝะฐ ะฟัะพะดัะบัะธะฒะฝัััั',
+ text: [
+ 'ะัะดััะนัะต ะฝะตะนะผะพะฒััะฝั ะฟะพััะถะฝัััั ั ัะฒะธะดะบะพะดัั Apple iPad Pro 11. ะะฐะฒะดัะบะธ ัะธะฟั M1 ะฒัะฝ ะฒะธะฒะพะดะธัั ะฟัะพะดัะบัะธะฒะฝัััั ะฝะฐ ะฝะพะฒะธะน ััะฒะตะฝั โ ัะฒะธะดัะธะน ั ะตัะตะบัะธะฒะฝััะธะน, ะฝัะถ ะฑัะดั-ะบะพะปะธ.',
+ 'ะงะธ ัะตะดะฐะณัััะต ะฒะธ ัะพัะพ, ะผะฐะปัััะต ะณัะฐััะบั, ัะธ ะฟัะฐััััะต ะฒ ะบัะปัะบะพั
ัะตััััะพัะผะฝะธั
ะทะฐััะพััะฝะบะฐั
โ iPad Pro 11 ะท ะปะตะณะบัััั ะฒะฟะพัะฐััััั ะท ัััะผ.',
+ ],
+ },
+ {
+ title: 'ะัะธะณะพะปะพะผัะปะธะฒะธะน ะดะธัะฟะปะตะน Liquid Retina',
+ text: [
+ 'ะะฐัะพะปะพะดะถัะนัะตัั ััะบัะฐะฒะธะผ ั ะทะฐั
ะพะฟะปะธะฒะธะผ ะฒัะทัะฐะปัะฝะธะผ ะดะพัะฒัะดะพะผ ะฝะฐ ะดะธัะฟะปะตั Liquid Retina iPad Pro 11. ะะฐะฒะดัะบะธ ัะตั
ะฝะพะปะพะณัั ProMotion ั True Tone ะดะธัะฟะปะตะน ะฐะดะฐะฟััััััั ะดะพ ะพัะพัะตะฝะฝั, ะทะฐะฑะตะทะฟะตััััะธ ะฟะปะฐะฒะฝะธะน ัะบัะพะป, ัะพัะฝั ะฟะตัะตะดะฐัั ะบะพะปัะพัั ะน ะฝะตะนะผะพะฒััะฝั ะดะตัะฐะปัะทะฐััั.',
+ 'ะัะด ะฟะตัะตะณะปัะดั ััะปัะผัะฒ ะดะพ ะผะพะฝัะฐะถั ะฒัะดะตะพ โ ะดะธัะฟะปะตะน iPad Pro 11 ะพะถะธะฒะปัั ะบะพะฝัะตะฝั ัะท ะฟัะธะณะพะปะพะผัะปะธะฒะพั ัััะบัััั.',
+ ],
+ },
+ {
+ title: 'ะฃะฝัะฒะตััะฐะปัะฝะฐ ัะธััะตะผะฐ ะบะฐะผะตั',
+ text: [
+ 'ะะฝัะผะฐะนัะต ััะดะพะฒั ัะพัะพ ัะฐ ะฒัะดะตะพ ะทะฐะฒะดัะบะธ ะฟะตัะตะดะพะฒัะน ัะธััะตะผั ะบะฐะผะตั iPad Pro 11. ะ 12 ะะฟ ะฝะฐะดัะธัะพะบะพะบััะฝะพั ััะพะฝัะฐะปัะฝะพั ะบะฐะผะตัะพั ัะฐ 12 ะะฟ ัะธัะพะบะพะบััะฝะพั ะทะฐะดะฝัะพั ะบะฐะผะตัะพั ะทั ัะบะฐะฝะตัะพะผ LiDAR ะฒะธ ะพััะธะผะฐััะต ัะบััะฝั ะทะฝัะผะบะธ ัะฐ ะทะผะพะถะตัะต ะทะฐะฝัััะฒะฐัะธัั ั ะดะพะฟะพะฒะฝะตะฝั ัะตะฐะปัะฝัััั.',
+ 'ะงะธ ัะต ะฒัะดะตะพะดะทะฒัะฝะบะธ, ัะบะฐะฝัะฒะฐะฝะฝั ะดะพะบัะผะตะฝััะฒ ะฐะฑะพ ะทะนะพะผะบะฐ 4K-ะฒัะดะตะพ โ ัะธััะตะผะฐ ะบะฐะผะตั iPad Pro 11 ะฟะพะบะฐะทัั ะฒะธะฝััะบะพะฒะธะน ัะตะทัะปััะฐั.',
+ ],
+ },
+ ],
+
+ 'apple-ipad-air-4th-gen': [
+ {
+ title: 'ะขะพะฝะบะธะน, ะปะตะณะบะธะน ั ะฟะพััะถะฝะธะน',
+ text: [
+ 'Apple iPad Air (4-ะณะพ ะฟะพะบะพะปัะฝะฝั) ะฟะพัะดะฝัั ะตะปะตะณะฐะฝัะฝะธะน ะดะธะทะฐะนะฝ ั ะฟะพััะถะฝั ะฟัะพะดัะบัะธะฒะฝัััั. ะขะพะฝะบะธะน ั ะปะตะณะบะธะน, ะฒัะฝ ะฝะตะนะผะพะฒััะฝะพ ะทัััะฝะธะน ะดะปั ะฟะตัะตะฝะตัะตะฝะฝั.',
+ 'ะะฐะฒะดัะบะธ ัะธะฟั A14 Bionic ะท Neural Engine iPad Air ะทะฐะฑะตะทะฟะตััั ัะฒะธะดะบั ัะฐ ะตัะตะบัะธะฒะฝั ัะพะฑะพัั, ัะพ ัะดะตะฐะปัะฝะพ ะดะปั ะฑะฐะณะฐัะพะทะฐะดะฐัะฝะพััั, ัะณะพั ั ัะฒะพััะธั
ะทะฐะดะฐั.',
+ ],
+ },
+ {
+ title: 'ะัะธะณะพะปะพะผัะปะธะฒะธะน ะดะธัะฟะปะตะน Liquid Retina',
+ text: [
+ 'ะะฐะฝัััะตัั ั ััะบัะฐะฒั ะฒัะทัะฐะปัะฝั ะฒัะฐะถะตะฝะฝั ะฝะฐ ะดะธัะฟะปะตั Liquid Retina iPad Air. ะะฐะฒะดัะบะธ True Tone ั ัะธัะพะบะพะผั ะบะพะปััะฝะพะผั ะดัะฐะฟะฐะทะพะฝั P3 ะฒัะฝ ะฟะตัะตะดะฐั ัะตะฐะปัััะธัะฝั ะบะพะปัะพัะธ ัะฐ ะฝะตะนะผะพะฒััะฝั ะดะตัะฐะปัะทะฐััั.',
+ 'ะงะธ ัะต ะฟะตัะตะณะปัะด ััะปัะผัะฒ, ัะตะดะฐะณัะฒะฐะฝะฝั ัะพัะพ ัะธ ัะตัััะฝะณ โ ะดะธัะฟะปะตะน iPad Air ะทะฐะฑะตะทะฟะตััั ะทะฐั
ะพะฟะปะธะฒะธะน ั ะฟัะธัะผะฝะธะน ะดะพัะฒัะด.',
+ ],
+ },
+ {
+ title: 'ะฃะฝัะฒะตััะฐะปัะฝั ะผะพะถะปะธะฒะพััั ัะฐ ะฟัะดััะธะผะบะฐ Apple Pencil',
+ text: [
+ 'ะ ะพะทะบัะธะนัะต ะฝะพะฒั ะผะพะถะปะธะฒะพััั ะท ัะฝัะฒะตััะฐะปัะฝะธะผะธ ััะฝะบัััะผะธ iPad Air. ะัะฝ ะฟัะดััะธะผัั Apple Pencil (2-ะณะพ ะฟะพะบะพะปัะฝะฝั), ัะพะฑ ะฒะธ ะผะพะณะปะธ ัะพะฑะธัะธ ะฝะพัะฐัะบะธ, ะผะฐะปัะฒะฐัะธ ะน ะบะพะผะตะฝััะฒะฐัะธ ะดะพะบัะผะตะฝัะธ ะท ัะพัะฝัััั.',
+ 'ะะฐะฒะดัะบะธ Touch ID, ะฒะฑัะดะพะฒะฐะฝะพะผั ั ะฒะตัั
ะฝั ะบะฝะพะฟะบั, ะฒะธ ะผะพะถะตัะต ะฑะตะทะฟะตัะฝะพ ัะพะทะฑะปะพะบะพะฒัะฒะฐัะธ iPad, ัะพะฑะธัะธ ะฟะพะบัะฟะบะธ ั ะฐะฒัะตะฝัะธััะบัะฒะฐัะธัั ั ะทะฐััะพััะฝะบะฐั
ะพะดะฝะธะผ ะดะพัะธะบะพะผ.',
+ ],
+ },
+ ],
+
+ 'apple-ipad-mini-6th-gen': [
+ {
+ title: 'ะะพะผะฟะฐะบัะฝะธะน ั ะฟะพััะฐัะธะฒะฝะธะน',
+ text: [
+ 'ะัะดััะนัะต ัะธะปั iPad ั ะบะพะผะฟะฐะบัะฝะพะผั ัะพัะผะฐัั ะท Apple iPad Mini (6-ะณะพ ะฟะพะบะพะปัะฝะฝั). ะะพะณะพ 8,3-ะดัะนะผะพะฒะธะน ะดะธัะฟะปะตะน Liquid Retina ั ัะพะฝะบะธะน ะดะธะทะฐะนะฝ ัะดะตะฐะปัะฝะพ ะฟะฐััััั ะดะปั ะฟัะพะดัะบัะธะฒะฝะพััั ัะฐ ัะพะทะฒะฐะณ ั ะดะพัะพะทั.',
+ 'ะงะธ ัะต ัะธัะฐะฝะฝั, ัะณัะธ ัะธ ะฒัะดะตะพ โ iPad Mini ะทะฐะฑะตะทะฟะตััั ะฟัะธะณะพะปะพะผัะปะธะฒะธะน ะฒัะทัะฐะปัะฝะธะน ะดะพัะฒัะด ั ะฟะพััะฐัะธะฒะฝะพะผั ะบะพัะฟััั.',
+ ],
+ },
+ {
+ title: 'ะงะธะฟ A15 Bionic ัะฐ Neural Engine',
+ text: [
+ 'iPad Mini (6-ะณะพ ะฟะพะบะพะปัะฝะฝั) ะพัะฝะฐัะตะฝะพ ะฟะพััะถะฝะธะผ ัะธะฟะพะผ A15 Bionic ัะฐ Neural Engine, ัะพ ะทะฐะฑะตะทะฟะตััััั ัะฒะธะดะบั ะน ะตัะตะบัะธะฒะฝั ัะพะฑะพัั. ะัะฝ ะท ะปะตะณะบัััั ะฒะฟะพัะฐััััั ะท ะฒะธะผะพะณะปะธะฒะธะผะธ ะทะฐะฒะดะฐะฝะฝัะผะธ ัะฐ ะณัะฐัััะฝะพ ะฝะฐัะธัะตะฝะธะผะธ ะทะฐััะพััะฝะบะฐะผะธ โ ัะบ ะดะปั ัะพะฑะพัะธ, ัะฐะบ ั ะดะปั ัะพะทะฒะฐะณ.',
+ 'ะะฐัะพะปะพะดะถัะนัะตัั ะฟะปะฐะฒะฝะพั ะฑะฐะณะฐัะพะทะฐะดะฐัะฝัััั, ะทะฐั
ะพะฟะปัััะธะผะธ ัะณัะฐะผะธ ัะฐ ะทัััะฝะธะผ ะฒะธะบะพัะธััะฐะฝะฝัะผ ะทะฐััะพััะฝะบัะฒ ะฝะฐ iPad Mini.',
+ ],
+ },
+ {
+ title: 'ะฃะดะพัะบะพะฝะฐะปะตะฝั ะบะฐะผะตัะธ ัะฐ Center Stage',
+ text: [
+ 'ะกัะฒะพััะนัะต ะฒัะฐะถะฐััั ัะพัะพ ะน ะฒัะดะตะพ ะทะฐะฒะดัะบะธ ะฒะดะพัะบะพะฝะฐะปะตะฝะธะผ ะบะฐะผะตัะฐะผ iPad Mini. ะ 12 ะะฟ ััะพะฝัะฐะปัะฝะพั ัะฐ 12 ะะฟ ะทะฐะดะฝัะพั ะบะฐะผะตัะพั ะฒะธ ัะพะฑะธัะธะผะตัะต ัะบััะฝั ะทะฝัะผะบะธ ัะฐ ะทะฐะฟะธััะฒะฐัะธะผะตัะต ััะดะพะฒั ะฒัะดะตะพ.',
+ 'iPad Mini ัะฐะบะพะถ ะผะฐั Center Stage โ ััะฝะบััั, ัะพ ะฐะฒัะพะผะฐัะธัะฝะพ ััะธะผะฐั ะฒะฐั ั ะบะฐะดัั ะฟัะด ัะฐั ะฒัะดะตะพะดะทะฒัะฝะบัะฒ, ัะพะฑะปััะธ ัะพะทะผะพะฒะธ ะถะฒะฐะฒััะธะผะธ.',
+ ],
+ },
+ ],
+
+ 'apple-ipad-10-2-2020': [
+ {
+ title: 'ะฃะฝัะฒะตััะฐะปัะฝะธะน ั ะดะพัััะฟะฝะธะน',
+ text: [
+ 'Apple iPad 10.2 (2020) โ ัะฝัะฒะตััะฐะปัะฝะธะน ั ะดะพัััะฟะฝะธะน ะฒะฐััะฐะฝั ะดะปั ัะพะดะตะฝะฝะธั
ะทะฐะดะฐั ั ัะพะทะฒะฐะณ. ะงะธ ัะต ัะตัััะฝะณ, ััััะผัะฝะณ ะบะพะฝัะตะฝัั ะฐะฑะพ ัะพะฑะพัะฐ ะฒ ะฟัะพะดัะบัะธะฒะฝะธั
ะทะฐััะพััะฝะบะฐั
โ ะฒัะฝ ะทะฐะฑะตะทะฟะตััั ะฟะปะฐะฒะฝั ัะฐ ัััะปะธะฒั ัะพะฑะพัั.',
+ 'ะะฐะฒะดัะบะธ ะฒะตะปะธะบะพะผั 10,2-ะดัะนะผะพะฒะพะผั ะดะธัะฟะปะตั Retina ะฒะธ ะพััะธะผะฐััะต ััะดะพะฒะธะน ะฟะตัะตะณะปัะด ะฑัะดั-ัะบะพะณะพ ะบะพะฝัะตะฝัั.',
+ ],
+ },
+ {
+ title: 'ะะพััะถะฝะฐ ะฟัะพะดัะบัะธะฒะฝัััั',
+ text: [
+ 'ะัะดััะนัะต ะฟะพััะถะฝัััั iPad 10.2 ะท ัะธะฟะพะผ A12 Bionic. ะัะฝ ะทะฐะฑะตะทะฟะตััั ัะฒะธะดะบั ะน ะตัะตะบัะธะฒะฝั ะฑะฐะณะฐัะพะทะฐะดะฐัะฝัััั, ะดะพะทะฒะพะปัััะธ ะฟะปะฐะฒะฝะพ ะน ะฑะตะทะฟะตัะตะฑัะนะฝะพ ะฟัะฐััะฒะฐัะธ ะท ะบัะปัะบะพะผะฐ ะทะฐััะพััะฝะบะฐะผะธ.',
+ 'ะัะด ัะณะพั ะดะพ ัะตะดะฐะณัะฒะฐะฝะฝั ะดะพะบัะผะตะฝััะฒ โ iPad 10.2 ะท ัััะผ ัะฟัะฐะฒะปัััััั ะท ะปะตะณะบัััั.',
+ ],
+ },
+ {
+ title: 'ะัะดััะธะผะบะฐ Apple Pencil ั Smart Keyboard',
+ text: [
+ 'ะ ะพะทะบัะธะนัะต ัะฒะพั ะบัะตะฐัะธะฒะฝัััั ั ะฟัะพะดัะบัะธะฒะฝัััั ัะท ะฟัะดััะธะผะบะพั Apple Pencil ัะฐ Smart Keyboard. ะ ะพะฑััั ะฝะพัะฐัะบะธ, ะผะฐะปัะนัะต ัะฐ ะฝะฐะบะธะดะฐะนัะต ะตัะบัะทะธ ะท ัะพัะฝัััั ะทะฐ ะดะพะฟะพะผะพะณะพั Apple Pencil (1-ะณะพ ะฟะพะบะพะปัะฝะฝั) ั ะฟะตัะตัะฒะพัััั ัะฒัะน iPad ะฝะฐ ะปะตะณะบะธะน ะฝะพััะฑัะบ ะทะฐะฒะดัะบะธ Smart Keyboard.',
+ 'iPad 10.2 ะฐะดะฐะฟััััััั ะฟัะด ะฒะฐัั ะฟะพััะตะฑะธ, ัะธ ะฒะธ ัััะดะตะฝั, ะฟัะพัะตััะพะฝะฐะป ัะธ ัะฒะพััะฐ ะพัะพะฑะธัััััั.',
+ ],
+ },
+ ],
+
+ 'apple-ipad-mini-5th-gen': [
+ {
+ title: 'ะะพะผะฟะฐะบัะฝะธะน ั ะฟะพััะถะฝะธะน',
+ text: [
+ 'Apple iPad Mini (5-ะณะพ ะฟะพะบะพะปัะฝะฝั) ะดะธะฒัั ะบะพะผะฟะฐะบัะฝะธะผ ัะพะทะผััะพะผ ั ะฟะพััะถะฝะพั ะฟัะพะดัะบัะธะฒะฝัััั. ะงะธ ะฒะธ ะฒ ะดะพัะพะทั, ัะธ ะฒะดะพะผะฐ โ ะฒัะฝ ัะดะตะฐะปัะฝะพ ะฟะฐััั ะดะปั ัะพะฑะพัะธ, ัะพะทะฒะฐะณ ัะฐ ัะฒะพััะพััั.',
+ 'ะะฐะฒะดัะบะธ ัะธะฟั A12 Bionic iPad Mini ะทะฐะฑะตะทะฟะตััั ัะฒะธะดะบั ะน ะตัะตะบัะธะฒะฝั ัะพะฑะพัั, ะฟะปะฐะฒะฝะพ ะทะฐะฟััะบะฐััะธ ะฒะธะผะพะณะปะธะฒั ะทะฐััะพััะฝะบะธ ัะฐ ัะณัะธ.',
+ ],
+ },
+ {
+ title: 'ะะธัะฟะปะตะน Retina ั True Tone',
+ text: [
+ 'ะะฐัะพะปะพะดะถัะนัะตัั ะฟัะธะณะพะปะพะผัะปะธะฒะธะผะธ ะฒัะทัะฐะปัะฝะธะผะธ ะฒัะฐะถะตะฝะฝัะผะธ ะฝะฐ ะดะธัะฟะปะตั Retina iPad Mini. ะขะตั
ะฝะพะปะพะณัั True Tone ะฟัะดะปะฐััะพะฒัั ะบะพะปััะฝั ัะตะผะฟะตัะฐัััั ะดะธัะฟะปะตั ะฟัะด ะพัะฒััะปะตะฝะฝั, ะทะฐะฑะตะทะฟะตััััะธ ะฟัะธัะพะดะฝะต ัะฐ ะบะพะผัะพััะฝะต ะฟะตัะตะณะปัะดะฐะฝะฝะต.',
+ 'ะัะด ัะธัะฐะฝะฝั ะบะฝะธะณ ะดะพ ะฟะตัะตะณะปัะดั ัะพัะพ โ ะดะธัะฟะปะตะน iPad Mini ะพะถะธะฒะปัั ะบะพะฝัะตะฝั ััะบัะฐะฒะธะผะธ ะบะพะปัะพัะฐะผะธ ัะฐ ัััะบะธะผะธ ะดะตัะฐะปัะผะธ.',
+ ],
+ },
+ {
+ title: 'ะัะดััะธะผะบะฐ Apple Pencil ั Smart Keyboard',
+ text: [
+ 'ะ ะพะทะบัะธะนัะต ัะฒะพั ะบัะตะฐัะธะฒะฝัััั ั ะฟัะพะดัะบัะธะฒะฝัััั ัะท ะฟัะดััะธะผะบะพั Apple Pencil ัะฐ Smart Keyboard. ะ ะพะฑััั ะฝะพัะฐัะบะธ, ะผะฐะปัะนัะต ะน ะบะพะผะตะฝััะนัะต ะดะพะบัะผะตะฝัะธ ัะพัะฝะพ ะทะฐ ะดะพะฟะพะผะพะณะพั Apple Pencil (1-ะณะพ ะฟะพะบะพะปัะฝะฝั) ั ะฟะตัะตัะฒะพัััั iPad Mini ะฝะฐ ัะฝัะฒะตััะฐะปัะฝั ัะพะฑะพัั ััะฐะฝััั ะท Smart Keyboard.',
+ 'iPad Mini ะฐะดะฐะฟััััััั ะดะพ ะฒะฐัะธั
ะฟะพััะตะฑ, ัะพะฑะปััะธ ะนะพะณะพ ะฟะพััะถะฝะธะผ ัะฝััััะผะตะฝัะพะผ ั ะดะปั ัะพะฑะพัะธ, ั ะดะปั ัะพะทะฒะฐะณ.',
+ ],
+ },
+ ],
+
+ 'apple-watch-series-3': [
+ {
+ title: 'ะกะปัะดะบัะนัะต ะทะฐ ัะฒะพัะผ ะทะดะพัะพะฒสผัะผ',
+ text: [
+ 'Apple Watch Series 3 ะพะฑะปะฐะดะฝะฐะฝั ัะตะฝัะพัะฐะผะธ, ัะพ ะฒัะดััะตะถัััั ัะฐััะพัั ัะตััะตะฒะธั
ัะบะพัะพัะตะฝั, ัะฟะฐะปะตะฝั ะบะฐะปะพััั ัะฐ ัะฝัั ัััะฝะตั-ะฟะพะบะฐะทะฝะธะบะธ ะฟัะพััะณะพะผ ะดะฝั. ะะพะดะธะฝะฝะธะบ ะผะพะถะต ััะตะถะธัะธ ะทะฐ ะฒะฐัะธะผะธ ััะตะฝัะฒะฐะฝะฝัะผะธ ัะฐ ะฟัะพะฟะพะฝัะฒะฐัะธ ะฟะตััะพะฝะฐะปัะฝั ัััะฝะตั-ััะปั.',
+ ],
+ },
+ {
+ title: 'ะะฐะปะธัะฐะนัะตัั ะฝะฐ ะทะฒสผัะทะบั ะฒ ะดะพัะพะทั',
+ text: [
+ 'ะะฐะฒะดัะบะธ ัััะปัะฝะธะบะพะฒะพะผั ะทะฒสผัะทะบั ะฒะธ ะผะพะถะตัะต ัะตะปะตัะพะฝัะฒะฐัะธ ัะฐ ะฝะฐะดัะธะปะฐัะธ ะฟะพะฒัะดะพะผะปะตะฝะฝั ะฟััะผะพ ัะท ะทะฐะฟโัััะบะฐ, ะฝะฐะฒััั ัะบัะพ iPhone ะฝะต ะฟะพััะด. ะ ัะท Siri ะฒะธ ะพััะธะผะฐััะต ะผะฐัััััะธ, ะฝะฐะดััะปะตัะต ะฟะพะฒัะดะพะผะปะตะฝะฝั ัะฐ ะฒััะฐะฝะพะฒะธัะต ะฝะฐะณะฐะดัะฒะฐะฝะฝั, ะฝะต ัะพัะบะฐััะธัั ะฟัะธัััะพั.',
+ ],
+ },
+ {
+ title: 'ะกัััะผัะต ัะฒะพั ัะปัะฑะปะตะฝั ะผัะทะธะบั',
+ text: [
+ 'ะ Apple Music ัะฐ Siri ะฒะธ ะผะพะถะตัะต ัะปัั
ะฐัะธ ะฟะพะฝะฐะด 75 ะผัะปัะนะพะฝัะฒ ะฟััะตะฝั ะฝะฐ Apple Watch Series 3. ะะฑะพ ะฒะผะธะบะฐะนัะต ัะปัะฑะปะตะฝั ะฟะพะดะบะฐััะธ, ะฐัะดัะพะบะฝะธะณะธ ัะฐ ัะฐะดัะพ.',
+ ],
+ },
+ ],
+
+ 'apple-watch-series-6': [
+ {
+ title: 'ะะธัะฟะปะตะน Retina ะท ััะฝะบัััั ยซะทะฐะฒะถะดะธ ะฒะฒัะผะบะฝะตะฝะพยป',
+ text: [
+ 'Apple Watch Series 6 ะผะฐััั ะดะธัะฟะปะตะน Retina ะท ััะฝะบัััั ยซะทะฐะฒะถะดะธ ะฒะฒัะผะบะฝะตะฝะพยป, ัะบะธะน ั 2,5 ัะฐะทะฐ ััะบัะฐะฒััะธะน ะฝะฐ ัะพะฝัั, ะบะพะปะธ ะฒะฐัะฐ ััะบะฐ ะพะฟััะตะฝะฐ. ะะธ ะฑะฐัะธัะธะผะตัะต ะฒัั ัะฝัะพัะผะฐััั ะฝะฐ ัะธัะตัะฑะปะฐัั ะท ะพะดะฝะพะณะพ ะฟะพะณะปัะดั.',
+ ],
+ },
+ {
+ title: 'ะกะตะฝัะพั ััะฒะฝั ะบะธัะฝั ะฒ ะบัะพะฒั',
+ text: [
+ 'ะะฐััะพััะฝะพะบ Blood Oxygen ะฒะธะผัััั ััะฒะตะฝั ะบะธัะฝั ั ะบัะพะฒั ะทะฐ ะดะพะฟะพะผะพะณะพั ะฝะพะฒะพะณะพ ัะตะฒะพะปัััะนะฝะพะณะพ ัะตะฝัะพัะฐ. ะะธ ัะฐะบะพะถ ะผะพะถะตัะต ะทะฝััะธ ะะะ ัะท ะทะฐะฟโัััะบะฐ ะฒ ะฑัะดั-ัะบะธะน ัะฐั ั ะฒ ะฑัะดั-ัะบะพะผั ะผัััั.',
+ ],
+ },
+ {
+ title: 'ะัะดััะตะถะตะฝะฝั ัะพะดะตะฝะฝะพั ะฐะบัะธะฒะฝะพััั',
+ text: [
+ 'Apple Watch Series 6 ะฒัะดััะตะถัััั ะฒะฐัั ัะพะดะตะฝะฝั ะฐะบัะธะฒะฝัััั โ ััะตะฝัะฒะฐะฝะฝั, ะบัะพะบะธ ัะฐ ัะฟะฐะปะตะฝั ะบะฐะปะพััั. ะะฑัะดะพะฒะฐะฝะธะน GPS ััะบััั ะฒะฐัั ะฟัะพะฑัะถะบะธ, ะฟัะพะณัะปัะฝะบะธ ัะฐ ะฟะพั
ะพะดะธ.',
+ ],
+ },
+ ],
+
+ 'apple-watch-series-5': [
+ {
+ title: 'ะะธัะฟะปะตะน Retina ะท ััะฝะบัััั ยซะทะฐะฒะถะดะธ ะฒะฒัะผะบะฝะตะฝะพยป',
+ text: [
+ 'ะะฐะฒะดัะบะธ ะดะธัะฟะปะตั Retina ะท ััะฝะบัััั ยซะทะฐะฒะถะดะธ ะฒะฒัะผะบะฝะตะฝะพยป ะฒะธ ะปะตะณะบะพ ะฟะพะฑะฐัะธัะต ัะฐั ั ะฒะฐะถะปะธะฒั ัะฝัะพัะผะฐััั, ะฝะต ะฟัะดะฝัะผะฐััะธ ััะบะธ. ะะธัะฟะปะตะน ัะฐะบะพะถ ะฝะฐ 30% ะฑัะปััะธะน, ะฝัะถ ั Series 3 โ ัะต ัะพะฑะธัั ะนะพะณะพ ะทัััะฝััะธะผ ะดะปั ะฟะตัะตะณะปัะดั ะน ะฒะทะฐัะผะพะดัั.',
+ ],
+ },
+ {
+ title: 'ะะฐััะพััะฝะพะบ ะะะ',
+ text: [
+ 'ะะฐััะพััะฝะพะบ ะะะ ะผะพะถะต ะฒะธัะฒะธัะธ ะฝะตัะตะณัะปััะฝะธะน ัะธัะผ ัะตััั ัะฐ ัะฟะพะฒัััะธัะธ ะฒะฐั, ัะบัะพ ัะพัั ะฝะต ัะฐะบ. ะะธ ัะฐะบะพะถ ะผะพะถะตัะต ะทะฝััะธ ะะะ ั ะฑัะดั-ัะบะธะน ัะฐั ั ะฒ ะฑัะดั-ัะบะพะผั ะผัััั ะฟััะผะพ ะท ะทะฐะฟโัััะบะฐ.',
+ ],
+ },
+ {
+ title: 'ะัะดััะตะถะตะฝะฝั ะฐะบัะธะฒะฝะพััั',
+ text: [
+ 'Apple Watch Series 5 ะฒัะดััะตะถัััั ะฒะฐัั ัะพะดะตะฝะฝั ะฐะบัะธะฒะฝัััั โ ััะตะฝัะฒะฐะฝะฝั, ะบัะพะบะธ ัะฐ ัะฟะฐะปะตะฝั ะบะฐะปะพััั. ะะฑัะดะพะฒะฐะฝะธะน GPS ััะบััั ะฒะฐัั ะฟัะพะฑัะถะบะธ, ะฟัะพะณัะปัะฝะบะธ ัะฐ ะฟะพั
ะพะดะธ.',
+ ],
+ },
+ ],
+
+ 'apple-watch-series-4': [
+ {
+ title: 'ะัะปััะธะน ะดะธัะฟะปะตะน',
+ text: [
+ 'Apple Watch Series 4 ะผะฐั ะฑัะปััะธะน ะดะธัะฟะปะตะน, ัะบะธะน ะดะพะทะฒะพะปัั ะฟะพะฑะฐัะธัะธ ะฑัะปััะต ัะฝัะพัะผะฐััั ะพะดะฝะธะผ ะฟะพะณะปัะดะพะผ. ะะธัะฟะปะตะน ะฝะฐ ะฟะพะฝะฐะด 30% ะฑัะปััะธะน, ะฝัะถ ั Series 3, ะฒัะฝ ะทะฐะนะผะฐั ะฒัั ะฟะพะฒะตัั
ะฝั ะตะบัะฐะฝะฐ ั ะผะฐั ะทะฐะบััะณะปะตะฝั ะบััะธ.',
+ ],
+ },
+ {
+ title: 'ะะฐััะพััะฝะพะบ ะะะ',
+ text: [
+ 'ะ ะทะฐััะพััะฝะบะพะผ ะะะ ะฒะธ ะผะพะถะตัะต ะทะฝััะธ ะตะปะตะบััะพะบะฐัะดัะพะณัะฐะผั ะฟัะพััะพ ะท ะทะฐะฟสผัััะบะฐ. ะฆั ััะฝะบััั ะดะพัััะฟะฝะฐ ะปะธัะต ะฒ ะฟะตะฒะฝะธั
ะบัะฐัะฝะฐั
ั ัะตะณัะพะฝะฐั
.',
+ ],
+ },
+ {
+ title: 'ะะธัะฒะปะตะฝะฝั ะฟะฐะดัะฝั',
+ text: [
+ 'Apple Watch Series 4 ะผะพะถะต ะฒะธัะฒะธัะธ, ะบะพะปะธ ะฒะธ ะฟะฐะดะฐััะต, ั ะฝะฐะดััะปะฐัะธ ัะฟะพะฒััะตะฝะฝั ะตะบัััะตะฝะธะผ ัะปัะถะฑะฐะผ, ัะบัะพ ะฒะธ ะฝะต ะฒัะดะฟะพะฒัััะต ะฟัะพััะณะพะผ ั
ะฒะธะปะธะฝะธ. ะฆั ััะฝะบััั ะดะพัััะฟะฝะฐ ะดะปั ะบะพัะธัััะฒะฐััะฒ ััะฐััะธั
ะทะฐ 65 ัะพะบัะฒ.',
+ ],
+ },
+ ],
+
+ 'apple-watch-se': [
+ {
+ title: 'ะะธัะฟะปะตะน Retina',
+ text: [
+ 'Apple Watch SE ะผะฐััั ะดะธัะฟะปะตะน Retina, ัะพ ะทะฐะฑะตะทะฟะตััั ัััะบั ัะฐ ััะบัะฐะฒั ะฒัะทัะฐะปัะฝั ะฒัะฐะถะตะฝะฝั ะดะปั ะทัััะฝะพะณะพ ะฟะตัะตะณะปัะดั.',
+ ],
+ },
+ {
+ title: 'Family Setup',
+ text: [
+ 'ะ Family Setup ะฒะธ ะผะพะถะตัะต ัะฟะฐัะธัะธ Apple Watch SE ะท iPhone ัะปะตะฝะฐ ััะผโั, ัะพะฑ ะทะฐะปะธัะฐัะธัั ะฝะฐ ะทะฒสผัะทะบั, ะฝะฐะฒััั ะฝะต ะผะฐััะธ ะฒะปะฐัะฝะพะณะพ iPhone.',
+ ],
+ },
+ {
+ title: 'ะัะดััะตะถะตะฝะฝั ัััะฝะตัั',
+ text: [
+ 'Apple Watch SE ะฟัะพะฟะพะฝัััั ะฟะพะฒะฝะพััะฝะฝะต ะฒัะดััะตะถะตะฝะฝั ัััะฝะตัั: ัะพะดะตะฝะฝะฐ ะฐะบัะธะฒะฝัััั, ะบะพะฝััะพะปั ัะตััะตะฒะพะณะพ ัะธัะผั ัะฐ ะฒะฑัะดะพะฒะฐะฝะธะน GPS ะดะปั ะทะนะพะผะบะธ ะฒะฐัะธั
ััะตะฝัะฒะฐะฝั ะฝะฐ ัะฒัะถะพะผั ะฟะพะฒัััั.',
+ ],
+ },
+ ],
+};
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..49b1495b61e
--- /dev/null
+++ b/src/i18n/locales/en.json
@@ -0,0 +1,165 @@
+{
+ "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"
+ },
+ "notFound": "Product not found"
+ },
+ "homePage": {
+ "title": "Product Catalog"
+ },
+ "loading": "Loading...",
+ "alt": {
+ "banner": "Phone advertisement"
+ },
+ "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..cce644d1ab4
--- /dev/null
+++ b/src/i18n/locales/ua.json
@@ -0,0 +1,169 @@
+{
+ "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": "ะัะพ ัะตะน ัะพะฒะฐั"
+ },
+ "notFound": "ะขะพะฒะฐั ะฝะต ะทะฝะฐะนะดะตะฝะพ"
+ },
+ "homePage": {
+ "title": "ะะฐัะฐะปะพะณ ัะพะฒะฐััะฒ"
+ },
+ "loading": "ะะฐะฒะฐะฝัะฐะถะตะฝะฝั...",
+ "alt": {
+ "banner": "ะ ะตะบะปะฐะผะฐ ัะตะปะตัะพะฝั"
+ },
+ "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..a8aa1881dfa
--- /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 (
+
+
{t('homePage.title')}
+
+
+ {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..6cb25241ac2
--- /dev/null
+++ b/src/modules/HomePage/components/organisms/MainSlider/MainSlider.tsx
@@ -0,0 +1,85 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+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 = () => {
+ const { t } = useTranslation();
+ const bannerAlt = t('alt.banner');
+
+ 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..13c1fb84a9d
--- /dev/null
+++ b/src/modules/ProductPage/ProductPage.tsx
@@ -0,0 +1,88 @@
+/* eslint-disable max-len */
+import React, { useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+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 { t } = useTranslation();
+
+ 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({ top: 0, behavior: 'smooth' });
+ }, [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..f2a54e772cd
--- /dev/null
+++ b/src/modules/ProductPage/components/organisms/ProductDescription/ProductDescription.tsx
@@ -0,0 +1,61 @@
+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';
+import { descriptionsUa } from '../../../../../i18n/descriptions.ua';
+
+type Props = {
+ productDetails: ProductDetails;
+};
+
+const ProductDescriptionComponent: React.FC = ({ productDetails }) => {
+ const { t, i18n } = useTranslation();
+
+ const localizedDescription =
+ i18n.language === 'ua' && descriptionsUa[productDetails?.namespaceId]
+ ? descriptionsUa[productDetails.namespaceId]
+ : productDetails?.description;
+
+ return (
+
+
+ {t('product.about.title')}
+
+
+
+
+
+ {localizedDescription?.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..f4856a2a7d4
--- /dev/null
+++ b/src/modules/shared/molecules/PageLoader/PageLoader.tsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import styles from './PageLoader.module.scss';
+
+export const PageLoader: React.FC = () => {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+ );
+};
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..593a6c22507
--- /dev/null
+++ b/src/modules/shared/templates/CategoryTemplate/components/organisms/CatalogPagination/CatalogPagination.tsx
@@ -0,0 +1,78 @@
+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 updatedSearch = getSearchWith(searchParams, {
+ [SearchParam.Page]: newPage.toString(),
+ });
+
+ navigate({ search: updatedSearch });
+ };
+
+ 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..c5c5e7842ce
--- /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/bcheban/react_phone-catalog',
+ external: true,
+ target: '_blank',
+ rel: 'noopener noreferrer',
+ },
+ {
+ label: 'Contacts',
+ path: 'https://github.com/bcheban',
+ external: true,
+ target: '_blank',
+ rel: 'noopener noreferrer',
+ },
+ {
+ label: 'Rights',
+ path: 'https://github.com/bcheban/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: '',
+ },
+ },
+ },
+});