diff --git a/.stylelintrc.js b/.stylelintrc.js index f3a4e74272a..8c5fe09f40c 100644 --- a/.stylelintrc.js +++ b/.stylelintrc.js @@ -1,4 +1,11 @@ module.exports = { - extends: "@mate-academy/stylelint-config", - rules: {} + extends: '@mate-academy/stylelint-config', + rules: { + 'selector-pseudo-class-no-unknown': [ + true, + { + ignorePseudoClasses: ['global', 'local'], + }, + ], + }, }; diff --git a/index.html b/index.html index 095fb3a4537..fca93f8b655 100644 --- a/index.html +++ b/index.html @@ -2,8 +2,9 @@ + - Vite + React + TS + Nice Gadgets
diff --git a/package-lock.json b/package-lock.json index 836b9e63b46..61bf462e0c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,11 +16,12 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.25.1", - "react-transition-group": "^4.4.5" + "react-transition-group": "^4.4.5", + "swiper": "^12.1.4" }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^2.1.3", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", @@ -1184,9 +1185,9 @@ } }, "node_modules/@mate-academy/scripts": { - "version": "1.8.5", - "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.8.5.tgz", - "integrity": "sha512-mHRY2FkuoYCf5U0ahIukkaRo5LSZsxrTSgMJheFoyf3VXsTvfM9OfWcZIDIDB521kdPrScHHnRp+JRNjCfUO5A==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-2.1.3.tgz", + "integrity": "sha512-a07wHTj/1QUK2Aac5zHad+sGw4rIvcNl5lJmJpAD7OxeSbnCdyI6RXUHwXhjF5MaVo9YHrJ0xVahyERS2IIyBQ==", "dev": true, "dependencies": { "@octokit/rest": "^17.11.2", @@ -9930,6 +9931,24 @@ "integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==", "dev": true }, + "node_modules/swiper": { + "version": "12.1.4", + "resolved": "https://registry.npmjs.org/swiper/-/swiper-12.1.4.tgz", + "integrity": "sha512-bihiwoKMOQwW8FfdUbo1DgkVH25E+4ZELIq0oopL1KTKBteLuaTMi/wwFjMxtlhTkk45k3XQ89D1Fvv0spSqBA==", + "funding": [ + { + "type": "custom", + "url": "https://sponsors.nolimits4web.com" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nolimits4web" + } + ], + "engines": { + "node": ">= 4.7.0" + } + }, "node_modules/synckit": { "version": "0.8.8", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.8.tgz", diff --git a/package.json b/package.json index ae251685c8b..abbb8b04d36 100644 --- a/package.json +++ b/package.json @@ -12,11 +12,12 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.25.1", - "react-transition-group": "^4.4.5" + "react-transition-group": "^4.4.5", + "swiper": "^12.1.4" }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^2.1.3", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", diff --git a/public/img/airpods.png b/public/img/airpods.png new file mode 100644 index 00000000000..9ab8e7d12b7 Binary files /dev/null and b/public/img/airpods.png differ diff --git a/public/img/apple-watch.png b/public/img/apple-watch.png new file mode 100644 index 00000000000..c5724a9d0a3 Binary files /dev/null and b/public/img/apple-watch.png differ diff --git a/public/img/category-accessory.png b/public/img/category-accessory.png new file mode 100644 index 00000000000..d342403a325 Binary files /dev/null and b/public/img/category-accessory.png differ diff --git a/public/img/category-phone.png b/public/img/category-phone.png new file mode 100644 index 00000000000..cd4718762fc Binary files /dev/null and b/public/img/category-phone.png differ diff --git a/public/img/category-tablet.png b/public/img/category-tablet.png new file mode 100644 index 00000000000..a82c660ffe5 Binary files /dev/null and b/public/img/category-tablet.png differ diff --git a/public/img/icons/apple-logo.svg b/public/img/icons/apple-logo.svg new file mode 100644 index 00000000000..3a26e9afbb5 --- /dev/null +++ b/public/img/icons/apple-logo.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/public/img/icons/cart.svg b/public/img/icons/cart.svg new file mode 100644 index 00000000000..6deb3bf9b71 --- /dev/null +++ b/public/img/icons/cart.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/img/icons/chevron-right-light.svg b/public/img/icons/chevron-right-light.svg new file mode 100644 index 00000000000..a15457b9ad6 --- /dev/null +++ b/public/img/icons/chevron-right-light.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/chevron-right.svg b/public/img/icons/chevron-right.svg new file mode 100644 index 00000000000..b4f46876718 --- /dev/null +++ b/public/img/icons/chevron-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/close.svg b/public/img/icons/close.svg new file mode 100644 index 00000000000..aadcc91fb1f --- /dev/null +++ b/public/img/icons/close.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/favourites-filled.svg b/public/img/icons/favourites-filled.svg new file mode 100644 index 00000000000..7138d7522bf --- /dev/null +++ b/public/img/icons/favourites-filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/favourites.svg b/public/img/icons/favourites.svg new file mode 100644 index 00000000000..d29d2a36aab --- /dev/null +++ b/public/img/icons/favourites.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/home.svg b/public/img/icons/home.svg new file mode 100644 index 00000000000..eb96fdbdc74 --- /dev/null +++ b/public/img/icons/home.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/img/icons/menu.svg b/public/img/icons/menu.svg new file mode 100644 index 00000000000..2c535f4586b --- /dev/null +++ b/public/img/icons/menu.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/img/icons/minus.svg b/public/img/icons/minus.svg new file mode 100644 index 00000000000..97c41038ac7 --- /dev/null +++ b/public/img/icons/minus.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/plus.svg b/public/img/icons/plus.svg new file mode 100644 index 00000000000..ab3c34061b5 --- /dev/null +++ b/public/img/icons/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/iphone17.png b/public/img/iphone17.png new file mode 100644 index 00000000000..31aad3f0b18 Binary files /dev/null and b/public/img/iphone17.png differ diff --git a/public/img/logo.svg b/public/img/logo.svg new file mode 100644 index 00000000000..800bc6f277b --- /dev/null +++ b/public/img/logo.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App.scss b/src/App.scss index 71bc413aade..e905e867169 100644 --- a/src/App.scss +++ b/src/App.scss @@ -1 +1,25 @@ -// not empty +@import './styles/fonts'; +@import './styles/mixins'; +@import './styles/globals'; +@import './styles/typography'; +@import './styles/variables'; + +html, +body { + scroll-behavior: smooth; + margin: 0; + padding: 0; + font-family: Montserrat, sans-serif; +} + +a { + display: inline-block; + + img { + display: block; + } +} + +.container { + @include content-padding-inline; +} diff --git a/src/App.tsx b/src/App.tsx index 372e4b42066..a85b265ea0f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,20 @@ +import { Outlet } from 'react-router-dom'; import './App.scss'; +import { Footer } from './components/Footer'; +import { Header } from './components/Header'; -export const App = () => ( -
-

Product Catalog

