diff --git a/index.html b/index.html index 095fb3a453..4fd713487e 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,8 @@ - Vite + React + TS + + Nice Gadgets
diff --git a/package-lock.json b/package-lock.json index 836b9e63b4..6e8f046bd3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@fortawesome/fontawesome-free": "^6.5.2", "bulma": "^1.0.1", "classnames": "^2.5.1", + "lodash.debounce": "^4.0.8", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.25.1", @@ -20,9 +21,10 @@ }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^1.9.12", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", + "@types/lodash.debounce": "^4.0.9", "@types/node": "^20.14.10", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", @@ -1184,10 +1186,11 @@ } }, "node_modules/@mate-academy/scripts": { - "version": "1.8.5", - "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.8.5.tgz", - "integrity": "sha512-mHRY2FkuoYCf5U0ahIukkaRo5LSZsxrTSgMJheFoyf3VXsTvfM9OfWcZIDIDB521kdPrScHHnRp+JRNjCfUO5A==", + "version": "1.9.12", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.9.12.tgz", + "integrity": "sha512-/OcmxMa34lYLFlGx7Ig926W1U1qjrnXbjFJ2TzUcDaLmED+A5se652NcWwGOidXRuMAOYLPU2jNYBEkKyXrFJA==", "dev": true, + "license": "MIT", "dependencies": { "@octokit/rest": "^17.11.2", "@types/get-port": "^4.2.0", @@ -2189,6 +2192,21 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/lodash": { + "version": "4.17.14", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.14.tgz", + "integrity": "sha512-jsxagdikDiDBeIRaPYtArcT8my4tN1og7MtMRquFT3XNA6axxyHDRUemqDz/taRDdOUn0GnGHRCuff4q48sW9A==", + "dev": true + }, + "node_modules/@types/lodash.debounce": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/lodash.debounce/-/lodash.debounce-4.0.9.tgz", + "integrity": "sha512-Ma5JcgTREwpLRwMM+XwBR7DaWe96nC38uCBDFKZWbNKD+osjVzdpnUSwBcqCptrp16sSOLBAUb50Car5I0TCsQ==", + "dev": true, + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/minimist": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", @@ -3163,9 +3181,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001642", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001642.tgz", - "integrity": "sha512-3XQ0DoRgLijXJErLSl+bLnJ+Et4KqV1PY6JJBGAFlsNsz31zeAIncyeZfLCabHK/jtSh+671RM9YMldxjUPZtA==", + "version": "1.0.30001690", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001690.tgz", + "integrity": "sha512-5ExiE3qQN6oF8Clf8ifIDcMRCRE/dMGcETG/XGMD8/XiXm6HXQgQTh1yZYLXXpSOsEUlJm1Xr7kGULZTuGtP/w==", "dev": true, "funding": [ { @@ -6802,6 +6820,11 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" + }, "node_modules/lodash.get": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", diff --git a/package.json b/package.json index ae251685c8..7ddef16909 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "@fortawesome/fontawesome-free": "^6.5.2", "bulma": "^1.0.1", "classnames": "^2.5.1", + "lodash.debounce": "^4.0.8", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.25.1", @@ -16,9 +17,10 @@ }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^1.9.12", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", + "@types/lodash.debounce": "^4.0.9", "@types/node": "^20.14.10", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000000..2cbc7a1c80 Binary files /dev/null and b/public/favicon.png differ diff --git a/public/icons/Close.svg b/public/icons/Close.svg new file mode 100644 index 0000000000..aadcc91fb1 --- /dev/null +++ b/public/icons/Close.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/Home.svg b/public/icons/Home.svg new file mode 100644 index 0000000000..474476cb02 --- /dev/null +++ b/public/icons/Home.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/icons/Menu.svg b/public/icons/Menu.svg new file mode 100644 index 0000000000..2c535f4586 --- /dev/null +++ b/public/icons/Menu.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icons/Minus.svg b/public/icons/Minus.svg new file mode 100644 index 0000000000..97c41038ac --- /dev/null +++ b/public/icons/Minus.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/Plus.svg b/public/icons/Plus.svg new file mode 100644 index 0000000000..ab3c34061b --- /dev/null +++ b/public/icons/Plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/arrow_down_dark.svg b/public/icons/arrow_down_dark.svg new file mode 100644 index 0000000000..63aabd6639 --- /dev/null +++ b/public/icons/arrow_down_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/arrow_left.svg b/public/icons/arrow_left.svg new file mode 100644 index 0000000000..ac494c7a55 --- /dev/null +++ b/public/icons/arrow_left.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/arrow_left__disabled.svg b/public/icons/arrow_left__disabled.svg new file mode 100644 index 0000000000..5093d9b94d --- /dev/null +++ b/public/icons/arrow_left__disabled.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/arrow_left_dark.svg b/public/icons/arrow_left_dark.svg new file mode 100644 index 0000000000..e2016da355 --- /dev/null +++ b/public/icons/arrow_left_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/arrow_left_dark__disabled.svg b/public/icons/arrow_left_dark__disabled.svg new file mode 100644 index 0000000000..983c07a6c3 --- /dev/null +++ b/public/icons/arrow_left_dark__disabled.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/arrow_right.svg b/public/icons/arrow_right.svg new file mode 100644 index 0000000000..b4f4687671 --- /dev/null +++ b/public/icons/arrow_right.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/arrow_right__disabled.svg b/public/icons/arrow_right__disabled.svg new file mode 100644 index 0000000000..a15457b9ad --- /dev/null +++ b/public/icons/arrow_right__disabled.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/arrow_right_dark.svg b/public/icons/arrow_right_dark.svg new file mode 100644 index 0000000000..3609b14bd6 --- /dev/null +++ b/public/icons/arrow_right_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/arrow_right_dark__disabled.svg b/public/icons/arrow_right_dark__disabled.svg new file mode 100644 index 0000000000..6e938ce245 --- /dev/null +++ b/public/icons/arrow_right_dark__disabled.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/close__disabled.svg b/public/icons/close__disabled.svg new file mode 100644 index 0000000000..61e7a40832 --- /dev/null +++ b/public/icons/close__disabled.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/close_dark.svg b/public/icons/close_dark.svg new file mode 100644 index 0000000000..925e5fce49 --- /dev/null +++ b/public/icons/close_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/favorites.svg b/public/icons/favorites.svg new file mode 100644 index 0000000000..ca57cfedd8 --- /dev/null +++ b/public/icons/favorites.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/favorites__filled.svg b/public/icons/favorites__filled.svg new file mode 100644 index 0000000000..c0a8e3d8b5 --- /dev/null +++ b/public/icons/favorites__filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/favorites_dark.svg b/public/icons/favorites_dark.svg new file mode 100644 index 0000000000..8fb5abef51 --- /dev/null +++ b/public/icons/favorites_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/home_dark.svg b/public/icons/home_dark.svg new file mode 100644 index 0000000000..e16ca7d794 --- /dev/null +++ b/public/icons/home_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/icons/menu_dark.svg b/public/icons/menu_dark.svg new file mode 100644 index 0000000000..c8c52c08a9 --- /dev/null +++ b/public/icons/menu_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icons/minus__disabled.svg b/public/icons/minus__disabled.svg new file mode 100644 index 0000000000..762e04664e --- /dev/null +++ b/public/icons/minus__disabled.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/search.svg b/public/icons/search.svg new file mode 100644 index 0000000000..801f11a548 --- /dev/null +++ b/public/icons/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/search_dark.svg b/public/icons/search_dark.svg new file mode 100644 index 0000000000..56a317c46f --- /dev/null +++ b/public/icons/search_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/icons/shopping_cart.svg b/public/icons/shopping_cart.svg new file mode 100644 index 0000000000..4b8ebce70e --- /dev/null +++ b/public/icons/shopping_cart.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icons/shopping_cart_dark.svg b/public/icons/shopping_cart_dark.svg new file mode 100644 index 0000000000..425ee63976 --- /dev/null +++ b/public/icons/shopping_cart_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/img/Banner Slider.png b/public/img/Banner Slider.png new file mode 100644 index 0000000000..77081cfbff Binary files /dev/null and b/public/img/Banner Slider.png differ diff --git a/public/img/Banner.png b/public/img/Banner.png new file mode 100644 index 0000000000..4feebeba2c Binary files /dev/null and b/public/img/Banner.png differ diff --git a/public/img/Category for accessories.png b/public/img/Category for accessories.png new file mode 100644 index 0000000000..3455908bb0 Binary files /dev/null and b/public/img/Category for accessories.png differ diff --git a/public/img/Category for phones.png b/public/img/Category for phones.png new file mode 100644 index 0000000000..7b0dd901aa Binary files /dev/null and b/public/img/Category for phones.png differ diff --git a/public/img/Category for tablets.png b/public/img/Category for tablets.png new file mode 100644 index 0000000000..d87f0d5417 Binary files /dev/null and b/public/img/Category for tablets.png differ diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 0000000000..5d40ab703e --- /dev/null +++ b/public/logo.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/logo_dark.svg b/public/logo_dark.svg new file mode 100644 index 0000000000..d59f941639 --- /dev/null +++ b/public/logo_dark.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App.scss b/src/App.scss index 71bc413aad..5f31bf26e7 100644 --- a/src/App.scss +++ b/src/App.scss @@ -1 +1,31 @@ -// not empty +@import '/src/styles/main'; + +* { + margin: 0; + padding: 0; +} + +.App { + display: flex; + flex-direction: column; + min-height: 100vh; + position: relative; + min-width: 320px; + + scroll-behavior: smooth; +} + +.App__content { + flex-grow: 1; +} + +.App__header { + position: sticky; + top: 0; + z-index: 10; +} + +body.theme_dark { + background-color: var(--color-bg); +} + diff --git a/src/App.tsx b/src/App.tsx index 372e4b4206..0e5be951c2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,38 @@ +import { Outlet, useLocation } from 'react-router-dom'; +import '../src/styles/main.scss'; import './App.scss'; +import { Footer } from './modules/shared/Footer'; +import { Header } from './modules/shared/Header'; +import { useEffect } from 'react'; +import { Menu } from './modules/shared/Menu'; -export const App = () => ( -
-

Product Catalog

-
-); +export const App = () => { + const location = useLocation(); + + useEffect(() => { + window.scrollTo({ + top: 0, + behavior: 'smooth', + }); + }, [location.pathname]); + + return ( +
+
+
+
+ +
+ +
+ +
+ +
+ +
+ ); +}; diff --git a/src/Root.tsx b/src/Root.tsx new file mode 100644 index 0000000000..068576b786 --- /dev/null +++ b/src/Root.tsx @@ -0,0 +1,47 @@ +import { Route, HashRouter as Router, Routes } from 'react-router-dom'; +import { App } from './App'; +import { HomePage } from './modules/HomePage'; +import { ProductPage } from './modules/ProductPage'; +import { GlobalProvider } from './store/GlobalContext'; +import { ProductDetailsPage } from './modules/ProductDetailsPage'; +import { ShoppingCartPage } from './modules/ShoppingCartPage'; +import { NotFoundPage } from './modules/NotFoundPage'; +import { FavoritesPage } from './modules/FavoritesPage'; + +export const Root = () => { + return ( + + + + }> + } /> + + + } /> + + } /> + + + + } /> + + } /> + + + + } /> + + } /> + + + } /> + + } /> + + } /> + + + + + ); +}; diff --git a/src/constants/colors.ts b/src/constants/colors.ts new file mode 100644 index 0000000000..95ea72ed74 --- /dev/null +++ b/src/constants/colors.ts @@ -0,0 +1,24 @@ +export type Colors = { + [key: string]: string; +}; + +export const colors: Colors = { + graphite: '#383838', + gold: '#FFD700', + sierrablue: '#6CABDD', + black: '#000000', + rosegold: '#B76E79', + silver: '#C0C0C0', + spacegray: '#4B4F54', + white: '#FFFFFF', + yellow: '#FFFF00', + red: '#FF0000', + coral: '#FF7F50', + midnight: '#2C3E50', + purple: '#800080', + spaceblack: '#1D1D1D', + blue: '#0000FF', + pink: '#FFC0CB', + green: '#008000', + midnightgreen: '#004953', +}; diff --git a/src/constants/iconsObject.ts b/src/constants/iconsObject.ts new file mode 100644 index 0000000000..271f582ddf --- /dev/null +++ b/src/constants/iconsObject.ts @@ -0,0 +1,94 @@ +export const iconsObject = { + menu: { + title: 'Menu icon', + path: './icons/menu.svg', + }, + menu_dark: { + title: 'Menu dark icon', + path: './icons/menu_dark.svg', + }, + favorites: { + title: 'Favorites icon', + path: './icons/favorites.svg', + }, + favorites_dark: { + title: 'Favorites dark icon', + path: './icons/favorites_dark.svg', + }, + favorites__filled: { + title: 'Favorites filled icon', + path: './icons/favorites__filled.svg', + }, + shopping_cart: { + title: 'Shopping cart icon', + path: './icons/shopping_cart.svg', + }, + shopping_cart_dark: { + title: 'Shopping cart dark icon', + path: './icons/shopping_cart_dark.svg', + }, + search: { + title: 'Search icon', + path: './icons/search.svg', + }, + search_dark: { + title: 'Search dark icon', + path: './icons/search_dark.svg', + }, + home: { + title: 'Home icon', + path: './icons/home.svg', + }, + home_dark: { + title: 'Home dark icon', + path: './icons/home_dark.svg', + }, + close: { + title: 'Close icon', + path: './icons/close.svg', + }, + close_dark: { + title: 'Close dark icon', + path: './icons/close_dark.svg', + }, + close__disabled: { + title: 'Close icon', + path: './icons/close__disabled.svg', + }, + arrow_left: { + title: 'Arrow left icon', + path: './icons/arrow_left.svg', + }, + arrow_left_dark: { + title: 'Arrow left dark icon', + path: './icons/arrow_left_dark.svg', + }, + arrow_left__disabled: { + title: 'Arrow left disabled icon', + path: './icons/arrow_left__disabled.svg', + }, + arrow_left_dark__disabled: { + title: 'Arrow left disabled icon', + path: './icons/arrow_left_dark__disabled.svg', + }, + arrow_right: { + title: 'Arrow right icon', + path: './icons/arrow_right.svg', + }, + arrow_right__disabled: { + title: 'Arrow right disabled icon', + path: './icons/arrow_right__disabled.svg', + }, + arrow_right_dark: { + title: 'Arrow right icon', + path: './icons/arrow_right_dark.svg', + }, + arrow_right_dark__disabled: { + title: 'Arrow right disabled icon', + path: './icons/arrow_right_dark__disabled.svg', + }, + arrow_down_dark: { + title: 'Arrow dow dark icon', + path: './icons/arrow_down_dark.svg', + }, +}; diff --git a/src/constants/navLinks.ts b/src/constants/navLinks.ts new file mode 100644 index 0000000000..0aab9fca91 --- /dev/null +++ b/src/constants/navLinks.ts @@ -0,0 +1,6 @@ +export const navLinks = [ + { title: 'Home', path: '/' }, + { title: 'Phones', path: '/phones' }, + { title: 'Tablets', path: '/tablets' }, + { title: 'Accessories', path: '/accessories' }, +]; diff --git a/src/hooks/useLocalStorage.tsx b/src/hooks/useLocalStorage.tsx new file mode 100644 index 0000000000..59baa36906 --- /dev/null +++ b/src/hooks/useLocalStorage.tsx @@ -0,0 +1,25 @@ +import { useEffect, useState } from 'react'; + +export function useLocalStorage(key: string, initialValue: T) { + const [value, setValue] = useState(() => { + try { + const item = localStorage.getItem(key); + + return item ? (JSON.parse(item) as T) : initialValue; + } catch (error) { + throw new Error(`Error reading localStorage key "${key}": ${error}`); + + return initialValue; + } + }); + + useEffect(() => { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch (error) { + throw new Error(`Error saving to localStorage key "${key}": ${error}`); + } + }, [key, value]); + + return [value, setValue] as const; +} diff --git a/src/hooks/useTheme.tsx b/src/hooks/useTheme.tsx new file mode 100644 index 0000000000..a38c0ecd63 --- /dev/null +++ b/src/hooks/useTheme.tsx @@ -0,0 +1,16 @@ +import { useEffect, useCallback } from 'react'; +import { useLocalStorage } from './useLocalStorage'; + +export const useTheme = () => { + const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light'); + + useEffect(() => { + document.body.classList.toggle('theme_dark', theme === 'dark'); + }, [theme]); + + const toggleTheme = useCallback(() => { + setTheme(theme === 'light' ? 'dark' : 'light'); + }, [theme, setTheme]); + + return { theme, toggleTheme }; +}; diff --git a/src/index.tsx b/src/index.tsx index 50470f1508..b0620ab4c6 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,4 @@ import { createRoot } from 'react-dom/client'; -import { App } from './App'; +import { Root } from './Root'; -createRoot(document.getElementById('root') as HTMLElement).render(); +createRoot(document.getElementById('root') as HTMLElement).render(); diff --git a/src/modules/FavoritesPage/FavoritesPage.scss b/src/modules/FavoritesPage/FavoritesPage.scss new file mode 100644 index 0000000000..432de035ed --- /dev/null +++ b/src/modules/FavoritesPage/FavoritesPage.scss @@ -0,0 +1,36 @@ +@import '../../styles/main'; + +.favoritesPage { + @include content-padding-inline; +} + +.favoritesPage__empty-content { + margin-top: 40px; + font-family: Mont-SemiBold, sans-serif; + font-size: 48px; + font-weight: 500; + color: var(--color-gray-secondary); + display: flex; + align-items: center; + justify-content: center; +} + +.favoritesPage__title { + margin-top: 16px; + margin-bottom: 40px; + + font-family: Mont-Bold, sans-serif; + font-weight: 800; + letter-spacing: -0.01em; + color: var(--color-gray-primary); + font-size: 48px; + line-height: 56px; +} + +.favoritesPage__description { + font-family: Mont-Regular, sans-serif; + font-size: 14px; + font-weight: 600; + line-height: 21px; + color: var(--color-gray-secondary); +} diff --git a/src/modules/FavoritesPage/FavoritesPage.tsx b/src/modules/FavoritesPage/FavoritesPage.tsx new file mode 100644 index 0000000000..8b78d34216 --- /dev/null +++ b/src/modules/FavoritesPage/FavoritesPage.tsx @@ -0,0 +1,54 @@ +import './FavoritesPage.scss'; +import React, { useContext } from 'react'; +import { useLocation, useSearchParams } from 'react-router-dom'; +import { GlobalContext } from '../../store/GlobalContext'; +import { ProductsList } from '../shared/ProductsList'; +import { Breadcrumbs } from '../shared/Breadcrumbs'; + +export const FavoritesPage: React.FC = () => { + const { favorites } = useContext(GlobalContext); + + const { pathname } = useLocation(); + + const [searchParams] = useSearchParams(); + const query = searchParams.get('query') || ''; + + const normalizeProductsType = + pathname.slice(1, 2).toUpperCase() + pathname.slice(2); + + let visibleFavorites = [...favorites]; + + if (query.length) { + visibleFavorites = visibleFavorites.filter(product => + product.name.toLowerCase().includes(query.toLowerCase().trim()), + ); + } + + const countFavorites = visibleFavorites.length; + + return ( +
+ +

{normalizeProductsType}

+ + + {`${countFavorites} ${countFavorites === 1 ? 'model' : 'models'}`} + + + {countFavorites === 0 && query.length > 0 ? ( +
+ {`There are no ${normalizeProductsType.toLowerCase()} matching the query`} +
+ ) : countFavorites === 0 ? ( +
Favorites is empty
+ ) : ( +
+ +
+ )} +
+ ); +}; diff --git a/src/modules/FavoritesPage/index.ts b/src/modules/FavoritesPage/index.ts new file mode 100644 index 0000000000..b3a884b188 --- /dev/null +++ b/src/modules/FavoritesPage/index.ts @@ -0,0 +1 @@ +export * from './FavoritesPage'; diff --git a/src/modules/HomePage/HomePage.scss b/src/modules/HomePage/HomePage.scss new file mode 100644 index 0000000000..c9b913ea80 --- /dev/null +++ b/src/modules/HomePage/HomePage.scss @@ -0,0 +1,38 @@ +@import '../../styles/main'; + +.homePage { + &__title { + margin-block: 24px; + font-family: Mont-Bold, sans-serif; + color: var(--color-gray-primary); + font-size: 32px; + font-weight: 800; + line-height: 41px; + letter-spacing: -0.01em; + + @include content-padding-inline; + + + @include on-tablet { + font-size: 48px; + line-height: 56px; + margin-block: 32px; + } + + @include on-desktop { + margin-block: 56px; + } + } + + &__hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + border: 0; + clip: rect(0, 0, 0, 0); + clip-path: inset(50%); + overflow: hidden; + } +} diff --git a/src/modules/HomePage/HomePage.tsx b/src/modules/HomePage/HomePage.tsx new file mode 100644 index 0000000000..f70faa3e72 --- /dev/null +++ b/src/modules/HomePage/HomePage.tsx @@ -0,0 +1,46 @@ +import './HomePage.scss'; +import { useContext } from 'react'; +import { PicturesSlider } from './components/PicturesSlider'; +import { ShopByCategory } from './components/ShopByCategory'; +import { GlobalContext } from '../../store/GlobalContext'; +import { ProductsSlider } from '../shared/ProductsSlider'; + +export const HomePage: React.FC = () => { + const { allProducts } = useContext(GlobalContext); + + const newestPhones = [...allProducts] + .filter(product => product.category === 'phones') + .sort((phone1, phone2) => phone2.year - phone1.year) + .slice(0, 20); + + const hotPricesProducts = [...allProducts] + .map(product => ({ + ...product, + discount: ((product.fullPrice - product.price) / product.fullPrice) * 100, + })) + .sort((product1, product2) => product2.discount - product1.discount) + .slice(0, 20); + + return ( +
+

Product Catalog

+

Welcome to Nice Gadgets store!

+ + + + + + + + +
+ ); +}; diff --git a/src/modules/HomePage/components/PicturesSlider/PicturesSlider.scss b/src/modules/HomePage/components/PicturesSlider/PicturesSlider.scss new file mode 100644 index 0000000000..4ed2de7606 --- /dev/null +++ b/src/modules/HomePage/components/PicturesSlider/PicturesSlider.scss @@ -0,0 +1,110 @@ +@import '../../../../styles/main'; + +.picturesSlider { + margin-bottom: 56px; + + @include on-tablet { + @include content-padding-inline; + + margin-bottom: 64px; + } + + @include on-desktop { + margin-bottom: 80px; + } +} + +.picturesSlider__container { + display: flex; + justify-content: center; + width: 100%; +} + +.picturesSlider__button { + display: none; + + transition: border-color 0.3s ease, background-color 0.3s ease; + + @include on-tablet { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + background-color: var(--color-bg); + border: 1px solid var(--color-gray-icons-placeholders); + cursor: pointer; + } +} + +.picturesSlider__button:hover { + border-color: var(--color-gray-primary); + background-color: var(--color-hover); +} + +body.theme_dark .picturesSlider__button { + border: none; + background-color: var(--color-surface-2); +} + +body.theme_dark .picturesSlider__button:hover { + background-color: var(--color-gray-icons-placeholders); +} + +.picturesSlider__container-image { + position: relative; + width: 100%; + aspect-ratio: 1 / 1; + + @include on-tablet { + min-height: 189px; + max-height: 400px; + margin-inline: 16px; + flex-grow: 1; + aspect-ratio: 400 / 189; + } +} + +.picturesSlider__image { + width: 100%; + height: 100%; + object-fit: cover; + + position: absolute; + top: 0; + left: 0; + transition: opacity 0.5s ease-in-out; + opacity: 0; + + &--active { + opacity: 1; + } +} + +.picturesSlider__dots { + height: 24px; + display: flex; + margin: 0 auto; + justify-content: center; +} + +.picturesSlider__dot { + width: 24px; + height: 24px; + cursor: pointer; + position: relative; +} + +.picturesSlider__dot::after { + content: ''; + position: absolute; + width: 14px; + height: 4px; + background-color: var(--color-gray-elements); + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +.picturesSlider__dot--active::after { + background-color: var(--color-gray-primary); +} diff --git a/src/modules/HomePage/components/PicturesSlider/PicturesSlider.tsx b/src/modules/HomePage/components/PicturesSlider/PicturesSlider.tsx new file mode 100644 index 0000000000..f4831355e1 --- /dev/null +++ b/src/modules/HomePage/components/PicturesSlider/PicturesSlider.tsx @@ -0,0 +1,122 @@ +import React, { useState, useEffect, useRef, useContext } from 'react'; +import './PicturesSlider.scss'; +import { Icon } from '../../../shared/Icon'; +import { iconsObject } from '../../../../constants/iconsObject'; +import { GlobalContext } from '../../../../store/GlobalContext'; + +export const PicturesSlider: React.FC = () => { + const images = [ + 'img/banner-phones.png', + 'img/banner-accessories.png', + 'img/banner-tablets.png', + ]; + + const [currentSlide, setCurrentSlide] = useState(0); + const { theme } = useContext(GlobalContext); + + const intervalRef = useRef(null); + + const nextSlide = () => { + setCurrentSlide(prev => (prev + 1) % images.length); + }; + + const prevSlide = () => { + setCurrentSlide(prev => (prev - 1 + images.length) % images.length); + }; + + useEffect(() => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + + intervalRef.current = setInterval(nextSlide, 5000); + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleDotClick = (index: number) => { + setCurrentSlide(index); + + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + + intervalRef.current = setInterval(nextSlide, 5000); + }; + + const handleNextButton = () => { + nextSlide(); + + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + + intervalRef.current = setInterval(nextSlide, 5000); + }; + + const handlePrevButton = () => { + prevSlide(); + + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + + intervalRef.current = setInterval(nextSlide, 5000); + }; + + return ( +
+
+
+ {theme === 'light' ? ( + + ) : ( + + )} +
+ +
+ {images.map((src, index) => ( + Slide + ))} +
+ +
+ {theme === 'light' ? ( + + ) : ( + + )} +
+
+ +
+ {images.map((_, index) => ( +
handleDotClick(index)} + >
+ ))} +
+
+ ); +}; diff --git a/src/modules/HomePage/components/PicturesSlider/index.ts b/src/modules/HomePage/components/PicturesSlider/index.ts new file mode 100644 index 0000000000..81a373f3aa --- /dev/null +++ b/src/modules/HomePage/components/PicturesSlider/index.ts @@ -0,0 +1 @@ +export * from './PicturesSlider'; diff --git a/src/modules/HomePage/components/ShopByCategory/ShopByCategory.scss b/src/modules/HomePage/components/ShopByCategory/ShopByCategory.scss new file mode 100644 index 0000000000..2f08d17956 --- /dev/null +++ b/src/modules/HomePage/components/ShopByCategory/ShopByCategory.scss @@ -0,0 +1,79 @@ +@import '../../../../styles/main'; + +.shopByCategory__container { + margin-block: 56px; + + @include content-padding-inline; + + @include on-tablet { + margin-block: 64px; + } +} + +.shopByCategory__title { + font-family: Mont-Bold, sans-serif; + font-size: 22px; + font-weight: 800; + line-height: 30.8px; + letter-spacing: -0.01em; + color: var(--color-gray-primary); + margin-bottom: 24px; + + @include on-tablet { + font-family: Mont-Bold, sans-serif; + font-size: 32px; + line-height: 41px; + } +} + +.shopByCategory__content { + gap: 32px; + + @include page-grid; +} + +.shopByCategory__link { + text-decoration: none; + grid-column: 1 / -1; + + @include on-tablet { + grid-column: span 4; + } + + @include on-desktop { + grid-column: span 8; + } +} + +.shopByCategory__block-image { + width: 100%; + height: auto; + + @include hover(transform, scale(1.05)); + + @include on-tablet { + max-width: 100%; + } + + @include on-desktop { + max-width: 100%; + } +} + +.shopByCategory__block-title { + margin-top: 24px; + font-family: Mont-SemiBold, sans-serif; + font-size: 20px; + font-weight: 700; + line-height: 25.56px; + color: var(--color-gray-primary); +} + +.shopByCategory__block-description { + font-family: Mont-Regular, sans-serif; + color: var(--color-gray-secondary); + font-size: 14px; + font-weight: 600; + line-height: 21px; + margin-top: 4px; +} diff --git a/src/modules/HomePage/components/ShopByCategory/ShopByCategory.tsx b/src/modules/HomePage/components/ShopByCategory/ShopByCategory.tsx new file mode 100644 index 0000000000..b096bd6957 --- /dev/null +++ b/src/modules/HomePage/components/ShopByCategory/ShopByCategory.tsx @@ -0,0 +1,72 @@ +import './ShopByCategory.scss'; +import { useContext } from 'react'; +import { Link } from 'react-router-dom'; +import { GlobalContext } from '../../../../store/GlobalContext'; + +export const ShopByCategory: React.FC = () => { + const { allProducts } = useContext(GlobalContext); + + const phonesLength = allProducts.filter( + product => product.category === 'phones', + ).length; + + const tabletsLength = allProducts.filter( + product => product.category === 'tablets', + ).length; + + const accessoriesLength = allProducts.filter( + product => product.category === 'accessories', + ).length; + + return ( +
+
+

Shop by category

+ +
+ +
+ Category Phones +

Mobile phones

+ + {`${phonesLength} models`} + +
+ + + +
+ Category Tablets +

Tablets

+ + {`${tabletsLength} models`} + +
+ + + +
+ Category Accessories +

Accessories

+ + {`${accessoriesLength} models`} + +
+ +
+
+
+ ); +}; diff --git a/src/modules/HomePage/components/ShopByCategory/index.ts b/src/modules/HomePage/components/ShopByCategory/index.ts new file mode 100644 index 0000000000..8081526324 --- /dev/null +++ b/src/modules/HomePage/components/ShopByCategory/index.ts @@ -0,0 +1 @@ +export * from './ShopByCategory'; diff --git a/src/modules/HomePage/index.ts b/src/modules/HomePage/index.ts new file mode 100644 index 0000000000..11e53da674 --- /dev/null +++ b/src/modules/HomePage/index.ts @@ -0,0 +1 @@ +export * from './HomePage'; diff --git a/src/modules/NotFoundPage/NotFoundPage.scss b/src/modules/NotFoundPage/NotFoundPage.scss new file mode 100644 index 0000000000..f642a396a7 --- /dev/null +++ b/src/modules/NotFoundPage/NotFoundPage.scss @@ -0,0 +1,14 @@ +.notFoundPage { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + margin-top: 40px; + font-family: Mont-SemiBold, sans-serif; + font-weight: 800; + letter-spacing: -0.01em; + color: var(--color-gray-primary); + font-size: 48px; + line-height: 56px; +} diff --git a/src/modules/NotFoundPage/NotFoundPage.tsx b/src/modules/NotFoundPage/NotFoundPage.tsx new file mode 100644 index 0000000000..7c4304a208 --- /dev/null +++ b/src/modules/NotFoundPage/NotFoundPage.tsx @@ -0,0 +1,14 @@ +import './NotFoundPage.scss'; +import React from 'react'; +import { Link } from 'react-router-dom'; + +export const NotFoundPage: React.FC = () => { + return ( +
+

Page not found

+ + Go to HomePage + +
+ ); +}; diff --git a/src/modules/NotFoundPage/index.ts b/src/modules/NotFoundPage/index.ts new file mode 100644 index 0000000000..6197aa75aa --- /dev/null +++ b/src/modules/NotFoundPage/index.ts @@ -0,0 +1 @@ +export * from './NotFoundPage'; diff --git a/src/modules/ProductDetailsPage/ProductDetailsPage.scss b/src/modules/ProductDetailsPage/ProductDetailsPage.scss new file mode 100644 index 0000000000..3fe076c2d8 --- /dev/null +++ b/src/modules/ProductDetailsPage/ProductDetailsPage.scss @@ -0,0 +1,41 @@ +@import '../../styles/main'; + +.detailsPage { + @include content-padding-inline; + + &__title { + margin-bottom: 32px; + font-size: 22px; + font-family: Mont-Bold, sans-serif; + + font-weight: 800; + line-height: 41px; + letter-spacing: -0.01em; + color: var(--color-gray-primary); + + @include on-tablet { + margin-bottom: 40px; + font-size: 32px; + } + } +} + +.detailsPage__error-message { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + margin-top: 40px; + font-family: Mont-SemiBold, sans-serif; + font-weight: 800; + letter-spacing: -0.01em; + color: var(--color-gray-primary); + font-size: 48px; + line-height: 56px; +} + + + + + diff --git a/src/modules/ProductDetailsPage/ProductDetailsPage.tsx b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx new file mode 100644 index 0000000000..671b9e99c7 --- /dev/null +++ b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx @@ -0,0 +1,150 @@ +import './ProductDetailsPage.scss'; +import { useContext, useState, useEffect } from 'react'; +import { Link, useLocation, useParams } from 'react-router-dom'; +import { Breadcrumbs } from '../shared/Breadcrumbs'; +import { SpecificProduct } from '../../types/SpecificProduct'; +import { ProductContentTop } from './components/ProductContentTop'; +import { ProductContentBottom } from './components/ProductContentBottom'; +import { GlobalContext } from '../../store/GlobalContext'; +import { ProductsSlider } from '../shared/ProductsSlider'; +import { ButtonBack } from '../shared/ButtonBack'; +import { getSpecificProducts } from '../../utils/productApi'; +import { Loader } from '../shared/Loader'; +import { Product } from '../../types/Product'; + +const getSuggestedProducts = ( + allProducts: Product[], + currentCategory: string, + productItemId: string, +) => { + return allProducts + .filter( + prod => + prod.category === currentCategory && prod.itemId !== productItemId, + ) + .sort(() => 0.5 - Math.random()); +}; + +export const ProductDetailsPage: React.FC = () => { + const { allProducts } = useContext(GlobalContext); + + const { productItemId } = useParams(); + + const [product, setProduct] = useState(null); + const [productsArray, setProductsArray] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(''); + + const currentCategory = useLocation().pathname.split('/')[1]; + + useEffect(() => { + setIsLoading(true); + setError(''); + + const timeout = setTimeout(() => { + getSpecificProducts(currentCategory) + .then(fetchedSpecificProducts => { + setProductsArray(fetchedSpecificProducts); + + const currentProduct = fetchedSpecificProducts.find( + prod => prod.id === productItemId, + ); + + if (currentProduct) { + setProduct(currentProduct); + setError(''); + } else { + setProduct(null); + setError('Product not found'); + } + }) + .catch(er => { + setError(` + Error loading products: The product category "${currentCategory}" does not exist. ${er.message}`); + }) + .finally(() => { + setIsLoading(false); + }); + }, 500); + + return () => clearTimeout(timeout); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentCategory]); + + useEffect(() => { + if (!productItemId || !productsArray.length) { + return; + } + + const currentProduct = productsArray.find(pr => pr.id === productItemId); + + if (currentProduct) { + setProduct(currentProduct); + setError(''); + } else { + setProduct(null); + setError('Product not found'); + } + }, [productItemId, productsArray]); + + const suggestedProducts = productItemId + ? getSuggestedProducts(allProducts, currentCategory, productItemId) + : []; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+
+ {error} + + Go to HomePage + +
+
+ ); + } + + if (!product) { + return; + } + + return ( +
+ {error && !isLoading && ( +
+ {error} + Go to HomePage +
+ )} + + + + + +

{product.name}

+ + + + + +
+ +
+
+ ); +}; diff --git a/src/modules/ProductDetailsPage/components/ProductContentBottom/ProductContentBottom.scss b/src/modules/ProductDetailsPage/components/ProductContentBottom/ProductContentBottom.scss new file mode 100644 index 0000000000..3452b2ba09 --- /dev/null +++ b/src/modules/ProductDetailsPage/components/ProductContentBottom/ProductContentBottom.scss @@ -0,0 +1,131 @@ +@import '../../../../styles/main'; + +.detailsPage { + &__content-buttom { + gap: 32px; + display: flex; + flex-direction: column; + + margin-bottom: 56px; + + @include on-tablet { + margin-bottom: 64px; + } + + @include on-desktop { + margin-block: 80px; + + @include page-grid; + } + } + + &__block { + &-about { + width: 100%; + + @include on-desktop { + grid-column: 1 / 13; + } + } + + &-techSpecs { + width: 100%; + + @include on-desktop { + grid-column: 14 / -1; + } + } + + &-title { + font-family: Mont-Bold, sans-serif; + font-size: 20px; + font-weight: 700; + line-height: 25.56px; + color: var(--color-gray-primary); + + @include on-tablet { + font-size: 22px; + font-weight: 800; + line-height: 30.8px; + } + } + } + + &__line-bottom { + width: 100%; + height: 1px; + background-color: var(--color-gray-elements); + margin-top: 16px; + } + + &__description { + margin-top: 32px; + display: flex; + flex-direction: column; + gap: 32px; + } + + &__section { + display: flex; + flex-direction: column; + gap: 16px; + + &-title { + font-family: Mont-SemiBold, sans-serif; + font-size: 16px; + font-weight: 700; + line-height: 20.45px; + color: var(--color-gray-primary); + + @include on-tablet { + font-size: 20px; + font-weight: 700; + line-height: 25.56px; + } + + &--bottom { + margin-top: 80px; + margin-bottom: 24px; + } + } + + &-description { + font-family: Mont-Regular, sans-serif; + font-size: 14px; + font-weight: 600; + line-height: 21px; + color: var(--color-gray-secondary); + } + } + + &__techSpecs { + &-content { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 25px; + } + + &-block { + display: flex; + justify-content: space-between; + gap: 20px; + } + + &-title { + font-family: Mont-Regular, sans-serif; + font-size: 14px; + font-weight: 500; + line-height: 21px; + color: var(--color-gray-secondary); + } + + &-value { + font-family: Mont-Regular, sans-serif; + font-size: 14px; + font-weight: 500; + line-height: 21px; + color: var(--color-gray-primary); + } + } +} diff --git a/src/modules/ProductDetailsPage/components/ProductContentBottom/ProductContentBottom.tsx b/src/modules/ProductDetailsPage/components/ProductContentBottom/ProductContentBottom.tsx new file mode 100644 index 0000000000..2f98a69b64 --- /dev/null +++ b/src/modules/ProductDetailsPage/components/ProductContentBottom/ProductContentBottom.tsx @@ -0,0 +1,86 @@ +import './ProductContentBottom.scss'; +import { SpecificProduct } from '../../../../types/SpecificProduct'; + +type Props = { + selectedProduct: SpecificProduct; +}; + +export const ProductContentBottom: React.FC = ({ selectedProduct }) => { + return ( +
+
+

About

+
+ +
+ {selectedProduct.description.map((chunk, index) => ( +
+ {chunk.title} + + {chunk.text} + +
+ ))} +
+
+ +
+

Tech specs

+
+ +
+
+ Screen + + {selectedProduct.screen} + +
+
+ Resolution + + {selectedProduct.resolution} + +
+
+ Processor + + {selectedProduct.processor} + +
+
+ RAM + + {selectedProduct.ram} + +
+
+ + Built in memory + + + {selectedProduct.capacity} + +
+
+ Camera + + {selectedProduct.camera} + +
+
+ Zoom + + {selectedProduct.zoom} + +
+
+ Cell + + {selectedProduct.cell.join(', ')} + +
+
+
+
+ ); +}; diff --git a/src/modules/ProductDetailsPage/components/ProductContentBottom/index.ts b/src/modules/ProductDetailsPage/components/ProductContentBottom/index.ts new file mode 100644 index 0000000000..a67cc2f37c --- /dev/null +++ b/src/modules/ProductDetailsPage/components/ProductContentBottom/index.ts @@ -0,0 +1 @@ +export * from './ProductContentBottom'; diff --git a/src/modules/ProductDetailsPage/components/ProductContentTop/ProductContentTop.scss b/src/modules/ProductDetailsPage/components/ProductContentTop/ProductContentTop.scss new file mode 100644 index 0000000000..fbef338390 --- /dev/null +++ b/src/modules/ProductDetailsPage/components/ProductContentTop/ProductContentTop.scss @@ -0,0 +1,415 @@ +@import '../../../../styles/main'; + +.detailsPage__content-top { + display: flex; + flex-direction: column; + width: 100%; + + @include on-tablet { + flex-direction: row; + grid-column: 1 / -1; + + @include page-grid; + } +} + +.detailsPage__container-imageSlider { + order: 2; + display: flex; + gap: 8px; + justify-content: space-between; + align-items: center; + width: 100%; + box-sizing: border-box; + margin-bottom: 40px; + min-height: 49px; + + @include on-tablet { + grid-column: 1 / 2; + flex-direction: column; + justify-content: flex-start; + gap: 16px; + margin: 0; + } + + @include on-desktop { + grid-column: span 2; + } +} + +.detailsPage__container-photos { + box-sizing: border-box; + padding: 2px; + border: 1px solid var(--color-gray-elements); + transition: border 0.3s ease; + + aspect-ratio: 1 / 1; + height: 100%; + width: 100%; + + display: flex; + align-items: center; + justify-content: center; + + @include on-tablet { + height: auto; + width: 100%; + } +} + +.detailsPage__photo { + width: 100%; + aspect-ratio: 1 / 1; + object-fit: contain; + cursor: pointer; +} + +.detailsPage__container-photos:not(.detailsPage__container-photos--active):hover { + border: 1px solid var(--color-gray-icons-placeholders); +} + +.detailsPage__container-photos--active { + border: 1px solid var(--color-gray-primary); +} + +.detailsPage__photo-mask { + order: 1; + aspect-ratio: 1 / 1; + margin-bottom: 16px; + overflow: hidden; + box-sizing: border-box; + + display: flex; + justify-content: center; + align-items: center; + + position: relative; + + @include on-tablet { + order: 2; + grid-column: span 6; + margin-bottom: 0; + } + + @include on-desktop { + grid-column: span 10; + } +} + +.detailsPage__image { + width: 100%; + height: 100%; + object-fit: contain; + + position: absolute; + opacity: 0; + transition: opacity 0.3s ease; + z-index: -1; + + &.detailsPage__image--active { + opacity: 1; + } +} + +.detailsPage__characteristics { + order: 3; + + width: 100%; + margin-right: 16px; + display: flex; + flex-direction: column; + + @include on-tablet { + grid-column: span 5; + } + + @include on-desktop { + grid-column: 14 / 21; + } +} + +.detailsPage__colors { + display: flex; + flex-direction: column; + gap: 8px; +} + +.detailsPage__colors-list { + list-style: none; + display: flex; + gap: 5px; +} + +.detailsPage__color-item { + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + width: 32px; + height: 32px; + box-sizing: border-box; + border: 1px solid var(--color-gray-elements); + border-radius: 50%; + transition: border 0.3s; + + &--selected { + border: 1px solid var(--color-gray-primary); + } +} + +.detailsPage__color-item:not(.detailsPage__color-item--selected):hover { + border: 1px solid var(--color-gray-icons-placeholders); +} + +.detailsPage__color-circle { + width: 30px; + height: 30px; + box-sizing: border-box; + border-radius: 50%; + border: 2px solid var(--color-bg); +} + +.detailsPage__line { + width: 320px; + height: 1px; + background-color: var(--color-gray-elements); + margin-top: 24px; +} + +.detailsPage__capacity { + margin-top: 24px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.detailsPage__capacity-list { + display: flex; + gap: 8px; + list-style: none; +} + +.detailsPage__capacity-block { + font-family: Mont-Regular, sans-serif; + font-size: 14px; + font-weight: 500; + text-align: center; + color: var(--color-gray-primary); + + &--selected { + color: var(--color-bg); + } +} + +.detailsPage__capacity-item { + height: 32px; + box-sizing: border-box; + padding-inline: 8px; + border: 1px solid var(--color-gray-icons-placeholders); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + + @include hover(border-color, var(--color-gray-primary)); + + &--selected { + background-color: var(--color-gray-primary); + } +} + +.detailsPage__container-price { + height: 41px; + display: flex; + gap: 8px; + margin-top: 32px; + align-items: center; +} + +.detailsPage__container-specifications { + display: flex; + flex-direction: column; + height: 77px; + padding-block: 8px; + gap: 8px; + margin-top: 32px; +} + +.detailsPage__container-buttons { + margin-top: 16px; + + width: 100%; + display: flex; + gap: 8px; +} + +.detailsPage__price-regular { + font-family: Mont-Regular, sans-serif; + color: var(--color-gray-secondary); + font-size: 22px; + font-weight: 500; + text-decoration-line: line-through; +} + +.detailsPage__price-discount { + font-family: Mont-Bold, sans-serif; + color: var(--color-gray-primary); + font-size: 32px; + font-weight: 800; + line-height: 41px; + letter-spacing: -0.01em; +} + +.detailsPage__block { + width: 100%; + height: 15px; + display: flex; + justify-content: space-between; + font-family: Mont-SemiBold, sans-serif; + font-size: 12px; + font-weight: 600; +} + +.detailsPage__block-about { + width: 560px; +} + +.detailsPage__block-techSpecs { + width: 512px; +} + +.detailsPage__block-title { + font-family: Mont-Bold, sans-serif; + font-size: 22px; + font-weight: 800; + line-height: 30.8px; + color: var(--color-gray-primary); +} + +.detailsPage__info { + color: var(--color-gray-secondary); + font-family: Mont-SemiBold, sans-serif; + font-size: 12px; + font-weight: 600; +} + +.detailsPage__value { + color: var(--color-gray-primary); +} + +.detailsPage__button { + border: none; + cursor: pointer; + background-color: var(--color-bg); + + font-family: Mont-Regular, sans-serif; + color: var(--color-bg); + font-size: 14px; + font-weight: 700; + line-height: 21px; + height: 40px; + + display: flex; + align-items: center; + justify-content: center; + + &-card { + background-color: var(--color-gray-primary); + width: 100%; + + @include hover(box-shadow, 0 3px 13px 0 #17203166); + } + + &-card--active { + background-color: var(--color-bg); + color: var(--color-green); + border: 1px solid var(--color-gray-elements); + } + + &-favorites { + width: 40px; + flex-shrink: 0; + border: 1px solid var(--color-gray-icons-placeholders); + } + + &-favorites--active { + border: 1px solid var(--color-gray-elements); + } +} + +.detailsPage__content-buttom { + display: flex; + gap: 64px; + margin-top: 80px; +} + +.detailsPage__line-bottom { + width: 100%; + height: 1px; + background-color: var(--color-gray-elements); + margin-top: 16px; +} + +.detailsPage__description { + margin-top: 32px; + display: flex; + flex-direction: column; + gap: 32px; +} + +.detailsPage__section { + display: flex; + flex-direction: column; + gap: 16px; + + &-title { + font-family: Mont-SemiBold, sans-serif; + font-size: 20px; + font-weight: 700; + line-height: 25.56px; + color: var(--color-gray-primary); + } + + &-description { + font-family: Mont-Regular, sans-serif; + font-size: 14px; + font-weight: 500; + line-height: 21px; + color: var(--color-gray-secondary); + } +} + +.detailsPage__techSpecs-content { + display: flex; + flex-direction: column; + gap: 24px; +} + +.detailsPage__techSpecs { + display: flex; + flex-direction: column; + gap: 8px; +} + +.detailsPage__techSpecs-title { + color: var(--color-gray-primary); +} + +.detailsPage__techSpecs-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 8px; +} + +.detailsPage__techSpecs-item { + font-family: Mont-Regular, sans-serif; + font-size: 14px; + font-weight: 500; + line-height: 21px; + color: var(--color-gray-primary); +} + +.detailsPage__techSpecs-item-description { + color: var(--color-gray-secondary); +} diff --git a/src/modules/ProductDetailsPage/components/ProductContentTop/ProductContentTop.tsx b/src/modules/ProductDetailsPage/components/ProductContentTop/ProductContentTop.tsx new file mode 100644 index 0000000000..09cbe109b9 --- /dev/null +++ b/src/modules/ProductDetailsPage/components/ProductContentTop/ProductContentTop.tsx @@ -0,0 +1,246 @@ +import './ProductContentTop.scss'; +import { NavLink } from 'react-router-dom'; +import { SpecificProduct } from '../../../../types/SpecificProduct'; +import { useContext, useState } from 'react'; +import { colors } from '../../../../constants/colors'; +import { GlobalContext } from '../../../../store/GlobalContext'; +import { Icon } from '../../../shared/Icon'; +import { iconsObject } from '../../../../constants/iconsObject'; +import { Product } from '../../../../types/Product'; +import classNames from 'classnames'; + +const getProductBySelectedProductId = ( + products: Product[], + selectedProductId: string, +) => { + return products.find(product => product.itemId === selectedProductId); +}; + +type Props = { + selectedProduct: SpecificProduct; + specificProducts: SpecificProduct[]; +}; + +export const ProductContentTop: React.FC = ({ + selectedProduct, + specificProducts, +}) => { + const { allProducts, cart, favorites, toggleFavorites, addToCart, theme } = + useContext(GlobalContext); + + const [selectedPhoto, setSelectedPhoto] = useState(0); + + const handleShoppingCard = (currentProduct: SpecificProduct) => { + const productToAdd = getProductBySelectedProductId( + allProducts, + currentProduct.id, + ); + + if (productToAdd) { + addToCart(productToAdd); + } + }; + + const handleFavorites = (currentProduct: SpecificProduct) => { + const favoriteProduct = getProductBySelectedProductId( + allProducts, + currentProduct.id, + ); + + if (favoriteProduct) { + toggleFavorites(favoriteProduct); + } + }; + + const getLink = (option: string, value: string) => { + const { + color: itemColor, + namespaceId: itemNamespaceId, + capacity: itemCapacity, + } = selectedProduct; + + const el = specificProducts.find(({ color, namespaceId, capacity }) => { + return ( + namespaceId === itemNamespaceId && + ((option === 'color' && color === value && capacity === itemCapacity) || + (option === 'capacity' && capacity === value && color === itemColor)) + ); + }); + + return el?.id ?? ''; + }; + + const isInCart = cart.some(item => item.id === selectedProduct.id); + + const isFavorites = favorites.some( + item => item.itemId === selectedProduct.id, + ); + + return ( +
+
+ {selectedProduct.images.map((image, index) => ( +
+ {`Thumbnail setSelectedPhoto(index)} + /> +
+ ))} +
+ +
+ {selectedProduct.images.map((image, index) => ( + {`Selected + ))} +
+ +
+
+ + Available colors + +
    + {selectedProduct.colorsAvailable.map(color => ( + +
  • + +
  • +
    + ))} +
+
+ +
+ +
+ + Select capacity + +
    + {selectedProduct.capacityAvailable.map(capacity => ( + +
  • + + {capacity.split('GB').join(' GB')} + +
  • +
    + ))} +
+
+ +
+ +
+ + {`$${selectedProduct.priceDiscount}`} + + + {`$${selectedProduct.priceRegular}`} + +
+ +
+ + + +
+ +
+
+ Screen + {selectedProduct.screen} +
+
+ Resolution + + {selectedProduct.resolution} + +
+
+ Processor + + {selectedProduct.processor} + +
+
+ RAM + {selectedProduct.ram} +
+
+
+
+ ); +}; diff --git a/src/modules/ProductDetailsPage/components/ProductContentTop/index.ts b/src/modules/ProductDetailsPage/components/ProductContentTop/index.ts new file mode 100644 index 0000000000..e58da14a10 --- /dev/null +++ b/src/modules/ProductDetailsPage/components/ProductContentTop/index.ts @@ -0,0 +1 @@ +export * from './ProductContentTop'; diff --git a/src/modules/ProductDetailsPage/index.ts b/src/modules/ProductDetailsPage/index.ts new file mode 100644 index 0000000000..6615089e5e --- /dev/null +++ b/src/modules/ProductDetailsPage/index.ts @@ -0,0 +1 @@ +export * from './ProductDetailsPage'; diff --git a/src/modules/ProductPage/ProductPage.scss b/src/modules/ProductPage/ProductPage.scss new file mode 100644 index 0000000000..3304c50bb0 --- /dev/null +++ b/src/modules/ProductPage/ProductPage.scss @@ -0,0 +1,73 @@ +@import '../../styles/main'; + +.productPage { + @include content-padding-inline; +} + +.productPage__title { + font-family: Mont-Bold, sans-serif; + font-size: 32px; + font-weight: 800; + line-height: 41px; + letter-spacing: -0.01em; + color: var(--color-gray-primary); + margin-bottom: 8px; + + @include on-tablet { + font-size: 48px; + line-height: 56px; + } +} + +.productPage__description { + font-family: Mont-Regular, sans-serif; + font-size: 14px; + font-weight: 600; + line-height: 21px; + color: var(--color-gray-secondary); +} + +.productPage__dropdown { + margin-top: 32px; + gap: 16px; + + @include page-grid; + + @include on-tablet { + margin-top: 40px; + } +} + +.productPage__dropdown--sortBy { + grid-column: span 2; + + @include on-tablet { + grid-column: span 4; + } +} + +.productPage__dropdown--itemsPerPage { + grid-column: span 2; + + @include on-tablet { + grid-column: span 3; + } +} + +.productPage__content { + display: flex; + flex-wrap: wrap; + gap: 40px 16px; + margin-top: 24px; +} + +.productPage__no-products { + margin-top: 40px; + font-family: Mont-SemiBold, sans-serif; + font-size: 48px; + font-weight: 500; + color: var(--color-gray-secondary); + display: flex; + justify-content: center; + text-align: ct; +} diff --git a/src/modules/ProductPage/ProductPage.tsx b/src/modules/ProductPage/ProductPage.tsx new file mode 100644 index 0000000000..60d8cc07a7 --- /dev/null +++ b/src/modules/ProductPage/ProductPage.tsx @@ -0,0 +1,190 @@ +import './ProductPage.scss'; +import React, { useContext, useEffect, useState } from 'react'; +import { useLocation, useSearchParams } from 'react-router-dom'; +import { Dropdown } from '../shared/Dropdown'; +import { Pagination } from '../shared/Pagination'; +import { GlobalContext } from '../../store/GlobalContext'; +import { Breadcrumbs } from '../shared/Breadcrumbs'; +import { ProductsList } from '../shared/ProductsList'; +import { Loader } from '../shared/Loader'; +import { Product } from '../../types/Product'; +import { getSearchWith } from '../../utils/searchHelper'; + +export type SearchParams = { + [key: string]: string | string[] | null; +}; + +const getPreparedProducts = ( + products: Product[], + { sortBy, query }: { sortBy: string; query: string }, +): Product[] => { + let filteredProducts = [...products]; + + if (query) { + filteredProducts = filteredProducts.filter(product => + product.name.toLowerCase().includes(query.toLowerCase().trim()), + ); + } + + switch (sortBy) { + case 'Newest': + return filteredProducts.sort((a, b) => b.year - a.year); + case 'Alphabetically': + return filteredProducts.sort((a, b) => a.name.localeCompare(b.name)); + case 'Cheapest': + return filteredProducts.sort((a, b) => a.fullPrice - b.fullPrice); + default: + return filteredProducts; + } +}; + +type Props = { + category: string; +}; + +export const ProductPage: React.FC = ({ category }) => { + const { allProducts } = useContext(GlobalContext); + + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [searchParams, setSearchParams] = useSearchParams(); + + const sortBy = searchParams.get('sort') || 'Newest'; + const itemsPerPage = searchParams.get('perPage') || 'All'; + const currentPage = Number(searchParams.get('page')) || 1; + const queryParam = searchParams.get('query') || ''; + + const location = useLocation(); + + const typeProduct = location.pathname.split('/')[1]; + + const categoryProducts = allProducts.filter( + product => product.category === category, + ); + + const visibleProducts = getPreparedProducts(categoryProducts, { + sortBy, + query: queryParam, + }); + + const countVisibleProducts = visibleProducts.length; + + const handleSortChange = (value: string) => { + const updatedParams = value === 'Newest' ? { sort: null } : { sort: value }; + + setSearchParams(getSearchWith(searchParams, updatedParams)); + }; + + const handleItemsPerPageChange = (value: string) => { + const updatedParams = + value === 'All' + ? { perPage: null, page: null } + : { perPage: value, page: '1' }; + + setSearchParams(getSearchWith(searchParams, updatedParams)); + }; + + const handlePageChange = (page: number) => { + const updatedParams = page === 1 ? { page: null } : { page: String(page) }; + + setSearchParams(getSearchWith(searchParams, updatedParams)); + }; + + const totalPages = + itemsPerPage === 'All' + ? 1 + : Math.ceil(countVisibleProducts / +itemsPerPage); + + const startIndex = (currentPage - 1) * +itemsPerPage; + const currentItems = + itemsPerPage === 'All' + ? visibleProducts + : visibleProducts.slice(startIndex, startIndex + +itemsPerPage); + + useEffect(() => { + setIsLoading(true); + setError(null); + + const timer = setTimeout(() => { + try { + if (!allProducts || allProducts.length === 0) { + setError('Failed to load products'); + } + } catch (err) { + setError('Failed to load products'); + } finally { + setIsLoading(false); + } + }, 500); + + return () => clearTimeout(timer); + }, [category, allProducts]); + + return ( +
+ {isLoading && } + + {!isLoading && error && ( +
+

Something went wrong. Please try again.

+ +
+ )} + + {!isLoading && !error && visibleProducts.length === 0 && !queryParam && ( +
+

There are no {category} yet.

+
+ )} + + {!isLoading && !error && visibleProducts.length === 0 && queryParam && ( +

{`There are no ${typeProduct} matching the query`}

+ )} + + {!isLoading && !error && visibleProducts.length > 0 && ( + <> + + +

+ {category && + `${category.charAt(0).toUpperCase() + category.slice(1)} page`} +

+ + + {`${countVisibleProducts} model${countVisibleProducts !== 1 ? 's' : ''}`} + + +
+
+ +
+
+ +
+
+ + + + {itemsPerPage !== 'All' && totalPages > 1 && ( + + )} + + )} +
+ ); +}; diff --git a/src/modules/ProductPage/index.ts b/src/modules/ProductPage/index.ts new file mode 100644 index 0000000000..875dce3d23 --- /dev/null +++ b/src/modules/ProductPage/index.ts @@ -0,0 +1 @@ +export * from './ProductPage'; diff --git a/src/modules/ShoppingCartPage/ShoppingCartPage.scss b/src/modules/ShoppingCartPage/ShoppingCartPage.scss new file mode 100644 index 0000000000..e7a09f771e --- /dev/null +++ b/src/modules/ShoppingCartPage/ShoppingCartPage.scss @@ -0,0 +1,129 @@ +@import '../../styles/main'; + +.cartPage { + @include content-padding-inline; +} + +.cartPage__title { + margin-top: 16px; + margin-bottom: 32px; + + font-family: Mont-Bold, sans-serif; + font-weight: 800; + letter-spacing: -0.01em; + color: var(--color-gray-primary); + font-size: 48px; + line-height: 56px; +} + +.cartPage__empty-content { + display: flex; + width: 100%; + align-items: center; + justify-content: center; +} + +.cartPage__empty-content-title { + font-family: Mont-SemiBold, sans-serif; + font-size: 48px; + font-weight: 500; + color: var(--color-gray-secondary); + display: flex; + justify-content: center; + width: 460px; +} + +.cartPage__image-empty { + width: 40%; +} + +.cartPage__content { + display: flex; + flex-direction: column; + gap: 32px; + + @include on-desktop { + @include page-grid; + } +} + +.cartPage__content-container { + display: flex; + flex-direction: column; + gap: 16px; + + @include on-desktop { + grid-column: span 16; + } +} + +.cartPage__total { + width: 100%; + height: 206px; + padding: 24px; + box-sizing: border-box; + + border: 1px solid var(--color-gray-elements); + display: flex; + flex-direction: column; + align-items: center; + + @include on-desktop { + grid-column: span 8; + } +} + +.cartPage__total-count { + height: 41px; + font-family: Mont-Bold, sans-serif; + font-size: 32px; + font-weight: 800; + line-height: 41px; + letter-spacing: -0.01em; + color: var(--color-gray-primary); +} + +.cartPage__total-title { + font-family: Mont-Regular, sans-serif; + + font-size: 14px; + font-weight: 500; + line-height: 21px; + color: var(--color-gray-secondary); +} + +.cartPage__line { + width: 100%; + height: 1px; + background-color: var(--color-gray-elements); + + margin-block: 24px; +} + +.cartPage__button-checkout { + width: 100%; + height: 48px; + background-color: var(--color-gray-primary); + border: none; + + font-family: Mont-SemiBold, sans-serif; + font-size: 14px; + font-weight: 700; + line-height: 21px; + text-align: center; + color: var(--color-bg); + cursor: pointer; + transition: background-color 0.3s ease, box-shadow 0.3s ease, color 0.3s ease; + + @include hover(box-shadow, 0 3px 13px 0 #17203166); +} + +body.theme_dark .cartPage__button-checkout { + background-color: var(--color-accent); + color: var(--color-gray-primary); + transition: background-color 0.3s ease, box-shadow 0.3s ease, color 0.3s ease; + + &:hover { + background-color: var(--color-accent-hover); + } +} diff --git a/src/modules/ShoppingCartPage/ShoppingCartPage.tsx b/src/modules/ShoppingCartPage/ShoppingCartPage.tsx new file mode 100644 index 0000000000..a868d2ba89 --- /dev/null +++ b/src/modules/ShoppingCartPage/ShoppingCartPage.tsx @@ -0,0 +1,80 @@ +import './ShoppingCartPage.scss'; +import React, { useContext } from 'react'; +import { useLocation } from 'react-router-dom'; +import { GlobalContext } from '../../store/GlobalContext'; +import { CartItem } from './components/CartItem'; +import { ButtonBack } from '../shared/ButtonBack'; + +export const ShoppingCartPage: React.FC = () => { + const { cart, clearShoppingCart } = useContext(GlobalContext); + + const { pathname } = useLocation(); + + const normalizeProductsType = + pathname.slice(1, 2).toUpperCase() + pathname.slice(2); + + const countCartItems = cart.length; + + const totalQuantity = cart.reduce((sum, item) => { + return sum + item.quantity; + }, 0); + + const totalCount = cart.reduce((sum, item) => { + return sum + item.quantity * item.product.price; + }, 0); + + const handleCheckout = () => { + const confirmed = confirm( + 'Checkout is not implemented yet. Do you want to clear the Cart?', + ); + + if (confirmed) { + clearShoppingCart(); + } + }; + + return ( +
+ + +

{normalizeProductsType}

+ + {countCartItems === 0 && ( +
+ + Your cart is empty + + Empty shopping cart +
+ )} + + {countCartItems !== 0 && ( +
+
+ {cart.map(item => ( + + ))} +
+ +
+ ${totalCount} + + Total for {totalQuantity} items + +
+ +
+
+ )} +
+ ); +}; diff --git a/src/modules/ShoppingCartPage/components/CartItem/CartItem.scss b/src/modules/ShoppingCartPage/components/CartItem/CartItem.scss new file mode 100644 index 0000000000..00ccc60433 --- /dev/null +++ b/src/modules/ShoppingCartPage/components/CartItem/CartItem.scss @@ -0,0 +1,154 @@ +@import '../../../../styles/main'; + +.cartItem { + width: 100%; + height: 164px; + box-sizing: border-box; + padding: 16px; + display: flex; + flex-direction: column; + gap: 16px; + border: 1px solid var(--color-gray-elements); + align-items: center; + transition: border-color 0.3s ease; + + @include on-tablet { + flex-direction: row; + gap: 24px; + height: 128px; + padding: 24px; + } + + &:hover { + border: 1px solid var(--color-gray-primary); + transition: border-color 0.3s ease; + } + + & .cartItem__wrapperTop { + height: 80px; + width: 100%; + display: flex; + gap: 16px; + align-items: center; + justify-content: space-between; + + @include on-tablet { + gap: 24px; + } + } + + & .cartItem__wrapperBottom { + height: 32px; + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + + @include on-tablet { + gap: 24px; + width: auto; + } + } + + & .cartItem__icon-close { + border: none; + background-color: var(--color-bg); + cursor: pointer; + display: flex; + justify-content: center; + align-items: center; + } + + & .cartItem__title { + font-family: Mont-Regular, sans-serif; + font-size: 14px; + font-weight: 600; + line-height: 21px; + color: var(--color-gray-primary); + flex-grow: 1; + } + + & .cartItem__counter-container { + width: 96px; + height: 32px; + display: flex; + } + + & .cartItem__price { + width: 80px; + text-align: right; + font-family: Mont-Bold, sans-serif; + font-size: 22px; + font-weight: 800; + line-height: 30.8px; + color: var(--color-gray-primary); + } + + & .cartItem__counter { + flex: 1; + width: 32px; + height: 32px; + box-sizing: border-box; + display: flex; + justify-content: center; + align-items: center; + font-family: Mont-Regular, sans-serif; + font-size: 14px; + font-weight: 600; + line-height: 21px; + } + + & .cartItem__button { + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + border: 1px solid var(--color-gray-icons-placeholders); + background-color: #fff; + width: 32px; + height: 32px; + cursor: pointer; + transition: border 0.3s ease; + + &--disabled { + border: 1px solid var(--color-gray-elements); + cursor: not-allowed; + } + } + + & .cartItem__total-container { + width: 368px; + height: 206px; + border: 1px solid var(--color-gray-elements); + } + + .cartItem__image { + height: 80px; + width: 80px; + object-fit: contain; + transition: transform 0.3s ease; + + &:hover { + transform: scale(1.05); + } + } +} + +body.theme_dark .cartItem__counter { + color: var(--color-gray-primary); +} + +body.theme_dark .cartItem__button { + background-color: var(--color-surface-2); + border: none; + + &--disabled { + background-color: var(--color-bg); + border: 1px solid var(--color-gray-elements); + } + + &:not(.cartItem__button--disabled):hover { + background-color: var(--color-gray-icons-placeholders); + border: none; + } +} diff --git a/src/modules/ShoppingCartPage/components/CartItem/CartItem.tsx b/src/modules/ShoppingCartPage/components/CartItem/CartItem.tsx new file mode 100644 index 0000000000..44ae144c84 --- /dev/null +++ b/src/modules/ShoppingCartPage/components/CartItem/CartItem.tsx @@ -0,0 +1,78 @@ +import './CartItem.scss'; +import classNames from 'classnames'; +import React, { useContext } from 'react'; +import { CartProduct } from '../../../../types/CartProduct'; +import { GlobalContext } from '../../../../store/GlobalContext'; +import { Icon } from '../../../shared/Icon'; +import { iconsObject } from '../../../../constants/iconsObject'; + +type Props = { + cartProduct: CartProduct; +}; + +export const CartItem: React.FC = ({ cartProduct }) => { + const { updateQuantity, theme } = useContext(GlobalContext); + + const totalProductPrice = cartProduct.product.price * cartProduct.quantity; + + return ( +
+
+ + Image product + {cartProduct.product.name} +
+ +
+
+ + {cartProduct.quantity} + +
+ + {`$${totalProductPrice}`} +
+
+ ); +}; diff --git a/src/modules/ShoppingCartPage/components/CartItem/index.ts b/src/modules/ShoppingCartPage/components/CartItem/index.ts new file mode 100644 index 0000000000..37a0553540 --- /dev/null +++ b/src/modules/ShoppingCartPage/components/CartItem/index.ts @@ -0,0 +1 @@ +export * from './CartItem'; diff --git a/src/modules/ShoppingCartPage/index.ts b/src/modules/ShoppingCartPage/index.ts new file mode 100644 index 0000000000..0940a9c722 --- /dev/null +++ b/src/modules/ShoppingCartPage/index.ts @@ -0,0 +1 @@ +export * from './ShoppingCartPage'; diff --git a/src/modules/shared/Breadcrumbs/Breadcrumbs.scss b/src/modules/shared/Breadcrumbs/Breadcrumbs.scss new file mode 100644 index 0000000000..f36233f00d --- /dev/null +++ b/src/modules/shared/Breadcrumbs/Breadcrumbs.scss @@ -0,0 +1,43 @@ +@import '../../../styles/main'; + +.breadcrumbs { + &__item { + font-family: Mont-SemiBold, sans-serif; + font-size: 12px; + font-weight: 600; + color: var(--color-gray-secondary); + display: inline-block; + vertical-align: middle; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + max-width: 100%; + + &--dark { + text-decoration: none; + color: var(--color-gray-primary); + } + } + + &__link-home, + &__arrow { + display: flex; + align-items: center; + justify-content: center; + } +} + +.breadcrumbs__container { + height: 16px; + margin-block: 24px; + display: flex; + align-items: center; + gap: 8px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + @include on-tablet { + margin-bottom: 40px; + } +} diff --git a/src/modules/shared/Breadcrumbs/Breadcrumbs.tsx b/src/modules/shared/Breadcrumbs/Breadcrumbs.tsx new file mode 100644 index 0000000000..c9e06a53e5 --- /dev/null +++ b/src/modules/shared/Breadcrumbs/Breadcrumbs.tsx @@ -0,0 +1,64 @@ +import './Breadcrumbs.scss'; +import { useContext } from 'react'; +import { Link } from 'react-router-dom'; +import { Icon } from '../Icon'; +import { iconsObject } from '../../../constants/iconsObject'; +import { GlobalContext } from '../../../store/GlobalContext'; + +type Props = { + productType: string; + productName?: string; +}; + +export const Breadcrumbs: React.FC = ({ productType, productName }) => { + const { theme } = useContext(GlobalContext); + + const normalizeProductsType = + productType && productType.charAt(0).toUpperCase() + productType.slice(1); + + return ( +
+
+ + {theme === 'light' ? ( + + ) : ( + + )} + + + + {theme === 'light' ? ( + + ) : ( + + )} + + + {productName ? ( + <> + + + {normalizeProductsType} + + + + {theme === 'light' ? ( + + ) : ( + + )} + + {productName} + + ) : ( + {normalizeProductsType} + )} +
+
+ ); +}; diff --git a/src/modules/shared/Breadcrumbs/index.ts b/src/modules/shared/Breadcrumbs/index.ts new file mode 100644 index 0000000000..ce977548b1 --- /dev/null +++ b/src/modules/shared/Breadcrumbs/index.ts @@ -0,0 +1 @@ +export * from './Breadcrumbs'; diff --git a/src/modules/shared/ButtonBack/ButtonBack.scss b/src/modules/shared/ButtonBack/ButtonBack.scss new file mode 100644 index 0000000000..aef96b8bef --- /dev/null +++ b/src/modules/shared/ButtonBack/ButtonBack.scss @@ -0,0 +1,23 @@ +@import '../../../styles/main'; + +.button { + margin-block: 24px 16px; + width: 66px; + height: 16px; + border: none; + background-color: var(--color-bg); + display: flex; + align-items: center; + gap: 4px; + cursor: pointer; + font-family: Mont-SemiBold, sans-serif; + font-size: 12px; + font-weight: 700; + color: var(--color-gray-primary); + + @include on-tablet { + margin-top: 40px; + } + + +} diff --git a/src/modules/shared/ButtonBack/ButtonBack.tsx b/src/modules/shared/ButtonBack/ButtonBack.tsx new file mode 100644 index 0000000000..78b8865c29 --- /dev/null +++ b/src/modules/shared/ButtonBack/ButtonBack.tsx @@ -0,0 +1,27 @@ +import './ButtonBack.scss'; +import React, { useCallback, useContext } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Icon } from '../Icon'; +import { iconsObject } from '../../../constants/iconsObject'; +import { GlobalContext } from '../../../store/GlobalContext'; + +export const ButtonBack: React.FC = () => { + const { theme } = useContext(GlobalContext); + + const navigate = useNavigate(); + + const handleBack = useCallback(() => { + navigate(-1); + }, [navigate]); + + return ( +
+ {theme === 'light' ? ( + + ) : ( + + )} + Back +
+ ); +}; diff --git a/src/modules/shared/ButtonBack/index.ts b/src/modules/shared/ButtonBack/index.ts new file mode 100644 index 0000000000..4a54f127f8 --- /dev/null +++ b/src/modules/shared/ButtonBack/index.ts @@ -0,0 +1 @@ +export * from './ButtonBack'; diff --git a/src/modules/shared/Dropdown/Dropdown.scss b/src/modules/shared/Dropdown/Dropdown.scss new file mode 100644 index 0000000000..d4a8e86df5 --- /dev/null +++ b/src/modules/shared/Dropdown/Dropdown.scss @@ -0,0 +1,98 @@ +@import '../../../styles/main'; + +.dropdown { + position: relative; + width: 100%; +} + +.dropdown__label { + font-family: Mont-SemiBold, sans-serif; + font-size: 12px; + font-weight: 700; + color: var(--color-gray-secondary); +} + +.dropdown__container { + display: flex; + align-items: center; + font-size: 14px; + font-weight: 500; + line-height: 21px; + color: var(--color-gray-secondary); +} + +.dropdown__icon { + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.3s ease; +} + +.dropdown__options { + position: absolute; + top: 110%; + left: 0; + width: calc(100% - 2px); + list-style: none; + background-color: var(--color-bg); + border: 1px solid var(--color-gray-icons-placeholders); + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); + max-height: 200px; + overflow-y: auto; + transition: all 0.3s ease-in-out; + z-index: 1; +} + +.dropdown__option { + padding: 8px; + cursor: pointer; + font-family: Mont-Regular, sans-serif; + font-size: 14px; + line-height: 21px; + color: var(--color-gray-secondary); + + &:hover { + color: var(--color-gray-primary); + background-color: var(--color-hover); + } +} + +body.theme_dark .dropdown__option:hover { + color: var(--color-gray-primary); + background-color: var(--color-surface-2); +} + +.dropdown__button { + height: 40px; + padding-inline: 12px; + background: none; + border: 1px solid var(--color-gray-icons-placeholders); + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + font-family: Mont-SemiBold, sans-serif; + font-size: 14px; + font-weight: 700; + line-height: 21px; + color: var(--color-gray-primary); + transition: border 0.3s; + + &:focus { + border-color: var(--color-accent); + } + + &:not(.dropdown__button--open) { + border-color: var(--color-gray-icons-placeholders); + } + + &:hover:not(.dropdown__button--open) { + border-color: var(--color-gray-secondary); + } +} + +.dropdown__button--open { + border-color: var(--color-accent); + background-color: var(--color-surface-2); +} diff --git a/src/modules/shared/Dropdown/Dropdown.tsx b/src/modules/shared/Dropdown/Dropdown.tsx new file mode 100644 index 0000000000..c11a782b1f --- /dev/null +++ b/src/modules/shared/Dropdown/Dropdown.tsx @@ -0,0 +1,79 @@ +import './Dropdown.scss'; +import React, { useEffect, useRef, useState } from 'react'; +import { Icon } from '../Icon'; +import { iconsObject } from '../../../constants/iconsObject'; + +type DropdownProps = { + label: string; + selected: string; + options: string[]; + onChange: (value: string) => void; +}; + +export const Dropdown: React.FC = ({ + label, + selected, + options, + onChange, +}) => { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + const handleButtonClick = () => setIsOpen(prevState => !prevState); + + const handleOptionClick = (value: string) => { + onChange(value); + setIsOpen(false); + }; + + return ( +
+ {label} +
+ + {isOpen && ( +
    + {options.map(option => ( +
  • handleOptionClick(option)} + > + {option} +
  • + ))} +
+ )} +
+
+ ); +}; diff --git a/src/modules/shared/Dropdown/index.ts b/src/modules/shared/Dropdown/index.ts new file mode 100644 index 0000000000..2f29bad4e6 --- /dev/null +++ b/src/modules/shared/Dropdown/index.ts @@ -0,0 +1 @@ +export * from './Dropdown'; diff --git a/src/modules/shared/Footer/Footer.scss b/src/modules/shared/Footer/Footer.scss new file mode 100644 index 0000000000..5bfdfe79ff --- /dev/null +++ b/src/modules/shared/Footer/Footer.scss @@ -0,0 +1,127 @@ +@import '../../../styles/main'; + +.footer { + box-shadow: 0 -1px 0 0 var(--color-gray-elements); +} + +.footer__container { + display: flex; + flex-direction: column; + gap: 32px; + padding-block: 32px; + margin-top: 64px; + + @include content-padding-inline; + + @include on-tablet { + flex-direction: row; + justify-content: space-between; + height: 96px; + align-items: center; + margin-top: 64px; + padding-block: 0; + gap: 0; + } + + @include on-desktop { + justify-content: space-between; + margin-top: 80px; + } +} + +.footer__logo-container { + height: 32px; + + @include on-tablet { + flex-grow: 1; + } +} + +.footer__logo { + width: 89px; + height: 32px; +} + +.footer__items { + display: flex; + flex-direction: column; + gap: 16px; + flex-shrink: 0; + + @include on-tablet { + flex-direction: row; + gap: 0; + flex-grow: 1; + justify-content: space-between; + } + + @include on-desktop { + display: flex; + justify-content: space-between; + align-items: center; + } +} + +.footer__link { + text-transform: uppercase; + text-decoration: none; + font-family: Mont-Bold, sans-serif; + font-size: 12px; + font-weight: 800; + line-height: 11px; + letter-spacing: 0.04em; + color: var(--color-gray-secondary); + + @include hover(color, var(--color-gray-primary)); +} + +.footer__block { + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + + @include on-tablet { + display: flex; + align-items: center; + justify-content: end; + flex-grow: 1; + } +} + +.footer__button { + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid var(--color-gray-icons-placeholders); + background-color: var(--color-bg); + box-sizing: border-box; + margin-left: 16px; + transform: rotate(90deg); + + transition: border-color 0.3s ease; + + + &:hover { + border: 1px solid var(--color-gray-primary); + background-color: var(--color-hover); + } +} + +.footer__button-title { + font-family: Mont-SemiBold, sans-serif; + font-size: 12px; + font-weight: 700; + line-height: 15.34px; + color: var(--color-gray-secondary); +} + +body.theme_dark .footer__button { + border: none; + background-color: var(--color-surface-2); + + @include hover(background-color, var(--color-gray-icons-placeholders)) +} diff --git a/src/modules/shared/Footer/Footer.tsx b/src/modules/shared/Footer/Footer.tsx new file mode 100644 index 0000000000..e4309062c2 --- /dev/null +++ b/src/modules/shared/Footer/Footer.tsx @@ -0,0 +1,68 @@ +import './Footer.scss'; +import React, { useContext } from 'react'; +import { Link } from 'react-router-dom'; +import { Icon } from '../Icon'; +import { iconsObject } from '../../../constants/iconsObject'; +import { GlobalContext } from '../../../store/GlobalContext'; + +export const Footer: React.FC = () => { + const { theme } = useContext(GlobalContext); + + const backToTop = () => { + window.scrollTo({ + top: 0, + behavior: 'smooth', + }); + }; + + return ( +
+
+ + {theme === 'light' ? ( + Nice Gadgets + ) : ( + Nice Gadgets + )} + + +
+ + Github + + + Contacts + + + rights + +
+ +
+ Back to top + +
+
+
+ ); +}; diff --git a/src/modules/shared/Footer/index.ts b/src/modules/shared/Footer/index.ts new file mode 100644 index 0000000000..ddcc5a9cd1 --- /dev/null +++ b/src/modules/shared/Footer/index.ts @@ -0,0 +1 @@ +export * from './Footer'; diff --git a/src/modules/shared/Header/Header.scss b/src/modules/shared/Header/Header.scss new file mode 100644 index 0000000000..396b02339a --- /dev/null +++ b/src/modules/shared/Header/Header.scss @@ -0,0 +1,297 @@ +@import '../../../styles/main'; +@import '../../../styles/utils/variables'; + +.header { + height: 48px; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + gap: 16px; + box-shadow: 0 1px 0 0 var(--color-gray-elements); + background-color: var(--color-bg); + + @include on-desktop { + height: 64px; + } +} + +.header__logo { + width: 64px; + height: 22px; + + @include on-desktop { + width: 80px; + height: 28px; + } +} + +.header__logo-container { + display: flex; + height: 100%; + width: 96px; + align-items: center; + justify-content: center; + flex-shrink: 0; + + @include on-desktop { + width: 128px; + } +} + +.header__menu { + display: none; + + @include on-tablet { + display: flex; + } +} + +.header__list { + list-style: none; + display: flex; + align-items: center; + gap: 24px; + text-decoration: none; + + @include on-desktop { + gap: 64px; + } +} + +.header__item { + position: relative; + display: inline-block; + text-transform: uppercase; + text-decoration: none; + line-height: 48px; + font-family: Mont-Bold, sans-serif; + color: var(--color-gray-secondary); + font-size: 12px; + font-weight: 800; + letter-spacing: 0.04em; + box-sizing: border-box; + + @include on-desktop { + line-height: 64px; + } +} + +.header__item:hover { + color: var(--color-gray-primary); +} + +.header__item::after, +.header__icon::after { + content: ''; + position: absolute; + display: block; + height: 0; + width: 100%; + background-color: var(--color-gray-primary); + transition: height 0.3s ease, transform 0.3s ease; + transform-origin: left; + bottom: 0; + box-sizing: border-box; +} + +.header__item:hover::after, +.header__icon:hover::after { + height: 2px; + transform: scale(1); +} + +.header__item--active { + color: var(--color-gray-primary); +} + +.header__item--active::after { + height: 2px; + transition: none; +} + +.header__search-wrapper { + position: relative; + display: flex; + align-items: center; + margin-right: 16px; +} + +.header__search-input { + height: 48px; + box-shadow: -1px 0 0 0 var(--color-gray-elements); + width: 100%; + padding-inline: 8px; + background-color: var(--color-bg); + font-family: Mont-Regular, sans-serif; + color: var(--color-gray-primary); + font-size: 14px; + font-weight: 400; + line-height: 21px; + outline: none; + border: none; + + @include on-tablet { + flex-grow: 1; + } + + @include on-desktop { + height: 64px; + } + + .header__search-input::placeholder { + font-family: Mont-Regular, sans-serif; + color: var(--color-gray-secondary); + font-size: 12px; + font-weight: 400; + line-height: 21px; + transition: opacity 0.3s ease; + } + + .header__search-input:focus::placeholder { + opacity: 0; + } +} + +.header__switch-theme { + background: none; + border: none; + cursor: pointer; + + order: 1; + color: var(--color-gray-secondary); + font-size: 12px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; + text-decoration: none; + line-height: 48px; + font-family: Mont-Bold, sans-serif; + box-sizing: border-box; + flex-shrink: 0; + + position: relative; + display: inline-block; + + @include on-tablet { + order: 0; + } + + @include on-desktop { + line-height: 64px; + } + + &:hover { + color: var(--color-gray-primary); + } +} + +.header__buttons-right { + height: 100%; + flex-grow: 1; + display: flex; + justify-content: flex-end; + align-items: center; +} + +.header__buttons-wrapper { + display: none; + + @include on-tablet { + display: flex; + } +} + +.header__buttons-wrapper--bottom { + display: flex; + position: fixed; + bottom: 0; + left: 0; + right: 0; + justify-content: space-between; + background-color: var(--color-bg); + height: 64px; + border-top: 1px solid var(--color-gray-elements); +} + +.header__icon { + width: 48px; + height: 48px; + box-shadow: -1px 0 0 0 var(--color-gray-elements); + display: flex; + align-items: center; + justify-content: center; + position: relative; + + + @include on-tablet { + display: flex; + } + + @include on-desktop { + width: 64px; + height: 64px; + } +} + +.header__buttons-wrapper--bottom .header__icon { + width: 100%; + height: 100%; + align-items: center; + justify-content: center; +} + +.header__icon:hover { + color: var(--color-gray-primary); + background: var(--color-hover); +} + +body.theme_dark .header__icon:hover { + @include hover(background-color, var(--color-gray-icons-placeholders)) +} + +.header__icon--active::after { + content: ''; + position: absolute; + width: 64px; + width: 100%; + background-color: var(--color-gray-primary); +} + +.header__icon-wrapper { + width: 28px; + height: 28px; + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.header__quantity { + position: absolute; + top: 0; + right: 0; + background-color: var(--color-red); + color: white; + border: 1px solid white; + border-radius: 50%; + width: 14px; + height: 14px; + display: flex; + justify-content: center; + align-items: center; + + font-family: Mont-Regular, sans-serif; + font-size: 9px; + font-weight: 700; +} + +.header__icon--menu { + display: flex; + order: 2; + + @include on-tablet { + display: none; + } +} + diff --git a/src/modules/shared/Header/Header.tsx b/src/modules/shared/Header/Header.tsx new file mode 100644 index 0000000000..0bab710f3e --- /dev/null +++ b/src/modules/shared/Header/Header.tsx @@ -0,0 +1,183 @@ +import './Header.scss'; +import classNames from 'classnames'; +import React, { useContext, useState, useEffect, useMemo } from 'react'; +import { Link, NavLink, useLocation, useSearchParams } from 'react-router-dom'; +import { GlobalContext } from '../../../store/GlobalContext'; +import { iconsObject } from '../../../constants/iconsObject'; +import { Icon } from '../Icon'; +import { navLinks } from '../../../constants/navLinks'; +import debounce from 'lodash.debounce'; +import { getSearchWith } from '../../../utils/searchHelper'; + +const getActiveItem = ({ isActive }: { isActive: boolean }) => + classNames('header__item', { 'header__item--active': isActive }); + +const getActiveIcon = ({ isActive }: { isActive: boolean }) => + classNames('header__icon', { 'header__icon--active': isActive }); + +export const Header: React.FC = () => { + const { cart, favorites, toggleMenu, isMenuOpen, theme, toggleTheme } = + useContext(GlobalContext); + + const location = useLocation(); + + const [searchParams, setSearchParams] = useSearchParams(); + const [query, setQuery] = useState(''); + + const totalQuantity = cart.reduce((sum, item) => sum + item.quantity, 0); + const totalFavorites = favorites.length; + const allowedPaths = ['/phones', '/tablets', '/accessories', '/favorites']; + + const applyQuery = useMemo(() => { + return debounce((value: string) => { + const updatedParams = getSearchWith(searchParams, { + query: value, + }); + + setSearchParams(updatedParams); + }, 1000); + }, [searchParams, setSearchParams]); + + useEffect(() => { + setQuery(''); + }, [location.pathname]); + + const handleInputChange = (event: React.ChangeEvent) => { + const newQuery = event.target.value.trim(); + + setQuery(event.target.value); + + if (newQuery.length > 0) { + applyQuery(event.target.value); + } else { + setSearchParams(prevParams => { + const newParams = new URLSearchParams(prevParams); + + newParams.delete('query'); + + return newParams; + }); + } + }; + + const clearInput = () => { + setQuery(''); + setSearchParams(prevParams => { + const newParams = new URLSearchParams(prevParams); + + newParams.delete('query'); + + return newParams; + }); + }; + + return ( +
+ + {theme === 'light' ? ( + Nice Gadgets + ) : ( + Nice Gadgets + )} + + +
+
+ {navLinks.map(link => ( + + {link.title} + + ))} +
+
+ +
+ {allowedPaths.includes(location.pathname) && ( +
+ + {query ? ( +
+ {theme === 'light' ? ( + + ) : ( + + )} +
+ ) : theme === 'light' ? ( + + ) : ( + + )} +
+ )} +
+ {isMenuOpen ? ( + theme === 'light' ? ( + + ) : ( + + ) + ) : theme === 'light' ? ( + + ) : ( + + )} +
+ + + +
{ + if (isMenuOpen) { + toggleMenu(); + } + }} + > + +
+ {totalFavorites > 0 && ( + {totalFavorites} + )} + {theme === 'light' ? ( + + ) : ( + + )} +
+
+ + +
+ {theme === 'light' ? ( + + ) : ( + + )} + {totalQuantity > 0 && ( + {totalQuantity} + )} +
+
+
+
+
+ ); +}; diff --git a/src/modules/shared/Header/index.ts b/src/modules/shared/Header/index.ts new file mode 100644 index 0000000000..266dec8a1b --- /dev/null +++ b/src/modules/shared/Header/index.ts @@ -0,0 +1 @@ +export * from './Header'; diff --git a/src/modules/shared/Icon/Icon.scss b/src/modules/shared/Icon/Icon.scss new file mode 100644 index 0000000000..201d10644b --- /dev/null +++ b/src/modules/shared/Icon/Icon.scss @@ -0,0 +1,7 @@ +@import '../../../styles/main'; + +.icon__picture { + display: flex; + width: 16px; + height: 16px; +} diff --git a/src/modules/shared/Icon/Icon.tsx b/src/modules/shared/Icon/Icon.tsx new file mode 100644 index 0000000000..61a15973e5 --- /dev/null +++ b/src/modules/shared/Icon/Icon.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import './Icon.scss'; + +type IconData = { + title: string; + path: string; +}; + +type Props = { + icon: IconData; +}; + +export const Icon: React.FC = ({ icon }) => { + return {icon.title}; +}; diff --git a/src/modules/shared/Icon/index.ts b/src/modules/shared/Icon/index.ts new file mode 100644 index 0000000000..e263cc0e6d --- /dev/null +++ b/src/modules/shared/Icon/index.ts @@ -0,0 +1 @@ +export * from './Icon'; diff --git a/src/modules/shared/Loader/Loader.scss b/src/modules/shared/Loader/Loader.scss new file mode 100644 index 0000000000..788b2397c9 --- /dev/null +++ b/src/modules/shared/Loader/Loader.scss @@ -0,0 +1,44 @@ +// :root { +// --loading-grey: #ededed; +// } + +:root body.theme_dark { + --loading-grey: #0F1121; +} + +.content { + position: fixed; + top: 0; + left: 0; + height: 100vh; + width: 100vw; + background: linear-gradient( + 100deg, + rgba(255, 255, 255, 0) 40%, + rgba(117, 117, 117, 0.5) 50%, + rgba(255, 255, 255, 0) 60% + ) white; + background-size: 200% 100%; + background-position-x: 180%; + animation: 0.5s loading ease-in-out infinite; + z-index: 1; +} + +body.theme_dark .content { + background: linear-gradient( + 100deg, + rgba(255, 255, 255, 0) 40%, + rgba(255, 255, 255, 0.5) 50%, + rgba(255, 255, 255, 0) 60% + ) var(--loading-grey); + background-size: 200% 100%; + background-position-x: 180%; + animation: 0.5s loading ease-in-out infinite; + z-index: 1; +} + +@keyframes loading { + to { + background-position-x: -20%; + } +} diff --git a/src/modules/shared/Loader/Loader.tsx b/src/modules/shared/Loader/Loader.tsx new file mode 100644 index 0000000000..b4c5e2735c --- /dev/null +++ b/src/modules/shared/Loader/Loader.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import './Loader.scss'; + +export const Loader: React.FC = () => { + return ( +
+
+
+ ); +}; diff --git a/src/modules/shared/Loader/index.ts b/src/modules/shared/Loader/index.ts new file mode 100644 index 0000000000..d5ce981151 --- /dev/null +++ b/src/modules/shared/Loader/index.ts @@ -0,0 +1 @@ +export * from './Loader'; diff --git a/src/modules/shared/Menu/Menu.scss b/src/modules/shared/Menu/Menu.scss new file mode 100644 index 0000000000..641a18adc9 --- /dev/null +++ b/src/modules/shared/Menu/Menu.scss @@ -0,0 +1,69 @@ +@import '../../../styles/main'; + +.menu { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + + position: fixed; + top: 48px; + bottom: 64px; + width: 100%; + background-color: var(--color-bg); + transform: translateX(-100%); + transition: transform 0.3s ease, opacity 0.3s ease; + opacity: 0; + z-index: 1; + pointer-events: none; +} + +.menu__list { + list-style: none; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 24px; +} + +.menu__link { + position: relative; + font-family: Molt-Bold, sans-serif; + font-weight: 800; + font-size: 12px; + letter-spacing: 0.04em; + text-align: center; + text-transform: uppercase; + color: var(--color-gray-secondary); + text-decoration: none; + display: flex; + + &:hover { + color: var(--color-gray-primary); + } +} + +.menu__link::after { + content: ''; + position: absolute; + display: block; + height: 0; + width: 100%; + background-color: var(--color-gray-primary); + bottom: -8px; + left: 0; + transition: height 0.3s ease; + box-sizing: content-box; +} + +.menu__link:hover::after { + height: 1px; +} + +.menu--open { + transform: translateX(0); + opacity: 1; + pointer-events: all; +} diff --git a/src/modules/shared/Menu/Menu.tsx b/src/modules/shared/Menu/Menu.tsx new file mode 100644 index 0000000000..8075e2f3f1 --- /dev/null +++ b/src/modules/shared/Menu/Menu.tsx @@ -0,0 +1,40 @@ +import React, { useContext, useEffect } from 'react'; +import './Menu.scss'; +import { Link } from 'react-router-dom'; +import { GlobalContext } from '../../../store/GlobalContext'; +import { navLinks } from '../../../constants/navLinks'; + +export const Menu: React.FC = () => { + const { isMenuOpen, toggleMenu } = useContext(GlobalContext); + + useEffect(() => { + const handleResize = () => { + if (window.innerWidth > 640 && isMenuOpen) { + toggleMenu(); + } + }; + + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + }; + }, [isMenuOpen, toggleMenu]); + + return ( + + ); +}; diff --git a/src/modules/shared/Menu/index.ts b/src/modules/shared/Menu/index.ts new file mode 100644 index 0000000000..629d3d0aa1 --- /dev/null +++ b/src/modules/shared/Menu/index.ts @@ -0,0 +1 @@ +export * from './Menu'; diff --git a/src/modules/shared/Pagination/Pagination.scss b/src/modules/shared/Pagination/Pagination.scss new file mode 100644 index 0000000000..a51d3da72b --- /dev/null +++ b/src/modules/shared/Pagination/Pagination.scss @@ -0,0 +1,79 @@ +@import '../../../styles/main'; + +.pagination { + margin: 40px auto 80px; + height: 32px; + display: flex; + gap: 16px; + justify-content: center; + align-items: center; + margin-left: auto; + margin-right: auto; +} + +.pagination__button-container { + display: flex; + gap: 8px; +} + +.pagination__button-page, +.pagination__button { + padding: 0; + margin: 0; + position: relative; + width: 32px; + height: 32px; + border: 1px solid var(--color-gray-elements); + background-color: var(--color-bg); + box-sizing: border-box; + cursor: pointer; + color: var(--color-gray-primary); + font-family: Mont-Regular, sans-serif; + font-size: 14px; + font-weight: 600; + display: flex; + justify-content: center; + align-items: center; + transition: background-color 0.3s, border-color 0.3s; +} + +body.theme_dark .pagination__button-page, +body.theme_dark .pagination__button { + border: none; + background-color: var(--color-surface-1); + color: var(--color-gray-primary); +} + +.pagination__button-page:not(.pagination__button-page--active):hover { + border-color: var(--color-gray-primary); +} + +body.theme_dark .pagination__button-page:not(.pagination__button-page--active):hover { + background-color: var(--color-gray-elements); +} + +.pagination__button:not(.pagination__button--disabled):hover { + border-color: var(--color-gray-primary); +} + +body.theme_dark .pagination__button:not(.pagination__button--disabled):hover { + background-color: var(--color-gray-elements); +} + +.pagination__button-page--active { + background-color: var(--color-gray-primary); + color: var(--color-bg); +} + +body.theme_dark .pagination__button-page--active { + background-color: var(--color-accent); +} + +.pagination__button--disabled { + cursor: not-allowed; +} + +body.theme_dark .pagination__button--disabled { + background-color: var(--color-bg); + border: 1px solid var(--color-gray-elements); +} diff --git a/src/modules/shared/Pagination/Pagination.tsx b/src/modules/shared/Pagination/Pagination.tsx new file mode 100644 index 0000000000..6e91fda4b8 --- /dev/null +++ b/src/modules/shared/Pagination/Pagination.tsx @@ -0,0 +1,93 @@ +import React, { useContext } from 'react'; +import './Pagination.scss'; +import { Icon } from '../Icon'; +import { iconsObject } from '../../../constants/iconsObject'; +import classNames from 'classnames'; +import { GlobalContext } from '../../../store/GlobalContext'; + +interface Props { + total: number; + perPage: number; + currentPage: number; + onPageChange: (page: number) => void; +} + +export const Pagination: React.FC = ({ + total, + perPage, + currentPage, + onPageChange, +}) => { + const { theme } = useContext(GlobalContext); + + const totalPages = Math.ceil(total / perPage); + const pageLimit = 4; + + let startPage = Math.max(1, currentPage - 1); + let endPage = Math.min(totalPages, currentPage + 2); + + if (endPage - startPage < pageLimit) { + if (startPage === 1) { + endPage = Math.min(totalPages, startPage + pageLimit - 1); + } else { + startPage = Math.max(1, endPage - pageLimit + 1); + } + } + + return ( +
+ +
+ {Array.from({ length: endPage - startPage + 1 }, (_, index) => ( + + ))} +
+ +
+ ); +}; diff --git a/src/modules/shared/Pagination/index.ts b/src/modules/shared/Pagination/index.ts new file mode 100644 index 0000000000..e016c96b72 --- /dev/null +++ b/src/modules/shared/Pagination/index.ts @@ -0,0 +1 @@ +export * from './Pagination'; diff --git a/src/modules/shared/ProductCard/ProductCard.scss b/src/modules/shared/ProductCard/ProductCard.scss new file mode 100644 index 0000000000..3cbaad5943 --- /dev/null +++ b/src/modules/shared/ProductCard/ProductCard.scss @@ -0,0 +1,206 @@ +@import '../../../styles/utils/mixins'; +@import '../../../styles/main'; +@import '../../../styles/utils/variables'; + +body.theme_dark .productCard { + border: none; + background-color: var(--color-surface-1); +} + +.productCard__container { + cursor: pointer; + text-decoration: none; + width: 100%; + height: 100%; + padding: 32px; + box-sizing: border-box; + border: 1px solid var(--color-gray-elements); + display: flex; + flex-direction: column; + gap: 8px; + transition: border 0.3s; + + @include hover(border-color, var(--color-gray-primary)); +} + +.productCard__container-photo { + width: 148px; + height: 129px; + margin: auto; + transition: transform 0.3s; + + @include on-tablet { + width: 173px; + height: 202px; + } + + @include on-desktop { + width: 208px; + height: 196px; + } +} + +.productCard__photo { + width: 100%; + height: 100%; + object-fit: contain; +} + +.productCard__container-title { + display: flex; + align-items: flex-end; +} + +.productCard__title { + font-family: Mont-Regular, sans-serif; + color: var(--color-gray-primary); + font-size: 14px; + box-sizing: border-box; + font-weight: 600; + padding-top: 16px; + height: 58px; +} + +.productCard__container-price { + height: 31px; + display: flex; + gap: 8px; +} + +.productCard__price { + &-regular { + font-family: Mont-Regular, sans-serif; + color: var(--color-gray-secondary); + font-size: 22px; + font-weight: 500; + line-height: 28.12px; + text-decoration-line: line-through; + } + + &-discount, + &-regular-without-discount { + font-family: Mont-Bold, sans-serif; + color: var(--color-gray-primary); + font-size: 22px; + font-weight: 800; + line-height: 30.8px; + } +} + +.productCard__divider { + display: block; + width: 100%; + height: 1px; + background-color: var(--color-gray-elements); +} + +.productCard__container-specifications { + display: flex; + flex-direction: column; + height: 77px; + justify-content: center; + gap: 8px; +} + +.productCard__block { + width: 100%; + height: 15px; + display: flex; + justify-content: space-between; + + font-family: Mont-SemiBold, sans-serif; + font-size: 12px; + font-weight: 600; +} + +.productCard__info { + color: var(--color-gray-secondary); +} + +.productCard__value { + color: var(--color-gray-primary); +} + +.productCard__container-buttons { + width: 100%; + display: flex; + gap: 8px; +} + +.productCard__button { + border: none; + cursor: pointer; + font-family: Mont-Regular, sans-serif; + color: var(--color-bg); + font-size: 14px; + font-weight: 700; + line-height: 21px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; +} + +.productCard__button-card { + background-color: var(--color-gray-primary); + width: 100%; + + @include hover(box-shadow, 0 3px 13px 0 #17203166); +} + +.productCard__button-card--active { + background-color: var(--color-bg); + color: var(--color-green); + border: 1px solid var(--color-gray-elements); + pointer-events: none; + + &:hover { + box-shadow: none; + border-color: inherit; + } +} + +body.theme_dark .productCard__button-card { + color: var(--color-gray-primary); + background-color: var(--color-accent); + + @include hover(background-color, var(--color-accent-hover)); +} + +body.theme_dark .productCard__button-card--active { + color: var(--color-gray-primary); + background-color: var(--color-surface-2); + border: none; +} + +.productCard__button-favorites { + background-color: var(--color-bg); + width: 40px; + flex-shrink: 0; + border: 1px solid var(--color-gray-icons-placeholders); + + @include hover(border-color, var(--color-gray-primary)); +} + +.productCard__button-favorites--active { + border: 1px solid var(--color-gray-elements); +} + +body.theme_dark .productCard__button-favorites { + color: var(--color-gray-primary); + background-color: var(--color-surface-2); + border: none; +} + +body.theme_dark .productCard__button-favorites:hover { + background-color: var(--color-gray-icons-placeholders); +} + +body.theme_dark .productCard__button-favorites--active { + background-color: var(--color-surface-1); + border: 1px solid var(--color-gray-elements); +} + +.productCard:hover .productCard__container-photo { + transform: scale(1.05); +} diff --git a/src/modules/shared/ProductCard/ProductCard.tsx b/src/modules/shared/ProductCard/ProductCard.tsx new file mode 100644 index 0000000000..3df8ac14d5 --- /dev/null +++ b/src/modules/shared/ProductCard/ProductCard.tsx @@ -0,0 +1,119 @@ +import './ProductCard.scss'; +import React, { useContext } from 'react'; +import { Link } from 'react-router-dom'; +import classNames from 'classnames'; +import { Product } from '../../../types/Product'; +import { GlobalContext } from '../../../store/GlobalContext'; +import { Icon } from '../Icon'; +import { iconsObject } from '../../../constants/iconsObject'; + +type Props = { + product: Product; + displayType: 'fullPrice' | 'with-discount'; +}; + +export const ProductCard: React.FC = ({ product, displayType }) => { + const { cart, favorites, toggleFavorites, addToCart, theme } = + useContext(GlobalContext); + + const isInCart = cart.some(item => item.id === product.itemId); + const isFavorites = favorites.some(item => item.itemId === product.itemId); + + return ( +
+ +
+ Product's photo +
+ +
+ {product.name} +
+ +
+ {displayType === 'fullPrice' && ( + + {`$${product.fullPrice}`} + + )} + + {displayType === 'with-discount' && ( + <> + + {`$${product.price}`} + + + {`$${product.fullPrice}`} + + + )} +
+ +
+ +
+
+ Screen + {product.screen} +
+
+ Capacity + {product.capacity} +
+
+ RAM + {product.ram} +
+
+ +
+ + +
+ +
+ ); +}; diff --git a/src/modules/shared/ProductCard/index.ts b/src/modules/shared/ProductCard/index.ts new file mode 100644 index 0000000000..7ce031c382 --- /dev/null +++ b/src/modules/shared/ProductCard/index.ts @@ -0,0 +1 @@ +export * from './ProductCard'; diff --git a/src/modules/shared/ProductsList/ProductsList.scss b/src/modules/shared/ProductsList/ProductsList.scss new file mode 100644 index 0000000000..53112068c6 --- /dev/null +++ b/src/modules/shared/ProductsList/ProductsList.scss @@ -0,0 +1,35 @@ +@import '../../../styles/main'; + +.productsList { + margin-top: 24px; + gap: 40px 16px; + + @include page-grid; + + @media (max-width: 640px) { + .productItem { + grid-column: 1 / -1; + } + } + + @include on-tablet { + .productItem { + grid-column: span 6; + } + } + + @media (min-width: 768px) { + .productItem { + grid-column: span 4; + } + } + + @include on-desktop { + .productItem { + grid-column: span 6; + } + } +} + + + diff --git a/src/modules/shared/ProductsList/ProductsList.tsx b/src/modules/shared/ProductsList/ProductsList.tsx new file mode 100644 index 0000000000..5bf701df46 --- /dev/null +++ b/src/modules/shared/ProductsList/ProductsList.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import './ProductsList.scss'; +import { Product } from '../../../types/Product'; +import { ProductCard } from '../ProductCard'; + +type Props = { + products: Product[]; + displayType: 'fullPrice' | 'with-discount'; +}; + +export const ProductsList: React.FC = ({ products, displayType }) => ( +
+ {products.map(product => ( +
+ +
+ ))} +
+); diff --git a/src/modules/shared/ProductsList/index.ts b/src/modules/shared/ProductsList/index.ts new file mode 100644 index 0000000000..09f9887f27 --- /dev/null +++ b/src/modules/shared/ProductsList/index.ts @@ -0,0 +1 @@ +export * from './ProductsList'; diff --git a/src/modules/shared/ProductsSlider/ProductsSlider.scss b/src/modules/shared/ProductsSlider/ProductsSlider.scss new file mode 100644 index 0000000000..01f0cd2380 --- /dev/null +++ b/src/modules/shared/ProductsSlider/ProductsSlider.scss @@ -0,0 +1,112 @@ +@import '../../../styles/main'; + +.productsSlider { + padding-left: 16px; + + @include on-desktop { + margin-block: 80px; + + @include content-padding-inline; + } +} + +.productsSlider__container-top { + padding-right: 16px; + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; + gap: 72px; +} + +.productsSlider__title { + font-size: 22px; + font-family: Mont-Bold, sans-serif; + font-weight: 800; + letter-spacing: -0.01em; + color: var(--color-gray-primary); + + @include on-tablet { + font-size: 32px; + } +} + +.productsSlider__buttons { + display: flex; + gap: 16px; +} + +.productsSlider__button { + width: 32px; + height: 32px; + border: 1px solid var(--color-gray-icons-placeholders); + background-color: var(--color-bg); + box-sizing: border-box; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: border 0.3s, background-color 0.3s; +} + +.productsSlider__button--disabled { + cursor: not-allowed; + border: 1px solid var(--color-gray-elements); + background-color: inherit; +} + +.productsSlider__button--disabled:hover { + border: 1px solid var(--color-gray-elements); +} + +body.theme_dark .productsSlider__button { + border: none; + background-color: var(--color-surface-2); +} + +.productsSlider__button:not(.productsSlider__button--disabled):hover { + border: 1px solid var(--color-gray-primary); + background-color: var(--color-hover); +} + +body.theme_dark .productsSlider__button:not(.productsSlider__button--disabled):hover { + border: none; + background-color: var(--color-gray-icons-placeholders); +} + +body.theme_dark .productsSlider__button--disabled { + border: 1px solid var(--color-gray-elements); + background-color: inherit; +} + +.productsSlider__viewport { + overflow: hidden; + width: 100%; + + @include on-desktop { + width: 1136px; + } +} + +.productsSlider__track { + display: flex; + transition: transform 0.5s ease-in-out; +} + +.productsSlider__item { + flex: 0 0 auto; + margin-right: 16px; + + width: 212px; + height: 442px; + + @include on-tablet { + width: 237px; + height: 515px; + } + + @include on-desktop { + width: 272px; + height: 509px; + } +} diff --git a/src/modules/shared/ProductsSlider/ProductsSlider.tsx b/src/modules/shared/ProductsSlider/ProductsSlider.tsx new file mode 100644 index 0000000000..3f77b048c2 --- /dev/null +++ b/src/modules/shared/ProductsSlider/ProductsSlider.tsx @@ -0,0 +1,128 @@ +import './ProductsSlider.scss'; +import classNames from 'classnames'; +import React, { useState, useEffect, useContext } from 'react'; +import { Product } from '../../../types/Product'; +import { ProductCard } from '../ProductCard'; +import { Icon } from '../Icon'; +import { iconsObject } from '../../../constants/iconsObject'; +import { GlobalContext } from '../../../store/GlobalContext'; + +type Props = { + title: string; + products: Product[]; + displayType: 'fullPrice' | 'with-discount'; +}; + +export const ProductsSlider: React.FC = ({ + title, + products, + displayType, +}) => { + const { theme } = useContext(GlobalContext); + + const [currentIndex, setCurrentIndex] = useState(0); + const [cardWidth, setCardWidth] = useState(272); + const gap = 16; + const totalCardsPerTrack = 4; + + const updateCardWidth = () => { + const screenWidth = window.innerWidth; + + if (screenWidth < 640) { + setCardWidth(212); + } else if (screenWidth < 1200) { + setCardWidth(237); + } else { + setCardWidth(272); + } + }; + + useEffect(() => { + updateCardWidth(); + window.addEventListener('resize', updateCardWidth); + + return () => { + window.removeEventListener('resize', updateCardWidth); + }; + }, []); + + const totalItems = products.length; + const maxIndex = totalItems - totalCardsPerTrack; + + const handleNext = () => { + if (currentIndex < maxIndex) { + setCurrentIndex(prev => prev + 1); + } + }; + + const handlePrev = () => { + if (currentIndex > 0) { + setCurrentIndex(prev => prev - 1); + } + }; + + return ( +
+
+

{title}

+
+
+ {currentIndex === 0 ? ( + theme === 'light' ? ( + + ) : ( + + ) + ) : theme === 'light' ? ( + + ) : ( + + )} +
+
+ {currentIndex === maxIndex ? ( + theme === 'light' ? ( + + ) : ( + + ) + ) : theme === 'light' ? ( + + ) : ( + + )} +
+
+
+ +
+
+ {products.map(phone => ( +
+ +
+ ))} +
+
+
+ ); +}; diff --git a/src/modules/shared/ProductsSlider/index.ts b/src/modules/shared/ProductsSlider/index.ts new file mode 100644 index 0000000000..68d3ea0012 --- /dev/null +++ b/src/modules/shared/ProductsSlider/index.ts @@ -0,0 +1 @@ +export * from './ProductsSlider'; diff --git a/src/store/GlobalContext.tsx b/src/store/GlobalContext.tsx new file mode 100644 index 0000000000..ff9a07b62b --- /dev/null +++ b/src/store/GlobalContext.tsx @@ -0,0 +1,184 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Product } from '../types/Product'; +import { CartProduct } from '../types/CartProduct'; +import { getAllProducts } from '../utils/productApi'; +import { useLocalStorage } from '../hooks/useLocalStorage'; +import { useTheme } from '../hooks/useTheme'; + +type GlobalContextType = { + allProducts: Product[]; + setAllProducts: React.Dispatch>; + cart: CartProduct[]; + setCart: React.Dispatch>; + favorites: Product[]; + setFavorites: React.Dispatch>; + updateQuantity: (id: string, newQuantity: number) => void; + clearShoppingCart: () => void; + isMenuOpen: boolean; + setIsMenuOpen: React.Dispatch>; + toggleMenu: () => void; + toggleFavorites: (currentProduct: Product) => void; + addToCart: (currentProduct: Product) => void; + theme: 'light' | 'dark'; + toggleTheme: () => void; +}; + +export const GlobalContext = React.createContext({ + allProducts: [] as Product[], + setAllProducts: () => {}, + cart: [] as CartProduct[], + setCart: () => {}, + favorites: [] as Product[], + setFavorites: () => {}, + updateQuantity: () => {}, + clearShoppingCart: () => {}, + isMenuOpen: false, + setIsMenuOpen: () => {}, + toggleMenu: () => {}, + toggleFavorites: () => {}, + addToCart: () => {}, + theme: 'light', + toggleTheme: () => {}, +}); + +type Props = { + children: React.ReactNode; +}; + +export const GlobalProvider: React.FC = ({ children }) => { + const { theme, toggleTheme } = useTheme(); + + const [allProducts, setAllProducts] = useState([]); + const [cart, setCart] = useLocalStorage('shoppingCart', []); + const [favorites, setFavorites] = useLocalStorage('favorites', []); + const [isMenuOpen, setIsMenuOpen] = useState(false); + + useEffect(() => { + const fetchAllProducts = () => { + getAllProducts() + .then(fetchedProducts => { + setAllProducts(fetchedProducts); + }) + .catch(error => { + throw new Error(`Error fetching products: ${error.message}`); + }) + .finally(() => {}); + }; + + fetchAllProducts(); + }, []); + + const updateQuantity = useCallback( + (id: string, newQuantity: number) => { + setCart(prevCart => { + const updatedShoppingCart = prevCart + .map(item => + item.id === id ? { ...item, quantity: newQuantity } : item, + ) + .filter(item => item.quantity > 0); + + return updatedShoppingCart; + }); + }, + [setCart], + ); + + const toggleMenu = useCallback(() => { + setIsMenuOpen(prevState => !prevState); + }, []); + + useEffect(() => { + const overflowStyle = isMenuOpen ? 'hidden' : 'auto'; + + document.body.style.overflow = overflowStyle; + + return () => { + document.body.style.overflow = 'auto'; + }; + }, [isMenuOpen]); + + const addToCart = useCallback( + (product: Product) => { + if (product) { + const isInCart = cart.some( + item => item.product.itemId === product.itemId, + ); + + if (!isInCart) { + const newProduct: CartProduct = { + id: product.itemId, + quantity: 1, + product: product, + }; + + setCart(prevCart => [...prevCart, newProduct]); + } + } + }, + [cart, setCart], + ); + + const toggleFavorites = useCallback( + (currentProduct: Product) => { + const isInFavorites = favorites.some( + item => item.itemId === currentProduct.itemId, + ); + + setFavorites(prevFavorites => { + if (isInFavorites) { + return prevFavorites.filter( + item => item.itemId !== currentProduct.itemId, + ); + } else { + return [...prevFavorites, currentProduct]; + } + }); + }, + [favorites, setFavorites], + ); + + const clearShoppingCart = useCallback(() => { + setCart([]); + }, [setCart]); + + const data = useMemo( + () => ({ + allProducts, + setAllProducts, + cart, + setCart, + favorites, + setFavorites, + updateQuantity, + clearShoppingCart, + isMenuOpen, + setIsMenuOpen, + toggleMenu, + toggleFavorites, + addToCart, + theme, + toggleTheme, + }), + [ + allProducts, + cart, + favorites, + setAllProducts, + setCart, + setFavorites, + clearShoppingCart, + updateQuantity, + isMenuOpen, + setIsMenuOpen, + toggleMenu, + toggleFavorites, + addToCart, + theme, + toggleTheme, + ], + ); + + return ( + {children} + ); +}; diff --git a/src/styles/main.scss b/src/styles/main.scss new file mode 100644 index 0000000000..48158f9101 --- /dev/null +++ b/src/styles/main.scss @@ -0,0 +1,3 @@ +@import './utils/fonts'; +@import './utils/mixins'; +@import './utils/variables'; diff --git a/src/styles/utils/_fonts.scss b/src/styles/utils/_fonts.scss new file mode 100644 index 0000000000..5467d1e191 --- /dev/null +++ b/src/styles/utils/_fonts.scss @@ -0,0 +1,17 @@ +@font-face { + font-family: Mont-Regular; + src: url('/fonts/Mont-Regular.otf'); + font-weight: normal; +} + +@font-face { + font-family: Mont-SemiBold; + src: url('/fonts/Mont-SemiBold.otf'); + font-weight: normal; +} + +@font-face { + font-family: Mont-Bold; + src: url('/fonts/Mont-Bold.otf'); + font-weight: normal; +} diff --git a/src/styles/utils/_variables.scss b/src/styles/utils/_variables.scss new file mode 100644 index 0000000000..543116da8d --- /dev/null +++ b/src/styles/utils/_variables.scss @@ -0,0 +1,29 @@ +$tablet-min-width: 640px; +$desktop-min-width: 1200px; + +body { + --color-bg: #FFF; + --color-gray-primary: #313237; + --color-gray-secondary: #89939A; + --color-gray-icons-placeholders: #B4BDC3; + --color-gray-elements: #E2E6E9; + --color-green: #27AE60; + --color-red: #EB5757; + --color-hover: #FAFBFC; +} + +body.theme_dark { + --color-gray-primary: #F1F2F9; + --color-gray-secondary: #75767F; + --color-bg: #0F1121; + --color-gray-icons-placeholders: #4A4D58; + --color-gray-elements: #3B3E4A; + --color-green: #27AE60; + --color-red: #EB5757; + --color-hover: #1cd97a; + --color-surface-1: #161827; + --color-surface-2: #323542; + --color-accent: #905BFF; + --color-accent-hover: #A378FF; +} + diff --git a/src/styles/utils/mixins.scss b/src/styles/utils/mixins.scss new file mode 100644 index 0000000000..4b90b8bc09 --- /dev/null +++ b/src/styles/utils/mixins.scss @@ -0,0 +1,48 @@ +@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; + max-width: 1136px; + + @include on-tablet { + padding-inline: 24px; + } + + @include on-desktop { + padding-inline: 0; + margin-inline: auto; + } +} + +@mixin hover($property, $toValue) { + transition: #{$property} 0.3s; + &:hover { + #{$property}: $toValue; + } +} + +@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; + } +} diff --git a/src/types/CartProduct.ts b/src/types/CartProduct.ts new file mode 100644 index 0000000000..5d8ea8e186 --- /dev/null +++ b/src/types/CartProduct.ts @@ -0,0 +1,7 @@ +import { Product } from './Product'; + +export type CartProduct = { + id: string; + quantity: number; + product: Product; +}; diff --git a/src/types/Product.ts b/src/types/Product.ts new file mode 100644 index 0000000000..8111167715 --- /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/SpecificProduct.ts b/src/types/SpecificProduct.ts new file mode 100644 index 0000000000..3461705f13 --- /dev/null +++ b/src/types/SpecificProduct.ts @@ -0,0 +1,24 @@ +export type SpecificProduct = { + id: string; + category: string; + namespaceId: string; + name: string; + capacityAvailable: string[]; + capacity: string; + priceRegular: number; + priceDiscount: number; + colorsAvailable: string[]; + color: string; + images: string[]; + description: { + title: string; + text: string[]; + }[]; + screen: string; + resolution: string; + processor: string; + ram: string; + camera?: string; + zoom?: string; + cell: string[]; +}; diff --git a/src/utils/productApi.ts b/src/utils/productApi.ts new file mode 100644 index 0000000000..4298a40639 --- /dev/null +++ b/src/utils/productApi.ts @@ -0,0 +1,26 @@ +import { Product } from '../types/Product'; +import { SpecificProduct } from '../types/SpecificProduct'; + +export async function fetchProducts(url: string): Promise { + const response = await fetch(url); + + if (!response.ok) { + const errorData = await response.json(); + + throw new Error( + `Error: ${response.statusText} - ${errorData.message || 'Unknown error'}`, + ); + } + + return response.json(); +} + +export function getAllProducts(): Promise { + return fetchProducts('./api/products.json'); +} + +export function getSpecificProducts( + productsType: string, +): Promise { + return fetchProducts(`./api/${productsType}.json`); +} diff --git a/src/utils/searchHelper.ts b/src/utils/searchHelper.ts new file mode 100644 index 0000000000..10a56f8f50 --- /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(); +}