-
-); +function App() { + return ( +
+
+ +
+ +
+ +
+ ); +} + +export default App; diff --git a/src/CartContext.tsx b/src/CartContext.tsx new file mode 100644 index 00000000000..adee6b608d5 --- /dev/null +++ b/src/CartContext.tsx @@ -0,0 +1,36 @@ +import React, { useCallback, useMemo } from 'react'; +import { useLocalStorage } from './hooks/useLocalStorage'; +import type { Cart } from './types/Cart'; + +type CartContextType = { + cart: Cart[]; + setCart: React.Dispatch>; +}; + +export const CartContext = React.createContext({ + cart: [], + setCart: () => {}, +}); + +type Props = { + children: React.ReactNode; +}; + +export const CartProvider: React.FC = ({ children }) => { + const [cart, saveCart] = useLocalStorage('cart', []); + + const setCart: React.Dispatch> = useCallback( + value => { + if (typeof value === 'function') { + saveCart(value(cart)); + } else { + saveCart(value); + } + }, + [cart, saveCart], + ); + + const value = useMemo(() => ({ cart, setCart }), [cart, setCart]); + + return {children}; +}; diff --git a/src/FavouritesContext.tsx b/src/FavouritesContext.tsx new file mode 100644 index 00000000000..43d5070b22d --- /dev/null +++ b/src/FavouritesContext.tsx @@ -0,0 +1,47 @@ +import React, { useCallback, useMemo } from 'react'; +import { useLocalStorage } from './hooks/useLocalStorage'; +import type { Product } from './types/Product'; + +type FavouritesContextType = { + favourites: Product[]; + setFavourites: React.Dispatch>; +}; + +export const FavouritesContext = React.createContext({ + favourites: [], + setFavourites: () => {}, +}); + +type Props = { + children: React.ReactNode; +}; + +export const FavouritesProvider: React.FC = ({ children }) => { + const [favourites, saveFavourites] = useLocalStorage( + 'favourites', + [], + ); + + const setFavourites: React.Dispatch> = + useCallback( + value => { + if (typeof value === 'function') { + saveFavourites(value(favourites)); + } else { + saveFavourites(value); + } + }, + [favourites, saveFavourites], + ); + + const value = useMemo( + () => ({ favourites, setFavourites }), + [favourites, setFavourites], + ); + + return ( + + {children} + + ); +}; diff --git a/src/Root.tsx b/src/Root.tsx new file mode 100644 index 00000000000..bf76450f41b --- /dev/null +++ b/src/Root.tsx @@ -0,0 +1,31 @@ +import { HashRouter, Navigate, Route, Routes } from 'react-router-dom'; +import App from './App'; +import { NotFoundPage } from './modules/NotFoundPage'; +import { CartPage } from './modules/CartPage/CartPage'; +import { FavoritesPage } from './modules/FavoritesPage'; +import { HomePage } from './modules/HomePage'; +import { CatalogPage } from './modules/CatalogPage'; +import { ProductDetailsPage } from './modules/ProductDetailsPage'; + +export const Root = () => { + return ( + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } + > + } /> + + + + ); +}; diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 00000000000..fc3d6ffdc7f --- /dev/null +++ b/src/api.ts @@ -0,0 +1,9 @@ +function wait(delay: number) { + return new Promise(resolve => setTimeout(resolve, delay)); +} + +export async function getProducts(type: string): Promise { + return wait(1) + .then(() => fetch(`./api/${type}.json`)) + .then(response => response.json()); +} diff --git a/src/components/Footer/Footer.module.scss b/src/components/Footer/Footer.module.scss new file mode 100644 index 00000000000..a8f3d3b1ac2 --- /dev/null +++ b/src/components/Footer/Footer.module.scss @@ -0,0 +1,60 @@ +@import './../../styles/mixins'; +@import './../../styles/typography'; + + +.footer { + border-top: 1px solid var(--color-elements); + + &__content { + display: flex; + justify-content: space-between; + flex-direction: column; + padding-block: 32px; + gap: 32px; + + @include on-tablet { + flex-direction: row; + } + } + + &__items { + display: flex; + flex-direction: column; + align-items: start; + gap: 16px; + + @include on-tablet { + flex-direction: row; + align-items: center; + gap: 13px; + } + + @include on-desktop { + flex-direction: row; + align-items: center; + gap: 106px; + } + } + + &__item { + text-decoration: none; + text-transform: uppercase; + color: var(--color-secondary); + + @include uppercase; + } + + &__toTop { + display: flex; + justify-content: center; + gap: 16px; + } + + &__back { + display: flex; + align-items: center; + color: var(--color-secondary); + + @include small-text; + } +} diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 00000000000..52eef0626d1 --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,48 @@ +import { Link } from 'react-router-dom'; +import s from './Footer.module.scss'; +import { SliderButton } from '../../modules/shared/SliderButton'; + +export const Footer = () => { + return ( +
+
+
+ + Logo + +
+ + Github + + + Contacts + + + Rights + +
+
+
Back to top
+ window.scrollTo({ top: 0 })} + /> +
+
+
+
+ ); +}; diff --git a/src/components/Footer/index.ts b/src/components/Footer/index.ts new file mode 100644 index 00000000000..ddcc5a9cd18 --- /dev/null +++ b/src/components/Footer/index.ts @@ -0,0 +1 @@ +export * from './Footer'; diff --git a/src/components/Header/Header.module.scss b/src/components/Header/Header.module.scss new file mode 100644 index 00000000000..c1c13075d0f --- /dev/null +++ b/src/components/Header/Header.module.scss @@ -0,0 +1,115 @@ +@import './../../styles/mixins'; + +.header { + display: flex; + gap: 16px; + border-bottom: 1px solid var(--color-elements); + justify-content: space-between; + align-items: center; + height: 48px; + + @include on-desktop { + gap: 24px; + height: 64px; + } + + &__logo-link { + padding: 13px 16px; + + @include on-desktop { + padding: 18px 24px; + } + } + + &__logo { + height: 22px; + width: 64px; + + @include on-desktop { + height: 28px; + width: 80px; + } + } + + &__nav { + height: 100%; + display: none; + + @include on-tablet { + display: flex; + } + } + + &__favorites-cart { + display: none; + + @include on-tablet { + flex: 1; + display: flex; + justify-content: flex-end; + height: 100%; + } + } + + &__favorites, + &__cart { + padding: 16px; + border-left: 1px solid var(--color-elements); + box-sizing: border-box; + position: relative; + + @include on-desktop { + padding: 24px; + } + + &--active { + &::after { + content: ''; + height: 3px; + width: 100%; + background: var(--color-primary-dark); + position: absolute; + bottom: 0; + left: 0; + } + } + } + + &__menu { + display: flex; + align-items: center; + padding-inline: 16px; + border-left: 1px solid var(--color-elements); + height: 100%; + + @include on-tablet { + display: none; + } + } + + &__iconWrap { + display: flex; + position: relative; + } + + &__counter { + position: absolute; + top: -6px; + left: 7px; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--color-red); + box-sizing: border-box; + border: 1px solid white; + border-radius: 50%; + color: white; + height: 14px; + width: 14px; + font-weight: 600; + font-size: 10px; + line-height: 100%; + letter-spacing: 0%; + padding-top: 2px; + } +} diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 00000000000..930e50adf1b --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,66 @@ +import { Link, useLocation } from 'react-router-dom'; +import s from './Header.module.scss'; +import { NavBar } from '../NavBar'; +import classNames from 'classnames'; +import { MobileMenu } from '../MobileMenu'; +import { useContext, useState } from 'react'; +import { CartContext } from '../../CartContext'; +import { FavouritesContext } from '../../FavouritesContext'; + +export const Header = () => { + const [isOpen, setIsOpen] = useState(false); + const { cart } = useContext(CartContext); + const { favourites } = useContext(FavouritesContext); + const { pathname } = useLocation(); + + const cartAmount = cart.reduce((sum, item) => sum + item.quantity, 0); + + return ( +
+ + Logo + + +
+ +
+ +
+ +
+ Favorites + {favourites.length > 0 && ( + {favourites.length} + )} +
+ + +
+ Cart + {cartAmount > 0 && ( + {cartAmount} + )} +
+ +
+ +
setIsOpen(true)}> +
+ Menu +
+
+ + +
+ ); +}; diff --git a/src/components/Header/index.ts b/src/components/Header/index.ts new file mode 100644 index 00000000000..266dec8a1bc --- /dev/null +++ b/src/components/Header/index.ts @@ -0,0 +1 @@ +export * from './Header'; diff --git a/src/components/MobileMenu/MobileMenu.module.scss b/src/components/MobileMenu/MobileMenu.module.scss new file mode 100644 index 00000000000..1fb66a1f4c2 --- /dev/null +++ b/src/components/MobileMenu/MobileMenu.module.scss @@ -0,0 +1,121 @@ +@import './../../styles/variables'; +@import './../../styles/mixins'; + +.menu { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100vh; + background: white; + transform: translateX(-100%); + transition: transform 0.3s ease; + z-index: 2; + + &__top { + display: flex; + justify-content: space-between; + border-bottom: 1px solid var(--color-elements); + height: 48px; + + @include on-desktop { + height: 64px; + } + } + + &__logo { + padding: 13px 16px; + + @include on-desktop { + padding: 18px 24px; + } + } + + &__logo-img { + height: 22px; + width: 64px; + + @include on-desktop { + height: 28px; + width: 80px; + } + } + + &__close { + display: flex; + align-items: center; + justify-content: center; + padding-inline: 16px; + border-left: 1px solid var(--color-elements); + height: 100%; + } + + &__nav { + margin-top: 24px; + } + + &__favorites-cart { + display: flex; + position: fixed; + bottom: 0; + left: 0; + right: 0; + border-top: 1px solid var(--color-elements); + } + + &__favorites, + &__cart { + display: flex; + justify-content: center; + padding-block: 24px; + box-sizing: border-box; + position: relative; + width: 50%; + + &--active { + &::after { + content: ''; + height: 2px; + width: 100%; + background: var(--color-primary-dark); + position: absolute; + bottom: 0; + left: 0; + } + } + } + + &__cart { + border-left: 1px solid var(--color-elements); + } + + &__iconWrap { + position: relative; + margin: auto; + } + + &__counter { + position: absolute; + top: -6px; + left: 7px; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--color-red); + box-sizing: border-box; + border: 1px solid white; + border-radius: 50%; + color: white; + height: 14px; + width: 14px; + font-weight: 600; + font-size: 10px; + line-height: 100%; + letter-spacing: 0%; + padding-top: 2px; + } + + &--open { + transform: translateX(0); + } +} diff --git a/src/components/MobileMenu/MobileMenu.tsx b/src/components/MobileMenu/MobileMenu.tsx new file mode 100644 index 00000000000..c4d3e06c3e8 --- /dev/null +++ b/src/components/MobileMenu/MobileMenu.tsx @@ -0,0 +1,77 @@ +import classNames from 'classnames'; +import { NavBar } from '../NavBar'; +import s from './MobileMenu.module.scss'; +import { Link, useLocation } from 'react-router-dom'; +import { useContext } from 'react'; +import { CartContext } from '../../CartContext'; +import { FavouritesContext } from '../../FavouritesContext'; + +type Props = { + isOpen: boolean; + onClose: (v: boolean) => void; +}; + +export const MobileMenu = ({ isOpen, onClose }: Props) => { + const { cart } = useContext(CartContext); + const { favourites } = useContext(FavouritesContext); + const { pathname } = useLocation(); + + const favouritesAmount = favourites.length; + const cartAmount = cart.reduce((sum, item) => sum + item.quantity, 0); + + return ( +
+
+
+ Logo +
+
onClose(false)}> + Close +
+
+ +
+ +
+ +
+ onClose(false)} + > +
+ Cart + {favouritesAmount > 0 && ( + {favouritesAmount} + )} +
+ + onClose(false)} + > +
+ Cart + {cartAmount > 0 && ( + {cartAmount} + )} +
+ +
+
+ ); +}; diff --git a/src/components/MobileMenu/index.ts b/src/components/MobileMenu/index.ts new file mode 100644 index 00000000000..298a8ff2b8f --- /dev/null +++ b/src/components/MobileMenu/index.ts @@ -0,0 +1 @@ +export * from './MobileMenu'; diff --git a/src/components/NavBar/NavBar.module.scss b/src/components/NavBar/NavBar.module.scss new file mode 100644 index 00000000000..f6e9274833c --- /dev/null +++ b/src/components/NavBar/NavBar.module.scss @@ -0,0 +1,56 @@ +@import './../../styles/mixins'; +@import './../../styles/typography'; + +.nav { + display: flex; + flex-direction: column; + gap: 32px; + height: 100%; + width: 100%; + align-items: center; + + @include on-tablet { + flex-direction: row; + } + + @include on-desktop { + gap: 64px; + } + + &__link { + text-decoration: none; + text-transform: uppercase; + display: block; + align-items: center; + position: relative; + width: fit-content; + height: 100%; + padding-bottom: 8px; + color: var(--color-secondary); + + @include uppercase; + + @include on-tablet { + display: flex; + padding-bottom: 0; + } + + &--active { + color: var(--color-primary-dark); + + &::after { + content: ''; + height: 2px; + width: 100%; + background: var(--color-primary-dark); + position: absolute; + bottom: 0; + left: 0; + + @include on-tablet { + height: 3px; + } + } + } + } +} diff --git a/src/components/NavBar/NavBar.tsx b/src/components/NavBar/NavBar.tsx new file mode 100644 index 00000000000..5ab66073ab5 --- /dev/null +++ b/src/components/NavBar/NavBar.tsx @@ -0,0 +1,32 @@ +import { Link, useLocation } from 'react-router-dom'; +import s from './NavBar.module.scss'; +import classNames from 'classnames'; + +type Props = { + onClose?: (v: boolean) => void; +}; + +export const NavBar = ({ onClose = () => {} }: Props) => { + const { pathname } = useLocation(); + + return ( + + ); +}; diff --git a/src/components/NavBar/index.ts b/src/components/NavBar/index.ts new file mode 100644 index 00000000000..39c20679455 --- /dev/null +++ b/src/components/NavBar/index.ts @@ -0,0 +1 @@ +export * from './NavBar'; diff --git a/src/global.d.ts b/src/global.d.ts new file mode 100644 index 00000000000..7d7f17f7e21 --- /dev/null +++ b/src/global.d.ts @@ -0,0 +1,4 @@ +declare module 'swiper/css'; +declare module 'swiper/css/*'; + +declare module '*.scss'; diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts new file mode 100644 index 00000000000..cb38c89bc81 --- /dev/null +++ b/src/hooks/useLocalStorage.ts @@ -0,0 +1,29 @@ +import { useState } from 'react'; + +export function useLocalStorage( + key: string, + startValue: T, +): [T, (v: T) => void] { + const [value, setValue] = useState(() => { + const data = localStorage.getItem(key); + + if (data === null) { + return startValue; + } + + try { + return JSON.parse(data); + } catch (error) { + localStorage.removeItem(key); + + return startValue; + } + }); + + const save = (newValue: T) => { + localStorage.setItem(key, JSON.stringify(newValue)); + setValue(newValue); + }; + + return [value, save]; +} diff --git a/src/index.tsx b/src/index.tsx index 50470f1508d..abcf16cfbee 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,15 @@ +import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; -import { App } from './App'; +import { Root } from './Root.js'; +import { CartProvider } from './CartContext.js'; +import { FavouritesProvider } from './FavouritesContext.js'; -createRoot(document.getElementById('root') as HTMLElement).render(); +createRoot(document.getElementById('root')!).render( + + + + + + + , +); diff --git a/src/modules/CartPage/CartPage.module.scss b/src/modules/CartPage/CartPage.module.scss new file mode 100644 index 00000000000..a85aba18804 --- /dev/null +++ b/src/modules/CartPage/CartPage.module.scss @@ -0,0 +1,95 @@ +@import './../../styles/mixins'; +@import './../../styles/typography'; + +.cart { + &__title { + @include h1; + } + + &__content { + @include page-grid; + + grid-gap: 32px; + margin-bottom: 56px; + + @include on-tablet { + margin-bottom: 64px; + } + + @include on-desktop { + margin-bottom: 80px; + } + } + + &__list { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + gap: 16px; + + @include on-desktop { + grid-column: 1 / 17; + } + } + + &__total { + grid-column: 1 / -1; + + @include on-desktop { + grid-column: 17 / -1; + } + } + + &__totalContent { + display: flex; + flex-direction: column; + justify-content: center; + padding: 24px; + gap: 16px; + border: 1px solid var(--color-elements); + } + + &__totalPriceAmount { + display: flex; + margin-inline: auto; + flex-direction: column; + } + + &__totalPrice { + display: flex; + margin-inline: auto; + color: var(--color-primary-dark); + font-weight: 800; + font-size: 32px; + line-height: 41px; + text-align: center; + letter-spacing: -1%; + } + + &__totalAmount { + color: var(--color-secondary); + + @include body-text; + } + + &__totalButton { + height: 48px; + display: flex; + } + + &__isEmpty { + width: 80%; + display: flex; + margin-inline: auto; + + @include on-tablet { + width: 60%; + + } + + @include on-desktop { + width: 45%; + + } + } +} diff --git a/src/modules/CartPage/CartPage.tsx b/src/modules/CartPage/CartPage.tsx new file mode 100644 index 00000000000..d9221308a28 --- /dev/null +++ b/src/modules/CartPage/CartPage.tsx @@ -0,0 +1,95 @@ +import { useLocation } from 'react-router-dom'; +import { useContext, useState } from 'react'; +import { CartContext } from '../../CartContext'; +import { capitalizeFirstLetter } from '../../utils/string'; +import { Back } from '../shared/Back'; +import s from './CartPage.module.scss'; +import { Line } from '../shared/Line'; +import { PrimaryButton } from '../shared/PrimaryButton'; +import { CartItem } from './components/CartItem/CartItem'; +import { CheckoutModal } from './components/CheckoutModal/CheckoutModal'; + +export const CartPage = () => { + const { pathname } = useLocation(); + + const { cart, setCart } = useContext(CartContext) || { + cart: [], + setCart: () => {}, + }; + + const [isModalOpen, setIsModalOpen] = useState(false); + + const type = pathname.slice(1); + + const totalPrice = cart.reduce( + (sum, item) => sum + item.product.price * item.quantity, + 0, + ); + + const totalAmount = cart.reduce((sum, item) => sum + item.quantity, 0); + + const handleCheckout = () => { + setIsModalOpen(true); + }; + + const handleConfirmCheckout = () => { + setCart([]); + setIsModalOpen(false); + }; + + const handleCancelCheckout = () => { + setIsModalOpen(false); + }; + + return ( +
+ + + {cart.length > 0 ? ( + <> +

{capitalizeFirstLetter(type)}

+ +
+
+ {cart.map(item => ( + + ))} +
+ +
+
+
+
${totalPrice}
+ +
+ Total for {totalAmount} items +
+
+ + + +
+ + Checkout + +
+
+
+
+ + + + ) : ( + cart-is-empty + )} +
+ ); +}; diff --git a/src/modules/CartPage/components/CartItem/CartItem.module.scss b/src/modules/CartPage/components/CartItem/CartItem.module.scss new file mode 100644 index 00000000000..9fb2d562feb --- /dev/null +++ b/src/modules/CartPage/components/CartItem/CartItem.module.scss @@ -0,0 +1,68 @@ +@import './../../../../styles/typography'; +@import './../../../../styles/mixins'; + +.cartItem { + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px; + border: 1px solid var(--color-elements); + + @include on-tablet { + flex-direction: row; + justify-content: space-between; + } + + &__firstRow { + display: flex; + gap: 16px; + align-items: center; + } + + &__delButton { + all: unset; + } + + &__img { + height: 80px; + width: 80px; + object-fit: contain; + } + + &__name { + flex: 1; + text-decoration: none; + color: var(--color-primary-dark); + + @include body-text; + } + + &__secondRow { + display: flex; + justify-content: space-between; + + @include on-tablet { + gap: 16px; + justify-content: space-between; + } + } + + &__counter { + display: flex; + align-items: center; + gap: 13px; + + @include body-text; + } + + &__price { + display: flex; + align-items: center; + justify-content: end; + color: var(--color-primary-dark); + font-weight: 800; + font-size: 22px; + line-height: 140%; + min-width: 80px; + } +} diff --git a/src/modules/CartPage/components/CartItem/CartItem.tsx b/src/modules/CartPage/components/CartItem/CartItem.tsx new file mode 100644 index 00000000000..734d37a4298 --- /dev/null +++ b/src/modules/CartPage/components/CartItem/CartItem.tsx @@ -0,0 +1,70 @@ +import { Link } from 'react-router-dom'; +import type { Cart } from '../../../../types/Cart'; +import s from './CartItem.module.scss'; +import { useContext } from 'react'; +import { CartContext } from '../../../../CartContext'; +import { SliderButton } from '../../../shared/SliderButton'; + +type Props = { + cartItem: Cart; +}; + +export const CartItem: React.FC = ({ cartItem }) => { + const { setCart } = useContext(CartContext); + + return ( +
+
+ + + image + + + {cartItem.product.name} + +
+
+
+ + setCart(prev => + prev.map(item => + item.id === cartItem.id + ? { ...item, quantity: item.quantity - 1 } + : item, + ), + ) + } + /> +
{cartItem.quantity}
+ + setCart(prev => + prev.map(item => + item.id === cartItem.id + ? { ...item, quantity: item.quantity + 1 } + : item, + ), + ) + } + /> +
+
${cartItem.product.price}
+
+
+ ); +}; diff --git a/src/modules/CartPage/components/CartItem/index.ts b/src/modules/CartPage/components/CartItem/index.ts new file mode 100644 index 00000000000..dc0dc8965a3 --- /dev/null +++ b/src/modules/CartPage/components/CartItem/index.ts @@ -0,0 +1 @@ +export * from '.'; diff --git a/src/modules/CartPage/components/CheckoutModal/CheckoutModal.module.scss b/src/modules/CartPage/components/CheckoutModal/CheckoutModal.module.scss new file mode 100644 index 00000000000..c7728e6364a --- /dev/null +++ b/src/modules/CartPage/components/CheckoutModal/CheckoutModal.module.scss @@ -0,0 +1,68 @@ +@import './../../../../styles/typography'; + +.modalOverlay { + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + padding: 16px; +} + +.modal { + width: 100%; + max-width: 420px; + background-color: var(--color-white); + border-radius: 16px; + padding: 24px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15); + + &__title { + font-size: 24px; + font-weight: 700; + margin-bottom: 12px; + text-align: center; + color: var(--color-primary-dark); + } + + &__text { + font-size: 16px; + line-height: 1.5; + text-align: center; + color: var(--color-secondary); + margin-bottom: 24px; + } + + &__actions { + display: flex; + gap: 12px; + } + + &__button { + flex: 1; + height: 48px; + border: none; + cursor: pointer; + font-weight: 600; + font-size: 16px; + transition: 0.3s; + + @include button-text; + + &:hover { + opacity: 0.9; + } + } + + &__buttonCancel { + background-color: var(--color-elements); + color: var(--color-primary-dark); + } + + &__buttonConfirm { + background-color: var(--color-primary-dark); + color: white; + } +} diff --git a/src/modules/CartPage/components/CheckoutModal/CheckoutModal.tsx b/src/modules/CartPage/components/CheckoutModal/CheckoutModal.tsx new file mode 100644 index 00000000000..e23c1aaf1fb --- /dev/null +++ b/src/modules/CartPage/components/CheckoutModal/CheckoutModal.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import s from './CheckoutModal.module.scss'; + +type Props = { + isOpen: boolean; + onConfirm: () => void; + onCancel: () => void; +}; + +export const CheckoutModal: React.FC = ({ + isOpen, + onConfirm, + onCancel, +}) => { + if (!isOpen) { + return null; + } + + return ( +
+
e.stopPropagation()}> +

Checkout is not implemented yet

+ +

Do you want to clear the Cart?

+ +
+ + + +
+
+
+ ); +}; diff --git a/src/modules/CartPage/components/CheckoutModal/index.ts b/src/modules/CartPage/components/CheckoutModal/index.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/modules/CartPage/index.ts b/src/modules/CartPage/index.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/modules/CatalogPage/CatalogPage.module.scss b/src/modules/CatalogPage/CatalogPage.module.scss new file mode 100644 index 00000000000..5787e4ca8d8 --- /dev/null +++ b/src/modules/CatalogPage/CatalogPage.module.scss @@ -0,0 +1,70 @@ +@import './../../styles/typography'; +@import './../../styles/mixins'; + +.catalog { + &__title { + margin-bottom: 8px; + + @include h1; + } + + &__amount { + color: var(--color-secondary); + margin: 0; + } + + &__filters { + margin-bottom: 24px; + margin-top: 32px; + + @include page-grid; + + @include on-tablet { + margin-top: 40px; + } + } + + &__filterSort { + grid-column: 1 / 3; + display: flex; + flex-direction: column; + gap: 4px; + + @include on-tablet { + grid-column: 1 / 5; + } + } + + &__filterPage { + grid-column: 3 / -1; + display: flex; + flex-direction: column; + gap: 4px; + + @include on-tablet { + grid-column: 5 / 8; + } + } + + &__filterName { + color: var(--color-secondary); + + @include small-text; + } + + &__products { + margin-bottom: 24px; + + @include on-tablet { + margin-bottom: 40px; + } + } + + &__pagination { + margin-bottom: 64px; + + @include on-desktop { + margin-bottom: 80px; + } + } +} diff --git a/src/modules/CatalogPage/CatalogPage.tsx b/src/modules/CatalogPage/CatalogPage.tsx new file mode 100644 index 00000000000..350f2e66f1e --- /dev/null +++ b/src/modules/CatalogPage/CatalogPage.tsx @@ -0,0 +1,116 @@ +import { useEffect, useState } from 'react'; +import { CardList } from '../shared/CardList/CardList'; +import type { Product } from '../../types/Product'; +import { getProducts } from '../../api'; +import { useLocation, useSearchParams } from 'react-router-dom'; +import { Breadcrumbs } from '../shared/Breadcrumbs'; +import { capitalizeFirstLetter } from '../../utils/string'; +import s from './CatalogPage.module.scss'; +import { Dropdown } from '../shared/Dropdown'; +import { Pagination } from '../shared/Pagination'; + +const sortOptions = [ + { value: 'age', label: 'Newest' }, + { value: 'title', label: 'Alphabetically' }, + { value: 'price', label: 'Cheapest' }, +]; + +const paginationOptions = [ + { value: 'all', label: 'All' }, + { value: '4', label: '4' }, + { value: '8', label: '8' }, + { value: '16', label: '16' }, +]; + +const getPreparedProducts = ( + products: Product[], + sort: string, + page: number, + perPage: number | string, +) => { + let preparedProducts = [...products]; + + if (sort) { + preparedProducts = preparedProducts.sort((p1, p2) => { + switch (sort) { + case 'title': + return p1.name.localeCompare(p2.name); + case 'price': + return p1.price - p2.price; + case 'age': + return p2.year - p1.year; + default: + return 0; + } + }); + } + + if (perPage !== 'all') { + const startItem = (+page - 1) * +perPage; + const endItem = startItem + +perPage; + + return [...preparedProducts].slice(startItem, endItem); + } + + return preparedProducts; +}; + +export const CatalogPage = () => { + const [products, setProducts] = useState([]); + const { pathname } = useLocation(); + const [searchParams] = useSearchParams(); + const sortBy = searchParams.get('sort') || 'age'; + const perPage = searchParams.get('perPage') || 'all'; + const page = searchParams.get('page') || '1'; + const type = pathname.slice(1); + + useEffect(() => { + getProducts('products').then(setProducts); + }, []); + + const productsByType = products.filter(product => product.category === type); + + const preparedProducts = getPreparedProducts( + productsByType, + sortBy, + +page, + perPage, + ); + + return ( +
+ +

{capitalizeFirstLetter(type)}

+

{productsByType.length} models

+ +
+
+
Sort by
+ +
+
+
Items on page
+ +
+
+ +
+ +
+ + {perPage !== 'all' && ( +
+ +
+ )} +
+ ); +}; diff --git a/src/modules/CatalogPage/index.ts b/src/modules/CatalogPage/index.ts new file mode 100644 index 00000000000..1cad0ffbfe4 --- /dev/null +++ b/src/modules/CatalogPage/index.ts @@ -0,0 +1 @@ +export * from './CatalogPage'; diff --git a/src/modules/FavoritesPage/FavoritesPage.module.scss b/src/modules/FavoritesPage/FavoritesPage.module.scss new file mode 100644 index 00000000000..4986c2ef4b7 --- /dev/null +++ b/src/modules/FavoritesPage/FavoritesPage.module.scss @@ -0,0 +1,28 @@ +@import './../../styles/typography'; + +.favourites { + &__title { + color: var(--color-primary-dark); + margin-bottom: 8px; + + @include h1; + } + + &__amount { + color: var(--color-secondary); + + @include body-text; + } + + &__list { + margin-block: 32px 56px; + + @include on-tablet { + margin-block: 40px 64px; + } + + @include on-desktop { + margin-block: 40px 80px; + } + } +} diff --git a/src/modules/FavoritesPage/FavoritesPage.tsx b/src/modules/FavoritesPage/FavoritesPage.tsx new file mode 100644 index 00000000000..1ede3a6c4af --- /dev/null +++ b/src/modules/FavoritesPage/FavoritesPage.tsx @@ -0,0 +1,26 @@ +import { useLocation } from 'react-router-dom'; +import { Breadcrumbs } from '../shared/Breadcrumbs'; +import { useContext } from 'react'; +import { FavouritesContext } from '../../FavouritesContext'; +import { CardList } from '../shared/CardList/CardList'; +import { capitalizeFirstLetter } from '../../utils/string'; +import s from './FavoritesPage.module.scss'; + +export const FavoritesPage = () => { + const { pathname } = useLocation(); + const { favourites } = useContext(FavouritesContext); + const type = pathname.slice(1); + + return ( +
+ +

{capitalizeFirstLetter(type)}

+
+ {favourites.length === 1 ? `1 item` : `${favourites.length} items`} +
+
+ +
+
+ ); +}; diff --git a/src/modules/FavoritesPage/index.ts b/src/modules/FavoritesPage/index.ts new file mode 100644 index 00000000000..b3a884b1889 --- /dev/null +++ b/src/modules/FavoritesPage/index.ts @@ -0,0 +1 @@ +export * from './FavoritesPage'; diff --git a/src/modules/HomePage/HomePage.module.scss b/src/modules/HomePage/HomePage.module.scss new file mode 100644 index 00000000000..2816a3c2d1a --- /dev/null +++ b/src/modules/HomePage/HomePage.module.scss @@ -0,0 +1,40 @@ +@import './../../styles/mixins'; +@import './../../styles/typography'; + +.homePage { + &__name { + display: none; + } + + &__title { + color: var(--color-primary-dark); + margin-block: 24px; + + @include h1; + + @include on-tablet { + margin-block: 32px; + } + + @include on-desktop { + margin-block: 56px; + } + } + + &__content { + display: flex; + flex-direction: column; + gap: 56px; + padding-block: 56px; + + @include on-tablet { + gap: 64px; + padding-block: 64px; + } + + @include on-desktop { + gap: 80px; + padding-block: 80px; + } + } +} diff --git a/src/modules/HomePage/HomePage.tsx b/src/modules/HomePage/HomePage.tsx new file mode 100644 index 00000000000..85ec33e3308 --- /dev/null +++ b/src/modules/HomePage/HomePage.tsx @@ -0,0 +1,59 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Banner } from './components/Banner'; +import { Categories } from './components/Categories'; +import type { Product } from '../../types/Product'; +import { getProducts } from '../../api'; +import { CardsSlider } from '../shared/CardsSlider'; +import { SwiperSlide } from 'swiper/react'; +import s from './HomePage.module.scss'; +import { Card } from '../shared/Card'; + +export const HomePage = () => { + const [products, setProducts] = useState([]); + + useEffect(() => { + getProducts('products').then(setProducts); + }, []); + + const newModel = useMemo(() => { + return [...products].sort((p1, p2) => p2.year - p1.year); + }, [products]); + + const hotPrices = useMemo(() => { + return [...products].sort((p1, p2) => { + const discount1 = p1.fullPrice - p1.price; + const discount2 = p2.fullPrice - p2.price; + + return discount2 - discount1; + }); + }, [products]); + + return ( + <> +

Product Catalog

+

Welcome to Nice Gadgets store!

+ + + +
+ + {newModel.map(product => ( + + + + ))} + + + + + + {hotPrices.map(product => ( + + + + ))} + +
+ + ); +}; diff --git a/src/modules/HomePage/components/Banner/Banner.module.scss b/src/modules/HomePage/components/Banner/Banner.module.scss new file mode 100644 index 00000000000..688feba6c39 --- /dev/null +++ b/src/modules/HomePage/components/Banner/Banner.module.scss @@ -0,0 +1,144 @@ +@import './../../../../styles/mixins'; + +.banner { + margin-left: -16px; + margin-right: -16px; + width: calc(100% + 32px); + + @include on-tablet { + margin: 0; + width: 100%; + display: flex; + + @include page-grid; + } + + &__button { + display: none; + + @include on-tablet { + margin-bottom: 20px; + display: block; + grid-column: span 1; + } + } + + &__swiperWrapper { + width: 100%; + + @include on-tablet { + grid-column: span 10; + } + + @include on-desktop { + grid-column: span 22; + } + } + + &__swiper { + :global { + .swiper-pagination { + position: static; + margin-top: 16px; + display: flex; + justify-content: center; + } + + .swiper-pagination-bullet { + width: 14px; + height: 4px; + border-radius: 0; + background-color: var(--color-elements); + opacity: 1; + } + + .swiper-pagination-bullet-active { + background-color: var(--color-primary-dark); + } + } + } + + &__slide { + height: 100%; + } + + &__slideCard { + aspect-ratio: 1 / 1; + + @include on-tablet { + aspect-ratio: 1 / 0.4; + } + } + + +} + +.slide { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + + padding: 32px; + border-radius: 20px; + overflow: hidden; + + // 👉 главное требование + aspect-ratio: 1 / 0.4; + + @media (max-width: 768px) { + flex-direction: column; + justify-content: center; + text-align: center; + + aspect-ratio: 1 / 1; + } + + &__content { + z-index: 2; + max-width: 400px; + } + + &__subtitle { + color: #a855f7; + font-size: 14px; + margin-bottom: 8px; + } + + &__title { + font-size: 36px; + font-weight: 700; + color: #fff; + } + + &__desc { + color: #aaa; + margin: 10px 0 20px; + } + + &__btn { + padding: 10px 20px; + border-radius: 999px; + border: none; + background: white; + color: black; + font-weight: 600; + cursor: pointer; + transition: 0.3s; + + &:hover { + transform: scale(1.05); + } + } + + &__image { + height: 90%; + object-fit: contain; + z-index: 1; + + @media (max-width: 768px) { + height: 50%; + margin-top: 20px; + } + } +} diff --git a/src/modules/HomePage/components/Banner/Banner.tsx b/src/modules/HomePage/components/Banner/Banner.tsx new file mode 100644 index 00000000000..ea155a8f06b --- /dev/null +++ b/src/modules/HomePage/components/Banner/Banner.tsx @@ -0,0 +1,54 @@ +import { Swiper, SwiperSlide } from 'swiper/react'; +import s from './Banner.module.scss'; +import 'swiper/css'; +import 'swiper/css/pagination'; + +import { Pagination, Autoplay } from 'swiper/modules'; +import { useRef } from 'react'; +import type { SwiperRef } from 'swiper/react'; +import { SliderButton } from '../../../shared/SliderButton'; +import BannerSlide, { bannerSlides } from '../BannerSlide/BannerSlide'; + +export const Banner = () => { + const swiperRef = useRef(null); + + return ( +
+
+ swiperRef.current?.swiper.slidePrev()} + /> +
+ +
+ + {bannerSlides.map((slide, index) => ( + + + + ))} + +
+ +
+ swiperRef.current?.swiper.slideNext()} + /> +
+
+ ); +}; diff --git a/src/modules/HomePage/components/Banner/index.ts b/src/modules/HomePage/components/Banner/index.ts new file mode 100644 index 00000000000..bc95f09d62a --- /dev/null +++ b/src/modules/HomePage/components/Banner/index.ts @@ -0,0 +1 @@ +export * from './Banner'; diff --git a/src/modules/HomePage/components/BannerSlide/BannerSlide.module.scss b/src/modules/HomePage/components/BannerSlide/BannerSlide.module.scss new file mode 100644 index 00000000000..5662c6695bb --- /dev/null +++ b/src/modules/HomePage/components/BannerSlide/BannerSlide.module.scss @@ -0,0 +1,292 @@ +@import './../../../../styles/mixins'; + +.slide { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding: 0 5% 0 6%; + box-sizing: border-box; + font-family: 'SF Pro Display', 'Helvetica Neue', Arial, sans-serif; + + @include on-tablet { + flex-direction: row; + justify-content: space-between; + } + + .glow1, + .glow2 { + position: absolute; + border-radius: 50%; + pointer-events: none; + filter: blur(75px); + } + + &__leftContent { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: clamp(10px, 0.9vw, 14px); + padding-block: 20px; + + @include on-tablet { + align-items: flex-start; + min-width: 35%; + } + } + + &__rigthContent { + display: flex; + flex-direction: column; + gap: 10px; + justify-content: flex-end; + height: 100%; + width: 100%; + } +} + +.badge { + font-size: clamp(9px, 0.85vw, 12px); + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + padding: 4px 12px; + border-radius: 100px; +} + +.headline { + font-size: clamp(16px, 3vw, 42px); + font-weight: 800; + line-height: 1.1; + margin: 0; + color: #fff; + letter-spacing: -0.02em; + display: flex; + gap: 10px; + flex-direction: row; + + @include on-tablet { + flex-direction: column; + } +} + +.sub { + margin: 0; + font-size: clamp(9px, 0.95vw, 14px); + color: rgba(255, 255, 255, 0.5); +} + +.btn { + display: inline-block; + margin-top: clamp(4px, 0.7vw, 10px); + padding: clamp(7px, 0.85vw, 13px) clamp(16px, 2vw, 30px); + font-size: clamp(9px, 0.8vw, 12px); + font-weight: 700; + letter-spacing: 0.1em; + text-decoration: none; + border-radius: 100px; + background: transparent; + color: #fff; + border: 1.5px solid rgba(255, 255, 255, 0.45); + transition: + background 0.22s, + border-color 0.22s, + transform 0.15s; + cursor: pointer; +} + +.btn:hover { + background: rgba(255, 255, 255, 0.1); + border-color: #fff; + transform: scale(1.03); +} + +.imgWrap { + display: flex; + justify-content: center; + align-items: flex-end; + min-height: 0; + height: 100%; + width: 100%; + + @include on-tablet { + height: 70%; + } +} + +.productImg { + max-width: 100%; + flex-shrink: 1; + object-fit: contain; + display: block; + filter: drop-shadow(0 20px 40px rgba(0, 0, 0, 0.55)); + + max-height: 150px; + + @include on-tablet { + max-height: 100%; + } +} + +.productLabel { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} + +.productTitle { + font-size: clamp(24px, 2.7vw, 36px); + font-weight: 700; + color: #fff; + letter-spacing: -0.01em; +} + +.productSub { + font-size: clamp(7px, 0.75vw, 16px); + color: rgba(255, 255, 255, 0.4); + letter-spacing: 0.05em; +} + +.orange .btn { + border-color: rgba(251, 146, 60, 0.5); + color: #fb923c; +} + +.cyan .btn { + border-color: rgba(34, 211, 238, 0.5); + color: #22d3ee; +} + + +.purple { + background: radial-gradient( + ellipse at 62% 50%, + #1a0533 0%, + #0b0012 60%, + #000 100% + ); +} + +.purple .glow1 { + width: 52%; + height: 140%; + top: -20%; + right: 16%; + background: #6d28d9; + opacity: 0.38; +} + +.purple .glow2 { + width: 28%; + height: 80%; + bottom: -20%; + right: 4%; + background: #4f46e5; + opacity: 0.3; +} + +.purple .accent { + color: #a855f7; +} + +.purple .badge { + background: rgba(138, 43, 226, 0.22); + color: #c084fc; + border: 1px solid rgba(192, 132, 252, 0.35); +} + +.orange { + background: radial-gradient( + ellipse at 62% 50%, + #1c0a00 0%, + #120600 55%, + #000 100% + ); +} + +.orange .glow1 { + width: 48%; + height: 130%; + top: -15%; + right: 18%; + background: #ea580c; + opacity: 0.38; +} + +.orange .glow2 { + width: 22%; + height: 70%; + bottom: -10%; + right: 6%; + background: #dc2626; + opacity: 0.28; +} + +.orange .accent { + color: #f97316; +} + +.orange .badge { + background: rgba(249, 115, 22, 0.2); + color: #fb923c; + border: 1px solid rgba(251, 146, 60, 0.35); +} + + + +.orange .btn:hover { + background: rgba(251, 146, 60, 0.1); + border-color: #fb923c; + color: #fff; +} + +.cyan { + background: radial-gradient( + ellipse at 58% 50%, + #001e2e 0%, + #00101a 60%, + #000 100% + ); +} + +.cyan .glow1 { + width: 48%; + height: 130%; + top: -15%; + right: 20%; + background: #0891b2; + opacity: 0.38; +} + +.cyan .glow2 { + width: 18%; + height: 60%; + top: 10%; + right: 26%; + background: #e0f2fe; + opacity: 0.07; +} + +.cyan .accent { + color: #22d3ee; +} + +.cyan .badge { + background: rgba(6, 182, 212, 0.2); + color: #22d3ee; + border: 1px solid rgba(34, 211, 238, 0.35); +} + + + +.cyan .btn:hover { + background: rgba(34, 211, 238, 0.1); + border-color: #22d3ee; + color: #fff; +} diff --git a/src/modules/HomePage/components/BannerSlide/BannerSlide.tsx b/src/modules/HomePage/components/BannerSlide/BannerSlide.tsx new file mode 100644 index 00000000000..39c08723e39 --- /dev/null +++ b/src/modules/HomePage/components/BannerSlide/BannerSlide.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import s from './BannerSlide.module.scss'; + +export interface BannerSlideData { + badge: string; + headlineAccent: string; + headlineRest: string; + sub: string; + btnText: string; + btnHref?: string; + productTitle: string; + productSub: string; + imgSrc: string; + imgAlt: string; + theme: 'purple' | 'cyan' | 'orange'; +} + +export const bannerSlides: BannerSlideData[] = [ + { + badge: 'New arrival', + headlineAccent: 'Now available!', + headlineRest: 'In our store!', + sub: 'Be the first to experience it', + btnText: 'Order now', + btnHref: '#', + productTitle: 'iPhone 17 Pro', + productSub: 'Pro. Beyond.', + imgSrc: './img/iphone17.png', + imgAlt: 'iPhone 17 Pro', + theme: 'orange', + }, + { + badge: 'Best seller', + headlineAccent: 'Pure sound.', + headlineRest: 'Zero limits.', + sub: 'Adaptive Noise Cancellation', + btnText: 'Shop now', + btnHref: '#', + productTitle: 'AirPods Pro', + productSub: 'Hear everything. Filter the rest.', + imgSrc: './img/airpods.png', + imgAlt: 'AirPods Pro', + theme: 'cyan', + }, + { + badge: 'Limited offer', + headlineAccent: 'Your health.', + headlineRest: 'On your wrist.', + sub: 'Advanced health sensors, all day', + btnText: 'Explore', + btnHref: '#', + productTitle: 'Apple Watch Series 9', + productSub: 'Smarter. Faster. Healthier.', + imgSrc: './img/apple-watch.png', + imgAlt: 'Apple Watch Series 9', + theme: 'purple', + }, +]; + +interface BannerSlideProps { + data: BannerSlideData; +} + +const BannerSlide: React.FC = ({ data }) => { + const { + badge, + headlineAccent, + headlineRest, + sub, + btnText, + btnHref, + productTitle, + productSub, + imgSrc, + imgAlt, + theme, + } = data; + + return ( +
+
+
+ +
+ {badge} +
+
{headlineAccent}
+ {headlineRest} +
+

{sub}

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

Shop by category

+ +
+
+ + category-phone.png + + + Mobile phones + +
95 models
+
+ +
+ + + + + Tablets + +
24 models
+
+ +
+ + + + + Accessories + +
100 models
+
+
+
+ ); +}; diff --git a/src/modules/HomePage/components/Categories/index.ts b/src/modules/HomePage/components/Categories/index.ts new file mode 100644 index 00000000000..79c7c7dcde7 --- /dev/null +++ b/src/modules/HomePage/components/Categories/index.ts @@ -0,0 +1 @@ +export * from './Categories'; diff --git a/src/modules/HomePage/index.ts b/src/modules/HomePage/index.ts new file mode 100644 index 00000000000..11e53da674c --- /dev/null +++ b/src/modules/HomePage/index.ts @@ -0,0 +1 @@ +export * from './HomePage'; diff --git a/src/modules/NotFoundPage/NotFoundPage.module.scss b/src/modules/NotFoundPage/NotFoundPage.module.scss new file mode 100644 index 00000000000..e8bf29ba62e --- /dev/null +++ b/src/modules/NotFoundPage/NotFoundPage.module.scss @@ -0,0 +1,8 @@ +.notFoundPage { + + &__img { + margin-inline: auto; + display: flex; + width: 50%; + } +} diff --git a/src/modules/NotFoundPage/NotFoundPage.tsx b/src/modules/NotFoundPage/NotFoundPage.tsx new file mode 100644 index 00000000000..c77eb6dafa7 --- /dev/null +++ b/src/modules/NotFoundPage/NotFoundPage.tsx @@ -0,0 +1,13 @@ +import s from './NotFoundPage.module.scss'; + +export const NotFoundPage = () => { + return ( +
+ page-not-found +
+ ); +}; diff --git a/src/modules/NotFoundPage/index.ts b/src/modules/NotFoundPage/index.ts new file mode 100644 index 00000000000..6197aa75aa8 --- /dev/null +++ b/src/modules/NotFoundPage/index.ts @@ -0,0 +1 @@ +export * from './NotFoundPage'; diff --git a/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss b/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss new file mode 100644 index 00000000000..1f58f9707e6 --- /dev/null +++ b/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss @@ -0,0 +1,15 @@ +@import './../../styles/mixins'; + +.productDetailsPage { + &__slider { + margin-block: 56px; + + @include on-tablet { + margin-block: 64px; + } + + @include on-desktop { + margin-block: 80px; + } + } +} diff --git a/src/modules/ProductDetailsPage/ProductDetailsPage.tsx b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx new file mode 100644 index 00000000000..6c178ccf331 --- /dev/null +++ b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx @@ -0,0 +1,88 @@ +import { useParams } from 'react-router-dom'; +import { Breadcrumbs } from '../shared/Breadcrumbs'; +import { useEffect, useMemo, useState } from 'react'; +import { getProducts } from '../../api'; +import type { ProductFull } from '../../types/ProductFull'; +import { Back } from '../shared/Back'; +import { ProductDetails } from './components/ProductDetails'; +import type { Product } from '../../types/Product'; +import { CardsSlider } from '../shared/CardsSlider'; +import s from './ProductDetailsPage.module.scss'; + +type Params = { + productId?: string; +}; + +export const ProductDetailsPage = () => { + const [detailedProducts, setDetailedProducts] = useState([]); + const [catalogProducts, setCatalogProducts] = useState([]); + const [loading, setLoading] = useState(true); + const { productId } = useParams(); + + // if (!productId) { + // return; + // } + + useEffect(() => { + setLoading(true); + + Promise.all([ + getProducts('phones'), + getProducts('tablets'), + getProducts('accessories'), + ]) + .then(([x, y, z]) => setDetailedProducts([...x, ...y, ...z])) + .catch(e => new Error(e)) + .finally(() => setLoading(false)); + + getProducts('products').then(setCatalogProducts); + }, []); + + const searchProduct = ( + namespaceId: string, + color: string, + capacity: string, + ): string | undefined => { + return detailedProducts.find( + item => + item.namespaceId === namespaceId && + item.color === color && + item.capacity === capacity, + )?.id; + }; + + const detailedProduct = useMemo(() => { + return detailedProducts.find(product => product.id === productId); + }, [detailedProducts, productId]); + + const catalogProduct = useMemo(() => { + return catalogProducts.find(product => product.itemId === productId); + }, [catalogProducts, productId]); + + return ( + <> + {loading &&
Loading...
} + + {!loading && detailedProduct && ( +
+ + + +
+ Math.random() - 0.5)} + name="You may also like" + /> +
+
+ )} + + ); +}; diff --git a/src/modules/ProductDetailsPage/components/About/About.module.scss b/src/modules/ProductDetailsPage/components/About/About.module.scss new file mode 100644 index 00000000000..0de88bcd40b --- /dev/null +++ b/src/modules/ProductDetailsPage/components/About/About.module.scss @@ -0,0 +1,44 @@ +@import './../../../../styles/typography'; + +.about { + &__title { + margin-block: 0 32px; + padding-bottom: 16px; + box-sizing: border-box; + border-bottom: 1px solid var(--color-elements); + + @include h3; + } + + &__list { + display: flex; + flex-direction: column; + gap: 32px; + } + + &__item { + display: flex; + flex-direction: column; + gap: 16px; + } + + &__itemTitle { + margin: 0; + color: var(--color-primary-dark); + + @include h4; + } + + &__texts { + display: flex; + flex-direction: column; + gap: 16px; + color: var(--color-secondary); + + @include body-text; + } + + &__text { + margin: 0; + } +} diff --git a/src/modules/ProductDetailsPage/components/About/About.tsx b/src/modules/ProductDetailsPage/components/About/About.tsx new file mode 100644 index 00000000000..40ee0d6c73f --- /dev/null +++ b/src/modules/ProductDetailsPage/components/About/About.tsx @@ -0,0 +1,29 @@ +import type { ProductFullDescription } from 'types/ProductFullDescription'; +import s from './About.module.scss'; + +type Props = { + description: ProductFullDescription[]; +}; + +export const About = ({ description }: Props) => { + return ( +
+

About

+ +
+ {description.map((item, index) => ( +
+

{item.title}

+
+ {item.text.map(t => ( +

+ {t} +

+ ))} +
+
+ ))} +
+
+ ); +}; diff --git a/src/modules/ProductDetailsPage/components/About/index.ts b/src/modules/ProductDetailsPage/components/About/index.ts new file mode 100644 index 00000000000..da0f79ebaaa --- /dev/null +++ b/src/modules/ProductDetailsPage/components/About/index.ts @@ -0,0 +1 @@ +export * from './About'; diff --git a/src/modules/ProductDetailsPage/components/MainControls/MainControls.module.scss b/src/modules/ProductDetailsPage/components/MainControls/MainControls.module.scss new file mode 100644 index 00000000000..89038a92765 --- /dev/null +++ b/src/modules/ProductDetailsPage/components/MainControls/MainControls.module.scss @@ -0,0 +1,96 @@ +@import './../../../../styles/typography'; + +.controls { + &__title { + color: var(--color-secondary); + margin-block: 0 8px; + + @include small-text; + } + + &__colorsBlock { + margin-bottom: 24px; + } + + &__colors { + display: flex; + gap: 8px; + } + + &__colorButton { + height: 32px; + width: 32px; + border-radius: 50%; + box-sizing: border-box; + border: 1px solid var(--color-elements); + cursor: pointer; + + &--active { + border: 1px solid var(--color-primary-dark); + } + } + + &__color { + height: 100%; + width: 100%; + border-radius: 50%; + box-sizing: border-box; + border: 2px solid white; + } + + &__capacitiesBlock { + margin-block: 24px; + } + + &__capacities { + display: flex; + gap: 8px; + } + + &__capacity { + display: flex; + align-items: center; + height: 32px; + padding-inline: 8px; + box-sizing: border-box; + border: 1px solid var(--color-elements); + cursor: pointer; + + &--active { + background-color: var(--color-primary-dark); + color: white; + border: 1px solid var(--color-primary-dark); + } + } + + &__prises { + display: flex; + gap: 8px; + margin-block: 32px 16px; + } + + &__priceDiscount { + color: var(--color-primary-dark); + font-weight: 800; + font-size: 32px; + line-height: 41px; + letter-spacing: -1%; + } + + &__priceRegular { + color: var(--color-secondary); + display: flex; + align-items: center; + font-weight: 500; + font-size: 22px; + line-height: 100%; + letter-spacing: 0%; + text-decoration: line-through; + } + + &__buttons { + display: flex; + gap: 8px; + margin-bottom: 32px; + } +} diff --git a/src/modules/ProductDetailsPage/components/MainControls/MainControls.tsx b/src/modules/ProductDetailsPage/components/MainControls/MainControls.tsx new file mode 100644 index 00000000000..9b96d3e642f --- /dev/null +++ b/src/modules/ProductDetailsPage/components/MainControls/MainControls.tsx @@ -0,0 +1,161 @@ +import classNames from 'classnames'; +import s from './MainControls.module.scss'; +import { PrimaryButton } from '../../../shared/PrimaryButton'; +import { AddToFovouritesButton } from '../../../shared/AddToFovouritesButton'; +import { TechSpecsList } from '../TechSpecsList'; +import type { ProductFull } from '../../../../types/ProductFull'; +import { colors } from '../../../../utils/colors'; +import { Line } from '../../../shared/Line'; +import { useNavigate } from 'react-router-dom'; +import { useContext } from 'react'; +import { CartContext } from '../../../../CartContext'; +import { FavouritesContext } from '../../../../FavouritesContext'; +import type { Product } from '../../../../types/Product'; + +type Props = { + product: ProductFull; + searchProduct: ( + namespaceId: string, + color: string, + capacity: string, + ) => string | undefined; + catalogProduct: Product | undefined; +}; + +export const MainControls = ({ + product, + searchProduct, + catalogProduct, +}: Props) => { + const navigate = useNavigate(); + const { cart, setCart } = useContext(CartContext); + const { favourites, setFavourites } = useContext(FavouritesContext); + const { + namespaceId, + colorsAvailable, + color, + capacityAvailable, + capacity, + priceDiscount, + priceRegular, + screen, + resolution, + processor, + ram, + } = product; + + if (catalogProduct === undefined) { + return; + } + + const isInCart = (id: string) => { + return !!cart.find(item => item.id === id); + }; + + const isFavourites = (id: string) => { + return !!favourites.find(item => item.itemId === id); + }; + + return ( +
+
+

Available colors

+
+ {colorsAvailable.map(col => ( +
{ + const productId = searchProduct(namespaceId, col, capacity); + + if (productId) { + navigate(`/product/${productId}`, { replace: true }); + } + }} + > +
+
+ ))} +
+
+ + + +
+

Select capacity

+
+ {capacityAvailable.map(cap => ( +
{ + const productId = searchProduct(namespaceId, color, cap); + + if (productId) { + navigate(`/product/${productId}`, { replace: true }); + } + }} + > + {cap} +
+ ))} +
+
+ + + +
+
${priceDiscount}
+
${priceRegular}
+
+
+ {isInCart(catalogProduct.itemId) ? ( + Added to cart + ) : ( + + setCart(prev => [ + ...prev, + { + id: catalogProduct.itemId, + quantity: 1, + product: catalogProduct, + }, + ]) + } + > + Add to cart + + )} + + setFavourites(prev => + isFavourites(catalogProduct.itemId) + ? prev.filter(item => item.itemId !== catalogProduct.itemId) + : [...prev, catalogProduct], + ) + } + /> +
+
+ +
+
+ ); +}; diff --git a/src/modules/ProductDetailsPage/components/MainControls/index.ts b/src/modules/ProductDetailsPage/components/MainControls/index.ts new file mode 100644 index 00000000000..0444e1d1933 --- /dev/null +++ b/src/modules/ProductDetailsPage/components/MainControls/index.ts @@ -0,0 +1 @@ +export * from './MainControls'; diff --git a/src/modules/ProductDetailsPage/components/Photo/Photo.module.scss b/src/modules/ProductDetailsPage/components/Photo/Photo.module.scss new file mode 100644 index 00000000000..3b9fc2e2a1a --- /dev/null +++ b/src/modules/ProductDetailsPage/components/Photo/Photo.module.scss @@ -0,0 +1,15 @@ +.photo { + width: 100%; + aspect-ratio: 1 / 1; + + + &__img { + max-width: 100%; + max-height: 100%; + aspect-ratio: 1 / 1; + + width: 100%; + height: 100%; + object-fit: contain; + } +} diff --git a/src/modules/ProductDetailsPage/components/Photo/Photo.tsx b/src/modules/ProductDetailsPage/components/Photo/Photo.tsx new file mode 100644 index 00000000000..798180a9fff --- /dev/null +++ b/src/modules/ProductDetailsPage/components/Photo/Photo.tsx @@ -0,0 +1,51 @@ +import React, { useEffect } from 'react'; +import { Swiper, SwiperSlide } from 'swiper/react'; +import type { Swiper as SwiperType } from 'swiper'; +import s from './Photo.module.scss'; + +type Props = { + images: string[]; + swiperRef: React.MutableRefObject; + activeImg: string; + onActiveImg: (img: string, index: number) => void; +}; + +export const Photo = ({ images, swiperRef, activeImg, onActiveImg }: Props) => { + useEffect(() => { + const activeIndex = images.indexOf(activeImg); + + if ( + activeIndex !== -1 && + swiperRef.current && + swiperRef.current.realIndex !== activeIndex + ) { + swiperRef.current.slideToLoop(activeIndex); + } + }, [activeImg, images, swiperRef]); // ✅ добавлен swiperRef + + return ( +
+ { + const ref = swiperRef; + + ref.current = swiper; + }} + onSlideChange={swiper => { + if (images[swiper.realIndex] !== activeImg) { + onActiveImg(images[swiper.realIndex], swiper.realIndex); + } + }} + > + {images.map(img => ( + + + + ))} + +
+ ); +}; diff --git a/src/modules/ProductDetailsPage/components/Photo/index.ts b/src/modules/ProductDetailsPage/components/Photo/index.ts new file mode 100644 index 00000000000..fd6cc4f3767 --- /dev/null +++ b/src/modules/ProductDetailsPage/components/Photo/index.ts @@ -0,0 +1 @@ +export * from './Photo'; diff --git a/src/modules/ProductDetailsPage/components/PhotoPreviews/PhotoPreviews.module.scss b/src/modules/ProductDetailsPage/components/PhotoPreviews/PhotoPreviews.module.scss new file mode 100644 index 00000000000..997273496cd --- /dev/null +++ b/src/modules/ProductDetailsPage/components/PhotoPreviews/PhotoPreviews.module.scss @@ -0,0 +1,29 @@ +@import './../../../../styles/mixins'; + +.images { + display: flex; + gap: 8px; + width: 100%; + + @include on-tablet { + flex-direction: column; + } + + &__item { + max-width: calc((100% - 32px) / 5); + max-height: 100%; + box-sizing: border-box; + padding: 4px; + border: 1px solid var(--color-elements); + aspect-ratio: 1 / 1; + object-fit: contain; + + @include on-tablet { + max-width: 100%; + } + + &--active { + border: 1px solid var(--color-primary-dark); + } + } +} diff --git a/src/modules/ProductDetailsPage/components/PhotoPreviews/PhotoPreviews.tsx b/src/modules/ProductDetailsPage/components/PhotoPreviews/PhotoPreviews.tsx new file mode 100644 index 00000000000..f922d8e80f6 --- /dev/null +++ b/src/modules/ProductDetailsPage/components/PhotoPreviews/PhotoPreviews.tsx @@ -0,0 +1,29 @@ +import classNames from 'classnames'; +import s from './PhotoPreviews.module.scss'; + +type Props = { + images: string[]; + activeImg: string; + onActiveImg: (img: string, index: number) => void; +}; + +export const PhotoPreviews = ({ images, activeImg, onActiveImg }: Props) => { + return ( +
+ {images.map((img, index) => ( + {img} onActiveImg(img, index)} + /> + ))} +
+ ); +}; diff --git a/src/modules/ProductDetailsPage/components/PhotoPreviews/index.ts b/src/modules/ProductDetailsPage/components/PhotoPreviews/index.ts new file mode 100644 index 00000000000..fe2420fc619 --- /dev/null +++ b/src/modules/ProductDetailsPage/components/PhotoPreviews/index.ts @@ -0,0 +1 @@ +export * from './PhotoPreviews'; diff --git a/src/modules/ProductDetailsPage/components/ProductDetails/ProductDetails.module.scss b/src/modules/ProductDetailsPage/components/ProductDetails/ProductDetails.module.scss new file mode 100644 index 00000000000..eee3e3c1d75 --- /dev/null +++ b/src/modules/ProductDetailsPage/components/ProductDetails/ProductDetails.module.scss @@ -0,0 +1,86 @@ +@import './../../../../styles/mixins'; +@import './../../../../styles/typography'; + +.product { + @include page-grid; + + &__title { + grid-column: 1 / -1; + margin-block: 16px 32px; + + @include h2; + + @include on-tablet { + margin-block: 16px 40px; + } + } + + &__photo { + grid-column: 1 / -1; + display: flex; + align-items: center; + + @include on-tablet { + grid-column: 2 / 8; + grid-row: 2 / 3; + } + + @include on-desktop { + grid-column: 3 / 13; + } + } + + &__photoPreviews { + grid-column: 1 / -1; + margin-top: 16px; + + @include on-tablet { + grid-column: 1 / 2; + margin-top: 0; + } + + @include on-desktop { + grid-column: 1 / 3; + } + } + + &__mainControls { + grid-column: 1 / -1; + + @include on-tablet { + grid-column: 8 / -1; + } + + @include on-desktop { + grid-column: 14 / 21; + } + } + + &__about { + grid-column: 1 / -1; + margin-top: 56px; + + @include on-tablet { + margin-top: 64px; + } + + @include on-desktop { + grid-column: 1 / 13; + margin-top: 80px; + } + } + + &__techSpecs { + grid-column: 1 / -1; + margin-top: 56px; + + @include on-tablet { + margin-top: 64px; + } + + @include on-desktop { + grid-column: 14 / -1; + margin-top: 80px; + } + } +} diff --git a/src/modules/ProductDetailsPage/components/ProductDetails/ProductDetails.tsx b/src/modules/ProductDetailsPage/components/ProductDetails/ProductDetails.tsx new file mode 100644 index 00000000000..9451b40f71d --- /dev/null +++ b/src/modules/ProductDetailsPage/components/ProductDetails/ProductDetails.tsx @@ -0,0 +1,69 @@ +import { useRef, useState } from 'react'; +import type { ProductFull } from '../../../../types/ProductFull'; +import { About } from '../About'; +import { MainControls } from '../MainControls'; +import { Photo } from '../Photo'; +import { PhotoPreviews } from '../PhotoPreviews'; +import { TechSpecs } from '../TechSpecs'; +import s from './ProductDetails.module.scss'; +import { Swiper as SwiperType } from 'swiper'; +import type { Product } from '../../../../types/Product'; + +type Props = { + product: ProductFull; + searchProduct: ( + namespaceId: string, + color: string, + capacity: string, + ) => string | undefined; + catalogProduct: Product | undefined; +}; + +export const ProductDetails = ({ + product, + searchProduct, + catalogProduct, +}: Props) => { + const { name, images, description } = product; + const swiperRef = useRef(null); + const [activeImg, setActiveImg] = useState(images[0]); + + const handleThumbnailClick = (img: string, index: number) => { + setActiveImg(img); + swiperRef.current?.slideTo(index); + }; + + return ( +
+

{name}

+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ ); +}; diff --git a/src/modules/ProductDetailsPage/components/ProductDetails/index.ts b/src/modules/ProductDetailsPage/components/ProductDetails/index.ts new file mode 100644 index 00000000000..8812622b6c9 --- /dev/null +++ b/src/modules/ProductDetailsPage/components/ProductDetails/index.ts @@ -0,0 +1 @@ +export * from './ProductDetails'; diff --git a/src/modules/ProductDetailsPage/components/TechSpecs/TechSpecs.module.scss b/src/modules/ProductDetailsPage/components/TechSpecs/TechSpecs.module.scss new file mode 100644 index 00000000000..2d1e6275d9f --- /dev/null +++ b/src/modules/ProductDetailsPage/components/TechSpecs/TechSpecs.module.scss @@ -0,0 +1,18 @@ +@import './../../../../styles/mixins'; +@import './../../../../styles/typography'; + +.specs { + &__title { + margin-block: 0 30px; + padding-bottom: 16px; + box-sizing: border-box; + border-bottom: 1px solid var(--color-elements); + + @include h3; + + @include on-tablet { + margin-block: 0 25px; + padding-bottom: 16px; + } + } +} diff --git a/src/modules/ProductDetailsPage/components/TechSpecs/TechSpecs.tsx b/src/modules/ProductDetailsPage/components/TechSpecs/TechSpecs.tsx new file mode 100644 index 00000000000..bcdbe7defed --- /dev/null +++ b/src/modules/ProductDetailsPage/components/TechSpecs/TechSpecs.tsx @@ -0,0 +1,30 @@ +import type { ProductFull } from '../../../../types/ProductFull'; +import { TechSpecsList } from '../TechSpecsList'; +import s from './TechSpecs.module.scss'; + +type Props = { + product: ProductFull; +}; + +export const TechSpecs = ({ product }: Props) => { + const { screen, resolution, processor, ram, capacity, camera, zoom, cell } = + product; + + return ( +
+

Tech specs

+ +
+ ); +}; diff --git a/src/modules/ProductDetailsPage/components/TechSpecs/index.ts b/src/modules/ProductDetailsPage/components/TechSpecs/index.ts new file mode 100644 index 00000000000..eada3132a08 --- /dev/null +++ b/src/modules/ProductDetailsPage/components/TechSpecs/index.ts @@ -0,0 +1 @@ +export * from './TechSpecs'; diff --git a/src/modules/ProductDetailsPage/components/TechSpecsList/TechSpecsList.module.scss b/src/modules/ProductDetailsPage/components/TechSpecsList/TechSpecsList.module.scss new file mode 100644 index 00000000000..49e7a23df07 --- /dev/null +++ b/src/modules/ProductDetailsPage/components/TechSpecsList/TechSpecsList.module.scss @@ -0,0 +1,24 @@ +@import './../../../../styles/typography'; + +.specsList { + display: flex; + flex-direction: column; + gap: 8px; + + &__item { + display: flex; + justify-content: space-between; + } + + &__label { + color: var(--color-secondary); + + @include body-text; + } + + &__value { + color: var(--color-primary-dark); + + @include body-text; + } +} diff --git a/src/modules/ProductDetailsPage/components/TechSpecsList/TechSpecsList.tsx b/src/modules/ProductDetailsPage/components/TechSpecsList/TechSpecsList.tsx new file mode 100644 index 00000000000..a60934ac470 --- /dev/null +++ b/src/modules/ProductDetailsPage/components/TechSpecsList/TechSpecsList.tsx @@ -0,0 +1,34 @@ +import type { ProductFull } from '../../../../types/ProductFull'; +import { capitalizeFirstLetter } from '../../../../utils/string'; +import s from './TechSpecsList.module.scss'; + +type Props = { + specs: Partial; +}; + +export const TechSpecsList = ({ specs }: Props) => { + return ( +
+ {Object.keys(specs).map(key => { + const typedKey = key as keyof ProductFull; + + if (!specs[typedKey]) { + return; + } + + return ( +
+
+ {capitalizeFirstLetter(typedKey)} +
+
+ {Array.isArray(specs[typedKey]) + ? specs[typedKey]?.join(', ') + : specs[typedKey]} +
+
+ ); + })} +
+ ); +}; diff --git a/src/modules/ProductDetailsPage/components/TechSpecsList/index.ts b/src/modules/ProductDetailsPage/components/TechSpecsList/index.ts new file mode 100644 index 00000000000..9f088dd330d --- /dev/null +++ b/src/modules/ProductDetailsPage/components/TechSpecsList/index.ts @@ -0,0 +1 @@ +export * from './TechSpecsList'; diff --git a/src/modules/ProductDetailsPage/index.ts b/src/modules/ProductDetailsPage/index.ts new file mode 100644 index 00000000000..6615089e5ec --- /dev/null +++ b/src/modules/ProductDetailsPage/index.ts @@ -0,0 +1 @@ +export * from './ProductDetailsPage'; diff --git a/src/modules/shared/AddToFovouritesButton/AddToFovouritesButton.module.scss b/src/modules/shared/AddToFovouritesButton/AddToFovouritesButton.module.scss new file mode 100644 index 00000000000..0165e2b8201 --- /dev/null +++ b/src/modules/shared/AddToFovouritesButton/AddToFovouritesButton.module.scss @@ -0,0 +1,20 @@ +.button { + all: unset; + display: inline-block; + height: 40px; + width: 40px; + background-image: url('/img/icons/favourites.svg'); + background-repeat: no-repeat; + background-position: center; + border: 1px solid var(--color-icons); + box-sizing: border-box; + cursor: pointer; + + &:hover { + border: 1px solid var(--color-primary-dark); + } + + &--selected { + background-image: url('/img/icons/favourites-filled.svg'); + } +} diff --git a/src/modules/shared/AddToFovouritesButton/AddToFovouritesButton.tsx b/src/modules/shared/AddToFovouritesButton/AddToFovouritesButton.tsx new file mode 100644 index 00000000000..3eb2adebedd --- /dev/null +++ b/src/modules/shared/AddToFovouritesButton/AddToFovouritesButton.tsx @@ -0,0 +1,21 @@ +import classNames from 'classnames'; +import s from './AddToFovouritesButton.module.scss'; + +type Props = { + selected?: boolean; + onClick?: () => void; +}; + +export const AddToFovouritesButton = ({ + selected = false, + onClick = () => {}, +}: Props) => { + return ( + + ); +}; diff --git a/src/modules/shared/AddToFovouritesButton/index.ts b/src/modules/shared/AddToFovouritesButton/index.ts new file mode 100644 index 00000000000..b38eecd3c57 --- /dev/null +++ b/src/modules/shared/AddToFovouritesButton/index.ts @@ -0,0 +1 @@ +export * from './AddToFovouritesButton'; diff --git a/src/modules/shared/Back/Back.module.scss b/src/modules/shared/Back/Back.module.scss new file mode 100644 index 00000000000..3a182e2ea15 --- /dev/null +++ b/src/modules/shared/Back/Back.module.scss @@ -0,0 +1,24 @@ +@import './../../../styles/typography'; + +.back { + all: unset; + display: flex; + gap: 8px; + color: var(--color-secondary); + margin-top: 24px; + align-items: center; + cursor: pointer; + + &__chevron { + height: 16px; + width: 16px; + margin-block: auto; + transform: rotate(180deg); + } + + &__title { + @include small-text; + + line-height: 12px; + } +} diff --git a/src/modules/shared/Back/Back.tsx b/src/modules/shared/Back/Back.tsx new file mode 100644 index 00000000000..043d9641795 --- /dev/null +++ b/src/modules/shared/Back/Back.tsx @@ -0,0 +1,13 @@ +import { useNavigate } from 'react-router-dom'; +import s from './Back.module.scss'; + +export const Back = () => { + const navigate = useNavigate(); + + return ( + + ); +}; diff --git a/src/modules/shared/Back/index.ts b/src/modules/shared/Back/index.ts new file mode 100644 index 00000000000..c4e96e28419 --- /dev/null +++ b/src/modules/shared/Back/index.ts @@ -0,0 +1 @@ +export * from './Back'; diff --git a/src/modules/shared/Breadcrumbs/Breadcrumbs.module.scss b/src/modules/shared/Breadcrumbs/Breadcrumbs.module.scss new file mode 100644 index 00000000000..2dac169b928 --- /dev/null +++ b/src/modules/shared/Breadcrumbs/Breadcrumbs.module.scss @@ -0,0 +1,58 @@ +.breadcrumbs { + margin-top: 24px; + + &__home { + display: flex; + margin-block: auto; + } + + &__list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + gap: 8px; + width: 100%; + } + + &__item { + display: flex; + gap: 8px; + } + + &__chevron { + display: flex; + margin-block: auto; + height: 16px; + width: 16px; + } + + &__itemLink { + display: flex; + text-decoration: none; + color: var(--color-secondary); + font-weight: 600; + font-size: 12px; + + &:hover { + color: var(--color-primary-dark); + } + } + + &__itemName { + display: flex; + gap: 8px; + overflow: hidden; + flex: 1; + } + + &__name { + display: inline; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--color-primary-dark); + font-weight: 600; + font-size: 12px; + } +} diff --git a/src/modules/shared/Breadcrumbs/Breadcrumbs.tsx b/src/modules/shared/Breadcrumbs/Breadcrumbs.tsx new file mode 100644 index 00000000000..3b1d1b40d2a --- /dev/null +++ b/src/modules/shared/Breadcrumbs/Breadcrumbs.tsx @@ -0,0 +1,42 @@ +import { Link } from 'react-router-dom'; +import s from './Breadcrumbs.module.scss'; +import { capitalizeFirstLetter } from '../../../utils/string'; + +type Props = { + type: string; + name?: string; +}; + +export const Breadcrumbs = ({ type, name }: Props) => { + return ( + + ); +}; diff --git a/src/modules/shared/Breadcrumbs/index.ts b/src/modules/shared/Breadcrumbs/index.ts new file mode 100644 index 00000000000..ce977548b14 --- /dev/null +++ b/src/modules/shared/Breadcrumbs/index.ts @@ -0,0 +1 @@ +export * from './Breadcrumbs'; diff --git a/src/modules/shared/Card/Card.module.scss b/src/modules/shared/Card/Card.module.scss new file mode 100644 index 00000000000..b3f09eb9798 --- /dev/null +++ b/src/modules/shared/Card/Card.module.scss @@ -0,0 +1,124 @@ +@import './../../../styles/mixins'; +@import './../../../styles/typography'; + +.card { + box-sizing: border-box; + border: 1px solid var(--color-elements); + width: 100%; + display: flex; + flex-direction: column; + height: 100%; + + &--grid { + grid-column: span 4; + + @media (min-width: 500px) { + grid-column: span 2; + } + + @include on-tablet { + grid-column: span 6; + } + + @media (min-width: 800px) { + grid-column: span 4; + } + + @include on-desktop { + grid-column: span 6; + } + } + + &:hover { + box-shadow: 0 2px 16px 0 #0000001a; + } + + &__content { + margin: 32px; + display: flex; + flex-direction: column; + gap: 8px; + min-height: 0; + flex: 1; + } + + &__imgLink { + flex: 1; + display: flex; + align-items: center; + } + + &__img { + max-width: 100%; + max-height: 100%; + aspect-ratio: 1 / 1; + + object-fit: contain; + } + + &__name { + text-decoration: none; + color: var(--color-primary-dark); + margin-top: 16px; + + @include body-text; + } + + &__prices { + display: flex; + gap: 8px; + } + + &__price { + color: var(--color-primary-dark); + font-weight: 800; + font-size: 22px; + line-height: 140%; + letter-spacing: 0%; + } + + &__fullPrice { + color: var(--color-secondary); + font-weight: 500; + font-size: 22px; + line-height: 140%; + text-decoration: line-through; + } + + &__divider { + height: 1px; + background-color: var(--color-elements); + width: 100%; + } + + &__specs { + display: flex; + justify-content: space-between; + flex-direction: column; + gap: 8px; + margin-block: 8px; + } + + &__spec { + display: flex; + justify-content: space-between; + flex-direction: row; + } + + &__label { + color: var(--color-secondary); + + @include small-text; + } + + &__value { + color: var(--color-primary-dark); + font-weight: 600; + font-size: 12px; + } + + &__buttons { + display: flex; + gap: 8px; + } +} diff --git a/src/modules/shared/Card/Card.tsx b/src/modules/shared/Card/Card.tsx new file mode 100644 index 00000000000..113e34a689a --- /dev/null +++ b/src/modules/shared/Card/Card.tsx @@ -0,0 +1,103 @@ +import { Link } from 'react-router-dom'; +import s from './Card.module.scss'; +import type { Product } from '../../../types/Product'; +import { PrimaryButton } from '../PrimaryButton'; +import { AddToFovouritesButton } from '../AddToFovouritesButton'; +import classNames from 'classnames'; +import { useContext } from 'react'; +import { CartContext } from '../../../CartContext'; +import { FavouritesContext } from '../../../FavouritesContext'; + +type Props = { + product: Product; + grid?: boolean; + withoutDiscount?: boolean; +}; + +export const Card = ({ + product, + grid = false, + withoutDiscount = false, +}: Props) => { + const { cart, setCart } = useContext(CartContext); + const { favourites, setFavourites } = useContext(FavouritesContext); + + const isInCart = (id: string) => { + return !!cart.find(item => item.id === id); + }; + + const isFavourites = (id: string) => { + return !!favourites.find(item => item.itemId === id); + }; + + return ( +
+
+ + image + + + + {product.name} + + +
+
+ ${withoutDiscount ? product.fullPrice : product.price} +
+
+ {!withoutDiscount && '$' + product.fullPrice} +
+
+ +
+ +
+
+
Screen
+
{product.screen}
+
+
+
Capacity
+
{product.capacity}
+
+
+
RAM
+
{product.ram}
+
+
+ +
+ {isInCart(product.itemId) ? ( + Added to cart + ) : ( + + setCart(prev => [ + ...prev, + { id: product.itemId, quantity: 1, product: product }, + ]) + } + > + Add to cart + + )} + + setFavourites(prev => + isFavourites(product.itemId) + ? [...prev].filter(item => item.itemId !== product.itemId) + : [...prev, product], + ) + } + /> +
+
+
+ ); +}; diff --git a/src/modules/shared/Card/index.ts b/src/modules/shared/Card/index.ts new file mode 100644 index 00000000000..ca0b060473a --- /dev/null +++ b/src/modules/shared/Card/index.ts @@ -0,0 +1 @@ +export * from './Card'; diff --git a/src/modules/shared/CardList/CardList.module.scss b/src/modules/shared/CardList/CardList.module.scss new file mode 100644 index 00000000000..14e77537c5e --- /dev/null +++ b/src/modules/shared/CardList/CardList.module.scss @@ -0,0 +1,7 @@ +@import './../../../styles/mixins'; + +.card-list { + @include page-grid; + + row-gap: 40px; +} diff --git a/src/modules/shared/CardList/CardList.tsx b/src/modules/shared/CardList/CardList.tsx new file mode 100644 index 00000000000..d930e26915d --- /dev/null +++ b/src/modules/shared/CardList/CardList.tsx @@ -0,0 +1,17 @@ +import type { Product } from '../../../types/Product'; +import { Card } from '../Card/Card'; +import s from './CardList.module.scss'; + +type Props = { + products: Product[]; +}; + +export const CardList = ({ products }: Props) => { + return ( +
+ {products.map(product => ( + + ))} +
+ ); +}; diff --git a/src/modules/shared/CardList/index.ts b/src/modules/shared/CardList/index.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/modules/shared/CardsSlider/CardsSlider.module.scss b/src/modules/shared/CardsSlider/CardsSlider.module.scss new file mode 100644 index 00000000000..3b99fc8786c --- /dev/null +++ b/src/modules/shared/CardsSlider/CardsSlider.module.scss @@ -0,0 +1,73 @@ +@import './../../../styles/mixins'; +@import './../../../styles/typography'; + +.cardsSlider { + display: flex; + flex-direction: column; + gap: 24px; + + &__top { + display: flex; + justify-content: space-between; + } + + &__name { + margin-block: 0; + color: var(--color-primary-dark); + + @include h2; + } + + &__buttons { + display: flex; + gap: 16px; + } + + &__list { + overflow: hidden; + width: calc(100%); + padding: 16px; + margin: -16px; + + :global { + .swiper-wrapper { + display: flex; + align-items: stretch; // 👈 ключевая строка + } + + .swiper-slide { + height: auto !important; + display: flex; + } + } + + @include on-tablet { + padding: 24px; + margin: -24px; + } + + @include on-desktop { + padding: 10px; + margin: -10px; + } + + // &__swiper { + // :global { + // .swiper-wrapper { + // display: flex; + // align-items: stretch; // 👈 ключевая строка + // } + + // .swiper-slide { + // height: 0 !important; + // display: flex; + // } + // } + // } + + &__slide { + height: auto !important; + display: flex; + } + } +} diff --git a/src/modules/shared/CardsSlider/CardsSlider.tsx b/src/modules/shared/CardsSlider/CardsSlider.tsx new file mode 100644 index 00000000000..fde77204dcb --- /dev/null +++ b/src/modules/shared/CardsSlider/CardsSlider.tsx @@ -0,0 +1,69 @@ +import { SliderButton } from '../SliderButton'; +import s from './CardsSlider.module.scss'; +import { Swiper } from 'swiper/react'; +import type { SwiperRef } from 'swiper/react'; + +import { useRef, useState } from 'react'; +import 'swiper/css'; + +type Props = { + children: React.ReactNode; + name: string; +}; + +export const CardsSlider = ({ children, name }: Props) => { + const [isBeginning, setIsBeginning] = useState(true); + const [isEnd, setIsEnd] = useState(false); + const swiperRef = useRef(null); + + return ( +
+
+

{name}

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