diff --git a/.stylelintignore b/.stylelintignore index 796b96d1c40..b2fb4307931 100644 --- a/.stylelintignore +++ b/.stylelintignore @@ -1 +1,5 @@ /build +*.tsx +*.ts +*.jsx +*.js diff --git a/index.html b/index.html index 095fb3a4537..0d32178d8a4 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,14 @@ - Vite + React + TS + Nice Gadgets + +
diff --git a/package-lock.json b/package-lock.json index 836b9e63b46..7ef0cda3bbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^2.1.3", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", @@ -1184,10 +1184,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": "2.1.3", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-2.1.3.tgz", + "integrity": "sha512-a07wHTj/1QUK2Aac5zHad+sGw4rIvcNl5lJmJpAD7OxeSbnCdyI6RXUHwXhjF5MaVo9YHrJ0xVahyERS2IIyBQ==", "dev": true, + "license": "MIT", "dependencies": { "@octokit/rest": "^17.11.2", "@types/get-port": "^4.2.0", diff --git a/package.json b/package.json index ae251685c8b..6fd0a6cbfe9 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.8.5", + "@mate-academy/scripts": "^2.1.3", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", diff --git a/public/404.html b/public/404.html new file mode 100644 index 00000000000..4671e314e6b --- /dev/null +++ b/public/404.html @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/public/img/banner-phones-mobile.png b/public/img/banner-phones-mobile.png new file mode 100644 index 00000000000..e9c5e37df9e Binary files /dev/null and b/public/img/banner-phones-mobile.png differ diff --git a/public/img/banner-phones.png b/public/img/banner-phones.png index c8fea5b6ee9..c9c53de489a 100644 Binary files a/public/img/banner-phones.png and b/public/img/banner-phones.png differ diff --git a/public/img/category-accessories.png b/public/img/category-accessories.png index 67c5bfdb35b..c059e7d40d2 100644 Binary files a/public/img/category-accessories.png and b/public/img/category-accessories.png differ diff --git a/public/img/category-phones.png b/public/img/category-phones.png index fd7616042f2..e3f4365bf2f 100644 Binary files a/public/img/category-phones.png and b/public/img/category-phones.png differ diff --git a/public/img/category-tablets.png b/public/img/category-tablets.png index 57e33c5807e..709c0ce9dcd 100644 Binary files a/public/img/category-tablets.png and b/public/img/category-tablets.png differ diff --git a/public/img/icons/arrow-down.svg b/public/img/icons/arrow-down.svg new file mode 100644 index 00000000000..e51b6915b63 --- /dev/null +++ b/public/img/icons/arrow-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/arrow-left.svg b/public/img/icons/arrow-left.svg new file mode 100644 index 00000000000..029122f798b --- /dev/null +++ b/public/img/icons/arrow-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/arrow-right.svg b/public/img/icons/arrow-right.svg new file mode 100644 index 00000000000..27e219b2bdd --- /dev/null +++ b/public/img/icons/arrow-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/arrow-up.svg b/public/img/icons/arrow-up.svg new file mode 100644 index 00000000000..e061a0757fd --- /dev/null +++ b/public/img/icons/arrow-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/close.svg b/public/img/icons/close.svg new file mode 100644 index 00000000000..78d418ab46b --- /dev/null +++ b/public/img/icons/close.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/fav-heart-like-red.svg b/public/img/icons/fav-heart-like-red.svg new file mode 100644 index 00000000000..7138d7522bf --- /dev/null +++ b/public/img/icons/fav-heart-like-red.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/fav-heart-like.svg b/public/img/icons/fav-heart-like.svg new file mode 100644 index 00000000000..ca57cfedd8a --- /dev/null +++ b/public/img/icons/fav-heart-like.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/home.svg b/public/img/icons/home.svg new file mode 100644 index 00000000000..474476cb027 --- /dev/null +++ b/public/img/icons/home.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/img/icons/menu.svg b/public/img/icons/menu.svg new file mode 100644 index 00000000000..2c535f4586b --- /dev/null +++ b/public/img/icons/menu.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/img/icons/minus.svg b/public/img/icons/minus.svg new file mode 100644 index 00000000000..97c41038ac7 --- /dev/null +++ b/public/img/icons/minus.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/moon.svg b/public/img/icons/moon.svg new file mode 100644 index 00000000000..9f284da3fd6 --- /dev/null +++ b/public/img/icons/moon.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/public/img/icons/plus.svg b/public/img/icons/plus.svg new file mode 100644 index 00000000000..ab3c34061b5 --- /dev/null +++ b/public/img/icons/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/img/icons/shopping-bag-cart.svg b/public/img/icons/shopping-bag-cart.svg new file mode 100644 index 00000000000..6030970f2e9 --- /dev/null +++ b/public/img/icons/shopping-bag-cart.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/img/icons/sun.svg b/public/img/icons/sun.svg new file mode 100644 index 00000000000..3a8b600a5a8 --- /dev/null +++ b/public/img/icons/sun.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/public/img/logo.svg b/public/img/logo.svg new file mode 100644 index 00000000000..44e264fcc9e --- /dev/null +++ b/public/img/logo.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/App.scss b/src/App.scss index 71bc413aade..40f0a5f579a 100644 --- a/src/App.scss +++ b/src/App.scss @@ -1 +1 @@ -// not empty +@use './styles/global.scss' as *; diff --git a/src/App.tsx b/src/App.tsx index 372e4b42066..ff26daf7e29 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,57 @@ +/* eslint-disable max-len */ +import { + HashRouter as Router, + Routes, + Route, + Navigate, +} from 'react-router-dom'; +import { CartProvider } from './context/CartContext'; +import { FavoritesProvider } from './context/FavoritesContext'; +import { HomePage } from './modules/HomePage/HomePage'; +import { PhonesPage } from './modules/PhonesPage/PhonesPage'; +import { TabletsPage } from './modules/TabletsPage/TabletsPage'; +import { AccessoriesPage } from './modules/AccessoriesPage/AccessoriesPage'; +import { ProductDetailsPage } from './modules/ProductDetailsPage/ProductDetailsPage'; +import { CartPage } from './modules/CartPage/CartPage'; +import { FavoritesPage } from './modules/FavoritesPage/FavoritesPage'; +import { NotFoundPage } from './modules/NotFoundPage/NotFoundPage'; +import { Layout } from './modules/shared/components/Layout/Layout'; +import { ThemeProvider } from './context/ThemeContext'; + import './App.scss'; export const App = () => ( -
-

Product Catalog

-
+ + + + + + }> + } /> + } /> + + + } /> + } /> + + + + } /> + } /> + + + + } /> + } /> + + + } /> + } /> + } /> + + + + + + ); diff --git a/src/context/CartContext.tsx b/src/context/CartContext.tsx new file mode 100644 index 00000000000..6ca74a361b9 --- /dev/null +++ b/src/context/CartContext.tsx @@ -0,0 +1,72 @@ +import { createContext, useContext, useState, useEffect } from 'react'; +import { CartItem, Product } from '../types'; + +type CartContextType = { + cartItems: CartItem[]; + addToCart: (product: Product) => void; + removeFromCart: (itemId: string) => void; + updateQuantity: (itemId: string, quantity: number) => void; + totalCount: number; +}; + +const CartContext = createContext({ + cartItems: [], + addToCart: () => {}, + removeFromCart: () => {}, + updateQuantity: () => {}, + totalCount: 0, +}); + +export const CartProvider = ({ children }: { children: React.ReactNode }) => { + const [cartItems, setCartItems] = useState(() => { + // localStorage on loading + const saved = localStorage.getItem('cart'); + + return saved ? JSON.parse(saved) : []; + }); + + // Save in localStorage on evry change + useEffect(() => { + localStorage.setItem('cart', JSON.stringify(cartItems)); + }, [cartItems]); + + const addToCart = (product: Product) => { + setCartItems(prev => { + const exists = prev.find(item => item.id === product.itemId); + + if (exists) { + return prev; + } + + return [...prev, { id: product.itemId, quantity: 1, product }]; + }); + }; + + const removeFromCart = (itemId: string) => { + setCartItems(prev => prev.filter(item => item.id !== itemId)); + }; + + const updateQuantity = (itemId: string, quantity: number) => { + setCartItems(prev => + prev.map(item => (item.id === itemId ? { ...item, quantity } : item)), + ); + }; + + const totalCount = cartItems.reduce((sum, item) => sum + item.quantity, 0); + + return ( + + {children} + + ); +}; + +export const useCart = () => useContext(CartContext); diff --git a/src/context/FavoritesContext.tsx b/src/context/FavoritesContext.tsx new file mode 100644 index 00000000000..c643bafb66b --- /dev/null +++ b/src/context/FavoritesContext.tsx @@ -0,0 +1,60 @@ +import { createContext, useContext, useState, useEffect } from 'react'; +import { Product } from '../types'; + +type FavoritesContextType = { + favorites: Product[]; + toggleFavorite: (product: Product) => void; + isFavorite: (itemId: string) => boolean; + totalCount: number; +}; + +const FavoritesContext = createContext({ + favorites: [], + toggleFavorite: () => {}, + isFavorite: () => false, + totalCount: 0, +}); + +export const FavoritesProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const [favorites, setFavorites] = useState(() => { + const saved = localStorage.getItem('favorites'); + + return saved ? JSON.parse(saved) : []; + }); + + useEffect(() => { + localStorage.setItem('favorites', JSON.stringify(favorites)); + }, [favorites]); + + const toggleFavorite = (product: Product) => { + setFavorites(prev => { + const exists = prev.find(p => p.itemId === product.itemId); + + if (exists) { + return prev.filter(p => p.itemId !== product.itemId); + } + + return [...prev, product]; + }); + }; + + const isFavorite = (itemId: string) => { + return favorites.some(p => p.itemId === itemId); + }; + + const totalCount = favorites.length; + + return ( + + {children} + + ); +}; + +export const useFavorites = () => useContext(FavoritesContext); diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx new file mode 100644 index 00000000000..dbd72264e39 --- /dev/null +++ b/src/context/ThemeContext.tsx @@ -0,0 +1,37 @@ +import { createContext, useContext, useState, useEffect } from 'react'; + +type ThemeContextType = { + isDark: boolean; + toggleTheme: () => void; +}; + +const ThemeContext = createContext({ + isDark: false, + toggleTheme: () => {}, +}); + +export const ThemeProvider = ({ children }: { children: React.ReactNode }) => { + const [isDark, setIsDark] = useState(() => { + return localStorage.getItem('theme') === 'dark'; + }); + + useEffect(() => { + if (isDark) { + document.documentElement.classList.add('dark'); + localStorage.setItem('theme', 'dark'); + } else { + document.documentElement.classList.remove('dark'); + localStorage.setItem('theme', 'light'); + } + }, [isDark]); + + const toggleTheme = () => setIsDark(prev => !prev); + + return ( + + {children} + + ); +}; + +export const useTheme = () => useContext(ThemeContext); diff --git a/src/modules/AccessoriesPage/AccessoriesPage.tsx b/src/modules/AccessoriesPage/AccessoriesPage.tsx new file mode 100644 index 00000000000..d2f7661d952 --- /dev/null +++ b/src/modules/AccessoriesPage/AccessoriesPage.tsx @@ -0,0 +1,9 @@ +import { ProductsPage } from '../shared/components/ProductsPage/ProductsPage'; + +export const AccessoriesPage = () => ( + +); diff --git a/src/modules/CartPage/CartPage.module.scss b/src/modules/CartPage/CartPage.module.scss new file mode 100644 index 00000000000..149002ab42d --- /dev/null +++ b/src/modules/CartPage/CartPage.module.scss @@ -0,0 +1,241 @@ +@use '../../styles/variables' as *; + +.page { + padding: $spacing-md 0; +} + +.back { + display: flex; + align-items: center; + gap: $spacing-xs; + color: var(--color-secondary); + font-size: $font-size-xs; + font-weight: 700; + margin-bottom: $spacing-md; + transition: $transition; + + img { + width: 16px; + height: 16px; + } + + &:hover { + color: var(--color-primary); + } +} + +.title { + font-size: $font-size-2xl; + font-weight: 800; + color: var(--color-primary); + margin-bottom: $spacing-lg; + + @media (min-width: $breakpoint-desktop) { + font-size: $font-size-3xl; + } +} + +.empty { + text-align: center; + color: var(--color-secondary); + padding: $spacing-xl; + font-size: $font-size-lg; +} + +.content { + display: grid; + grid-template-columns: 1fr; + gap: $spacing-sm; + + @media (min-width: $breakpoint-desktop) { + grid-template-columns: 1fr 368px; + } +} + +/* ─── Cart Item ─── */ +.items { + display: flex; + flex-direction: column; + gap: $spacing-sm; + width: 100%; +} + +.item { + display: flex; + flex-direction: column; + padding: $spacing-sm; + border: 1px solid var(--color-elements); + gap: $spacing-sm; + width: 100%; + min-height: 160px; + + @media (min-width: $breakpoint-tablet) { + flex-direction: row; + align-items: center; + justify-content: space-between; + min-height: 128px; + } +} + +.itemLeft { + display: flex; + align-items: center; + gap: $spacing-sm; + flex: 1; + min-width: 0; +} + +.itemName { + font-size: $font-size-sm; + font-weight: 600; + color: var(--color-primary); + overflow: hidden; + text-overflow: ellipsis; + + @media (min-width: $breakpoint-tablet) { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + max-width: 176px; + } +} + +.itemImage { + width: 66px; + height: 66px; + object-fit: contain; + flex-shrink: 0; +} + +.itemRight { + display: flex; + align-items: center; + justify-content: space-between; + gap: $spacing-md; + + @media (min-width: $breakpoint-tablet) { + justify-content: flex-end; + } +} + +.removeBtn { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 16px; + height: 16px; + transition: $transition; + + img { + width: 16px; + height: 16px; + } + + &:hover { + opacity: 0.7; + } +} + +/* ─── Quantity ─── */ +.quantity { + display: flex; + align-items: center; + gap: $spacing-xs; +} + +.quantityBtn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid var(--color-elements); + transition: $transition; + + img { + width: 16px; + height: 16px; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &:hover:not(:disabled) { + border-color: var(--color-primary); + } +} + +.quantityValue { + width: 32px; + text-align: center; + font-size: $font-size-md; + font-weight: 700; + color: var(--color-primary); +} + +.itemPrice { + font-size: $font-size-lg; + font-weight: 800; + color: var(--color-primary); + min-width: 60px; + text-align: right; +} + +/* ─── Total ─── */ +.total { + width: 100%; + padding: $spacing-md; + border: 1px solid var(--color-elements); + display: flex; + flex-direction: column; + gap: $spacing-sm; + + @media (min-width: $breakpoint-desktop) { + width: 368px; + height: 206px; + flex-shrink: 0; + position: sticky; + top: 80px; + } +} + +.totalInfo { + display: flex; + flex-direction: column; + align-items: center; + gap: $spacing-xs; +} + +.totalPrice { + font-size: $font-size-2xl; + font-weight: 800; + color: var(--color-primary); +} + +.totalCount { + font-size: $font-size-sm; + font-weight: 600; + color: var(--color-secondary); +} + +.divider { + height: 1px; + background-color: var(--color-elements); +} + +.checkout { + width: 100%; + height: 48px; + background-color: var(--color-primary); + color: var(--color-white); + font-size: $font-size-sm; + font-weight: 700; + transition: $transition; + + &:hover { + box-shadow: 0 3px 13px rgba(23, 32, 49, 0.4); + } +} diff --git a/src/modules/CartPage/CartPage.tsx b/src/modules/CartPage/CartPage.tsx new file mode 100644 index 00000000000..ddba776ebd0 --- /dev/null +++ b/src/modules/CartPage/CartPage.tsx @@ -0,0 +1,103 @@ +import { useNavigate } from 'react-router-dom'; +import { useCart } from '../../context/CartContext'; +import { getImg } from '../../utils/getImageUrl'; +import styles from './CartPage.module.scss'; + +export const CartPage = () => { + const navigate = useNavigate(); + const { cartItems, removeFromCart, updateQuantity, totalCount } = useCart(); + + const totalPrice = cartItems.reduce( + (sum, item) => sum + item.product.price * item.quantity, + 0, + ); + + const handleCheckout = () => { + const confirmed = window.confirm( + 'Checkout is not implemented yet. Do you want to clear the Cart?', + ); + + if (confirmed) { + cartItems.forEach(item => removeFromCart(item.id)); + } + }; + + return ( +
+ + +

Cart

+ + {cartItems.length === 0 ? ( +

Your cart is empty

+ ) : ( +
+ {/* Cart items */} +
+ {cartItems.map(item => ( +
+
+ + + {item.product.name} + +

{item.product.name}

+
+ +
+
+ + + {item.quantity} + + +
+ +

+ ${item.product.price * item.quantity} +

+
+
+ ))} +
+ + {/* Total */} +
+
+

${totalPrice}

+

Total for {totalCount} items

+
+
+ +
+
+ )} +
+ ); +}; diff --git a/src/modules/FavoritesPage/FavoritesPage.module.scss b/src/modules/FavoritesPage/FavoritesPage.module.scss new file mode 100644 index 00000000000..6ae4426ad4e --- /dev/null +++ b/src/modules/FavoritesPage/FavoritesPage.module.scss @@ -0,0 +1,53 @@ +@use '../../styles/variables' as *; + +.page { + padding: $spacing-md 0; + margin-bottom: $spacing-xl; + + @media (min-width: $breakpoint-tablet) { + margin-bottom: $spacing-2xl; + } + + @media (min-width: $breakpoint-desktop) { + margin-bottom: $spacing-3xl; + } +} + +.title { + font-size: $font-size-2xl; + font-weight: 800; + color: var(--color-primary); + margin-bottom: $spacing-xs; + + @media (min-width: $breakpoint-desktop) { + font-size: $font-size-3xl; + } +} + +.count { + font-size: $font-size-sm; + color: var(--color-secondary); + margin-bottom: $spacing-lg; +} + +.empty { + text-align: center; + color: var(--color-secondary); + padding: $spacing-xl; + font-size: $font-size-lg; +} + +.grid { + display: grid; + grid-template-columns: 1fr; + gap: $spacing-md; + justify-items: center; + + @media (min-width: $breakpoint-tablet) { + grid-template-columns: repeat(2, 1fr); + } + + @media (min-width: $breakpoint-desktop) { + grid-template-columns: repeat(4, 1fr); + } +} diff --git a/src/modules/FavoritesPage/FavoritesPage.tsx b/src/modules/FavoritesPage/FavoritesPage.tsx new file mode 100644 index 00000000000..2ee9053a96e --- /dev/null +++ b/src/modules/FavoritesPage/FavoritesPage.tsx @@ -0,0 +1,27 @@ +import { useFavorites } from '../../context/FavoritesContext'; +import { Breadcrumbs } from '../shared/components/Breadcrumbs/Breadcrumbs'; +import { ProductCard } from '../shared/components/ProductCard/ProductCard'; +import styles from './FavoritesPage.module.scss'; + +export const FavoritesPage = () => { + const { favorites } = useFavorites(); + + return ( +
+ + +

Favourites

+

{favorites.length} items

+ + {favorites.length === 0 ? ( +

No favourites yet

+ ) : ( +
+ {favorites.map(product => ( + + ))} +
+ )} +
+ ); +}; diff --git a/src/modules/HomePage/HomePage.module.scss b/src/modules/HomePage/HomePage.module.scss new file mode 100644 index 00000000000..70384f89d1a --- /dev/null +++ b/src/modules/HomePage/HomePage.module.scss @@ -0,0 +1,42 @@ +@use '../../styles/variables' as *; + +.homePage { + padding: $spacing-md 0; + +} + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.title { + text-align: left; + font-size: $font-size-2xl; + font-weight: 800; + color: var(--color-primary); + margin-bottom: $spacing-md; + margin-top: $spacing-md; + max-width: 300px; + + + @media (min-width: $breakpoint-tablet) { + max-width: 400px; + font-size: $font-size-3xl; + margin-top: $spacing-lg; + margin-bottom: $spacing-lg; + } + + @media (min-width: $breakpoint-desktop) { + max-width: none; + margin-top: $spacing-xl; + margin-bottom: $spacing-xl; + } +} diff --git a/src/modules/HomePage/HomePage.tsx b/src/modules/HomePage/HomePage.tsx new file mode 100644 index 00000000000..f5dcc45ca9e --- /dev/null +++ b/src/modules/HomePage/HomePage.tsx @@ -0,0 +1,41 @@ +import { BannerSlider } from './components/BannerSlider/BannerSlider'; +import { Categories } from './components/Categories/Categories'; +// eslint-disable-next-line max-len +import { ProductsSlider } from '../shared/components/ProductsSlider/ProductsSlider'; +import { useProducts } from '../shared/components/hooks/useProducts'; +import styles from './HomePage.module.scss'; + +export const HomePage = () => { + const { products, loading, error } = useProducts(); + + // New Products Sorted Year + const newModels = [...products].sort((a, b) => b.year - a.year).slice(0, 10); + + // Hot prices — products with biggest sale + const hotPrices = [...products] + .sort((a, b) => b.fullPrice - b.price - (a.fullPrice - a.price)) + .slice(0, 10); + + if (loading) { + return
Loading...
; + } + + if (error) { + return
Something went wrong...
; + } + + return ( +
+

Product Catalog

+

Welcome to Nice Gadgets store!

+ + + + +
+ ); +}; diff --git a/src/modules/HomePage/components/BannerSlider/BannerSlider.module.scss b/src/modules/HomePage/components/BannerSlider/BannerSlider.module.scss new file mode 100644 index 00000000000..b403020532c --- /dev/null +++ b/src/modules/HomePage/components/BannerSlider/BannerSlider.module.scss @@ -0,0 +1,112 @@ +@use '../../../../styles/variables' as *; + +.slider { + position: relative; + width: 100%; + margin-bottom: $spacing-xl; +} + +/* ─── Wrapper ─── */ +.wrapper { + display: flex; + align-items: stretch; + gap: 0; + + @media (min-width: $breakpoint-tablet) { + gap: 19px; + } + + @media (min-width: $breakpoint-desktop) { + gap: 16px; + } +} + +/* ─── Track ─── */ +.track { + position: relative; + width: 100%; + aspect-ratio: 1 / 1; + + @media (min-width: $breakpoint-tablet) { + aspect-ratio: 490 / 189; + } + + @media (min-width: $breakpoint-desktop) { + aspect-ratio: 1040 / 400; + } +} + +/* ─── Slides ─── */ +.slide { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0; + transition: opacity 0.5s ease; +} + +.slideActive { + opacity: 1; +} + +.image { + width: 100%; + height: 100%; + object-fit: cover; +} + +/* ─── Buttons ─── */ +.btn { + display: none; + + @media (min-width: $breakpoint-tablet) { + display: flex; + align-items: center; + justify-content: center; + + width: 32px; + flex-shrink: 0; + border: 1px solid var(--color-elements); + transition: $transition; + + img { + width: 16px; + height: 16px; + } + + &:hover { + border-color: var(--color-primary); + } + + &:active { + border-color: var(--color-primary); + background-color: var(--color-elements); + } + } +} + + +/* ─── Dots ─── */ +.dots { + display: flex; + justify-content: center; + gap: $spacing-xs; + margin-top: $spacing-sm; +} + +.dot { + width: 14px; + height: 4px; + background-color: var(--color-elements); + transition: $transition; + + &:hover { + background-color: var(--color-secondary); + } +} + +.dotActive { + background-color: var(--color-primary); +} diff --git a/src/modules/HomePage/components/BannerSlider/BannerSlider.tsx b/src/modules/HomePage/components/BannerSlider/BannerSlider.tsx new file mode 100644 index 00000000000..864ffb6c8d3 --- /dev/null +++ b/src/modules/HomePage/components/BannerSlider/BannerSlider.tsx @@ -0,0 +1,104 @@ +import { useState, useEffect } from 'react'; +import { getImg } from '../../../../utils/getImageUrl'; +import styles from './BannerSlider.module.scss'; + +const banners = [ + { + id: 1, + src: getImg('/img/banner-phones.png'), + mobileSrc: getImg('/img/banner-phones-mobile.png'), + alt: 'Phones banner', + }, + { id: 2, src: getImg('/img/banner-tablets.png'), alt: 'Tablets banner' }, + { + id: 3, + src: getImg('/img/banner-accessories.png'), + alt: 'Accessories banner', + }, +]; + +export const BannerSlider = () => { + const [currentIndex, setCurrentIndex] = useState(0); + + // Banner auto chnange each 5 sec + useEffect(() => { + const interval = setInterval(() => { + setCurrentIndex(prev => (prev + 1) % banners.length); + }, 5000); + + return () => clearInterval(interval); + }, []); + + const handlePrev = () => { + setCurrentIndex(prev => (prev === 0 ? banners.length - 1 : prev - 1)); + }; + + const handleNext = () => { + setCurrentIndex(prev => (prev + 1) % banners.length); + }; + + const handleDotClick = (index: number) => { + setCurrentIndex(index); + }; + + return ( +
+ {/* hide button-left for mobile */} +
+ + + {/* images */} +
+ {banners.map((banner, index) => ( +
+ + {banner.mobileSrc && ( + + )} + + {banner.alt} + +
+ ))} +
+ + {/* hide button-right for mobile */} + +
+ + {/* dots-under */} +
+ {banners.map((banner, index) => ( +
+
+ ); +}; diff --git a/src/modules/HomePage/components/Categories/Categories.module.scss b/src/modules/HomePage/components/Categories/Categories.module.scss new file mode 100644 index 00000000000..01ddd673703 --- /dev/null +++ b/src/modules/HomePage/components/Categories/Categories.module.scss @@ -0,0 +1,93 @@ +@use '../../../../styles/variables' as *; + +.categories { + margin-top: $spacing-xl; + + @media (min-width: $breakpoint-tablet) { + margin-top: $spacing-2xl; + } + + @media (min-width: $breakpoint-desktop) { + margin-top: $spacing-3xl; + } + +} + +.title { + font-size: $font-size-xl; + font-weight: 800; + color: var(--color-primary); + margin-bottom: $spacing-md; + + @media (min-width: $breakpoint-desktop) { + font-size: $font-size-2xl; + } +} + +.list { + display: grid; + grid-template-columns: 1fr; + gap: $spacing-lg; + + @media (min-width: $breakpoint-tablet) { + grid-template-columns: repeat(3, 1fr); // tablet+ — 3 columns + gap: $spacing-sm; + } +} + +.imageWrapper { + width: 100%; + aspect-ratio: 1 / 1; + margin: 0 auto; + overflow: hidden; + transition: $transition; +} + +.item { + display: flex; + flex-direction: column; + gap: $spacing-xs; + transition: $transition; + + &:hover .imageWrapper { + transform: scale(1.05); + } + + &:nth-child(1) .imageWrapper { + background-color: $color-category-phone; + } + &:nth-child(2) .imageWrapper { + background-color: $color-category-tablets; + } + &:nth-child(3) .imageWrapper { + background-color: $color-category-accessories; + } +} + +.image { + width: 100%; + height: 100%; + object-fit: cover; +} + +.itemTitle { + font-size: $font-size-lg; + font-weight: 700; + color: var(--color-primary); + margin-top: $spacing-md; + + @media (min-width: $breakpoint-tablet) { + font-weight: 600; + } +} + +.itemCount { + font-size: $font-size-sm; + font-weight: 600; + color: var(--color-secondary); + margin-top: $spacing-xs; + + @media (min-width: $breakpoint-tablet) { + font-weight: 500; + } +} diff --git a/src/modules/HomePage/components/Categories/Categories.tsx b/src/modules/HomePage/components/Categories/Categories.tsx new file mode 100644 index 00000000000..621ef4955a3 --- /dev/null +++ b/src/modules/HomePage/components/Categories/Categories.tsx @@ -0,0 +1,49 @@ +import { Link } from 'react-router-dom'; +import { getImg } from '../../../../utils/getImageUrl'; +import styles from './Categories.module.scss'; + +export const Categories = () => { + return ( +
+

Shop by category

+ +
+ +
+ Phones +
+

Mobile phones

+

95 models

+ + + +
+ Tablets +
+

Tablets

+

24 models

+ + + +
+ Accessories +
+

Accessories

+

100 models

+ +
+
+ ); +}; diff --git a/src/modules/NotFoundPage/NotFoundPage.module.scss b/src/modules/NotFoundPage/NotFoundPage.module.scss new file mode 100644 index 00000000000..4fffc3ae402 --- /dev/null +++ b/src/modules/NotFoundPage/NotFoundPage.module.scss @@ -0,0 +1,54 @@ +@use '../../styles/variables' as *; + +.page { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: $spacing-3xl $container-padding-mobile; + text-align: center; + min-height: 50vh; +} + +.image { + width: 100%; + max-width: 350px; + margin-bottom: $spacing-lg; + + @media (min-width: $breakpoint-tablet) { + max-width: 500px; + } +} + +.title { + font-size: $font-size-2xl; + font-weight: 800; + color: var(--color-primary); + margin-bottom: $spacing-md; + + @media (min-width: $breakpoint-desktop) { + font-size: $font-size-3xl; + } +} + +.text { + font-size: $font-size-md; + color: var(--color-secondary); + margin-bottom: $spacing-lg; +} + +.link { + display: inline-flex; + align-items: center; + height: 48px; + padding: 0 $spacing-lg; + background-color: var(--color-primary); + color: var(--color-white); + font-size: $font-size-sm; + font-weight: 700; + transition: $transition; + + &:hover { + box-shadow: 0 3px 13px rgba(23, 32, 49, 0.4); + } +} diff --git a/src/modules/NotFoundPage/NotFoundPage.tsx b/src/modules/NotFoundPage/NotFoundPage.tsx new file mode 100644 index 00000000000..df71cc2c70b --- /dev/null +++ b/src/modules/NotFoundPage/NotFoundPage.tsx @@ -0,0 +1,22 @@ +import { Link } from 'react-router-dom'; +import { getImg } from '../../utils/getImageUrl'; +import styles from './NotFoundPage.module.scss'; + +export const NotFoundPage = () => { + return ( +
+ Page not found +

Page not found

+

+ The page you are looking for does not exist. +

+ + Go to Home page + +
+ ); +}; diff --git a/src/modules/PhonesPage/PhonesPage.tsx b/src/modules/PhonesPage/PhonesPage.tsx new file mode 100644 index 00000000000..679925657ba --- /dev/null +++ b/src/modules/PhonesPage/PhonesPage.tsx @@ -0,0 +1,9 @@ +import { ProductsPage } from '../shared/components/ProductsPage/ProductsPage'; + +export const PhonesPage = () => ( + +); diff --git a/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss b/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss new file mode 100644 index 00000000000..5bd7c39d920 --- /dev/null +++ b/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss @@ -0,0 +1,409 @@ +@use '../../styles/variables' as *; + +.page { + padding: $spacing-md 0; +} + +.loader, +.error { + padding: $spacing-xl; + text-align: center; +} + +.back { + display: flex; + align-items: center; + gap: $spacing-xs; + color: var(--color-secondary); + font-size: $font-size-sm; + font-weight: 600; + margin-bottom: $spacing-md; + transition: $transition; + + img { + width: 16px; + height: 16px; + } + + &:hover { + color: var(--color-primary); + } +} + +.title { + font-size: $font-size-xl; + font-weight: 800; + color: var(--color-primary); + margin-bottom: $spacing-lg; + + @media (min-width: $breakpoint-desktop) { + font-size: $font-size-2xl; + } +} + +/* ─── Main ─── */ +.main { + display: grid; + grid-template-columns: 1fr; + gap: $spacing-lg; + margin-bottom: $spacing-xl; + + @media (min-width: $breakpoint-tablet) { + grid-template-columns: auto 1fr; + gap: $spacing-md; + } + + @media (min-width: $breakpoint-desktop) { + grid-template-columns: 1fr 1fr; + gap: $spacing-2xl; + } +} + +/* ─── Gallery ─── */ +.gallery { + display: flex; + flex-direction: column-reverse; + gap: $spacing-sm; + + @media (min-width: $breakpoint-tablet) { + flex-direction: row; + } +} + +.thumbnails { + display: flex; + flex-direction: row; + gap: $spacing-xs; + + @media (min-width: $breakpoint-tablet) { + flex-direction: column; + } +} + +.thumbnail { + width: 50px; + height: 50px; + border: 1px solid var(--color-elements); + padding: 4px; + transition: $transition; + + img { + width: 100%; + height: 100%; + object-fit: contain; + } + + &:hover { + border-color: var(--color-primary); + } +} + +.thumbnailActive { + border-color: var(--color-primary); +} + +.mainImage { + width: 288px; + height: 288px; + margin: 0 auto; + + @media (min-width: $breakpoint-tablet) { + width: 287px; + height: 287px; + } + + @media (min-width: $breakpoint-desktop) { + width: 100%; + max-width: 464px; + height: auto; + aspect-ratio: 1 / 1; + } + + img { + width: 100%; + height: 100%; + object-fit: contain; + } +} + +/* ─── Options ─── */ + + +.optionGroup { + margin-bottom: $spacing-sm; +} + +.optionLabel { + font-size: $font-size-xs; + font-weight: 600; + color: var(--color-secondary); + margin-bottom: $spacing-xs; +} + +.optionHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: $spacing-xs; +} + +.productId { + font-size: $font-size-xs; + font-weight: 700; + color: var(--color-icons); +} + +.colors { + display: flex; + gap: $spacing-xs; +} + +.colorBtn { + width: 32px; + height: 32px; + border-radius: 50%; + border: 2px solid var(--color-white); + outline: 1px solid var(--color-elements); + transition: $transition; + + &:hover { + outline-color: var(--color-secondary); + } +} + +.colorBtnActive { + outline-color: var(--color-primary); +} + +.capacities { + display: flex; + gap: $spacing-xs; + flex-wrap: wrap; +} + +.capacityBtn { + padding: $spacing-xs $spacing-sm; + border: 1px solid var(--color-elements); + font-size: $font-size-sm; + font-weight: 500; + color: var(--color-primary); + transition: $transition; + + &:hover { + border-color: var(--color-primary); + } +} + +.capacityBtnActive { + background-color: var(--color-primary); + color: var(--color-white); + border-color: var(--color-primary); +} + +.divider { + height: 1px; + background-color: var(--color-elements); + margin: $spacing-md 0; +} + +.prices { + display: flex; + align-items: center; + gap: $spacing-sm; + margin-bottom: $spacing-md; +} + +.price { + font-size: $font-size-2xl; + font-weight: 800; + color: var(--color-primary); +} + +.fullPrice { + font-size: $font-size-xl; + font-weight: 500; + color: var(--color-secondary); + text-decoration: line-through; +} + +.actions { + display: flex; + gap: $spacing-xs; + margin-bottom: $spacing-md; +} + +.addToCart { + width: 100%;; + height: 48px; + background-color: var(--color-primary); + color: var(--color-white); + font-size: $font-size-sm; + font-weight: 700; + transition: $transition; + + @media (min-width: $breakpoint-tablet) { + width: 180px; + flex: none; + } + + @media (min-width: $breakpoint-desktop) { + width: 263px; + } + + &:hover { + box-shadow: 0 3px 13px rgba(23, 32, 49, 0.4); + } +} + +.addToCartActive { + background-color: var(--color-white); + border: 1px solid var(--color-elements); + color: $color-green; +} + +.favorite { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border: 1px solid var(--color-elements); + transition: $transition; + + img { + width: 16px; + height: 16px; + } + + &:hover { + border-color: var(--color-primary); + } +} + +.favoriteActive { + border-color: var(--color-elements); +} + + +/* ─── Specs ─── */ +.shortSpecs { + display: flex; + flex-direction: column; + gap: $spacing-xs; + + @media (min-width: $breakpoint-desktop) { + max-width: 320px; + } +} + +.specs { + display: flex; + flex-direction: column; + gap: $spacing-xs; +} + +.spec { + display: flex; + justify-content: space-between; +} + +.specName { + font-size: $font-size-sm; + font-weight: 500; + color: var(--color-secondary); + flex-shrink: 0; +} + +.specValue { + font-size: $font-size-sm; + font-weight: 500; + color: var(--color-primary); + line-height: 1.5; + text-align: right; + + @media (min-width: $breakpoint-desktop) { + word-break: break-word; + } +} + + +/* ─── About + Tech specs ─── */ + +.bottomSection { + display: grid; + grid-template-columns: 1fr; + gap: $spacing-xl; + margin-bottom: $spacing-xl; + + @media (min-width: $breakpoint-tablet) { + margin-bottom: $spacing-2xl; + } + + @media (min-width: $breakpoint-desktop) { + grid-template-columns: 1fr 1fr; + gap: $spacing-2xl; // 64px + } +} + +.about { + @media (min-width: $breakpoint-desktop) { + max-width: 560px; + } +} + +.techSpecs { + @media (min-width: $breakpoint-desktop) { + width: 100%; + + .specs { + width: 100%; + } + } +} + +.sectionTitle { + font-size: $font-size-lg; + font-weight: 700; + color: var(--color-primary); + margin-bottom: $spacing-sm; +} + +.descSection { + margin-bottom: $spacing-md; +} + +.descTitle { + font-size: $font-size-md; + font-weight: 700; + color: var(--color-primary); + margin-bottom: $spacing-xs; +} + +.descText { + font-size: $font-size-sm; + color: var(--color-secondary); + line-height: 1.6; +} + +/* ─── Product is Not Found ─── */ + +.notFound { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: $spacing-3xl 0; + text-align: center; +} + +.notFoundImage { + width: 100%; + max-width: 350px; + margin-bottom: $spacing-lg; +} + +.notFoundText { + font-size: $font-size-xl; + font-weight: 700; + color: var(--color-primary); +} diff --git a/src/modules/ProductDetailsPage/ProductDetailsPage.tsx b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx new file mode 100644 index 00000000000..526431f9153 --- /dev/null +++ b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx @@ -0,0 +1,310 @@ +/* eslint-disable @typescript-eslint/indent */ +import { useParams, useNavigate, useLocation } from 'react-router-dom'; +import { useMemo, useState, useEffect } from 'react'; +// eslint-disable-next-line max-len +import { useProductDetails } from '../shared/components/hooks/useProductDetails'; +import { useProducts } from '../shared/components/hooks/useProducts'; +import { useCart } from '../../context/CartContext'; +import { useFavorites } from '../../context/FavoritesContext'; +import { Breadcrumbs } from '../shared/components/Breadcrumbs/Breadcrumbs'; +// eslint-disable-next-line max-len +import { ProductsSlider } from '../shared/components/ProductsSlider/ProductsSlider'; +import { getImg } from '../../utils/getImageUrl'; +import styles from './ProductDetailsPage.module.scss'; + +export const ProductDetailsPage = () => { + const { productId } = useParams<{ productId: string }>(); + const navigate = useNavigate(); + + // which category from URL + const { pathname } = useLocation(); + + useEffect(() => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, [pathname]); + + const category = pathname.split('/')[1]; + + const { product, loading, error, notFound } = useProductDetails( + category, + productId || '', + ); + const { products } = useProducts(); + + const [selectedImage, setSelectedImage] = useState(0); + const { addToCart, cartItems } = useCart(); + const { toggleFavorite, isFavorite } = useFavorites(); + + const isAddedToCart = cartItems.some(item => item.id === productId); + const isLiked = product ? isFavorite(product.id) : false; + + // Random products + const suggestedProducts = useMemo(() => { + return [...products].sort(() => Math.random() - 0.5).slice(0, 10); + }, [products]); + + if (loading) { + return
Loading...
; + } + + if (error) { + return ( +
+

Something went wrong

+ +
+ ); + } + + if (notFound) { + return ( +
+ Product not found +

Product was not found

+
+ ); + } + + if (!product) { + return null; + } + + const productToAction = { + id: 0, + itemId: product.id, + category: product.category, + name: product.name, + price: product.priceDiscount, + fullPrice: product.priceRegular, + screen: product.screen, + capacity: product.capacity, + color: product.color, + ram: product.ram, + year: 0, + image: product.images[0], + }; + + const currentColor = product.color; + const currentCapacity = product.capacity; + + const handleColorSelect = (color: string) => { + const capacity = currentCapacity.toLowerCase().replace(' ', ''); + const colorFormatted = color.replace(' ', '-'); + const newId = `${product.namespaceId}-${capacity}-${colorFormatted}`; + + navigate(`/${category}/${newId}`); + }; + + const handleCapacitySelect = (cap: string) => { + const capacity = cap.toLowerCase().replace(' ', ''); + const colorFormatted = currentColor.replace(' ', '-'); + const newId = `${product.namespaceId}-${capacity}-${colorFormatted}`; + + navigate(`/${category}/${newId}`); + }; + + return ( +
+ {/* Breadcrumbs */} + + + {/* Back button */} + + +

{product.name}

+ + {/* Main content */} +
+ {/* Gallery */} +
+
+ {product.images.map((img, index) => ( + + ))} +
+ +
+ {product.name} +
+
+ + {/* Options */} +
+ {/* Colors */} +
+
+

Available colors

+

ID: {product.namespaceId}

+
+
+ {product.colorsAvailable.map(color => ( +
+
+ +
+ + {/* Capacity */} +
+

Select capacity

+
+ {product.capacityAvailable.map(cap => ( + + ))} +
+
+ +
+ + {/* Price */} +
+ ${product.priceDiscount} + ${product.priceRegular} +
+ + {/* Buttons */} +
+ + + +
+ + {/* Short specs */} +
+
+
+ Screen + {product.screen} +
+
+ Resolution + {product.resolution} +
+
+ Processor + {product.processor} +
+
+ RAM + {product.ram} +
+
+
+
+
+ +
+ {/* About */} +
+

About

+
+ {product.description.map(desc => ( +
+

{desc.title}

+ {desc.text.map(text => ( +

+ {text} +

+ ))} +
+ ))} +
+ + {/* Tech specs */} +
+

Tech specs

+
+
+
+ Screen + {product.screen} +
+
+ Resolution + {product.resolution} +
+
+ Processor + {product.processor} +
+
+ RAM + {product.ram} +
+ {product.camera && ( +
+ Camera + {product.camera} +
+ )} + {product.zoom && ( +
+ Zoom + {product.zoom} +
+ )} +
+ Cell + + {product.cell.join(', ')} + +
+
+
+
+ +
+ ); +}; diff --git a/src/modules/TabletsPage/TabletsPage.tsx b/src/modules/TabletsPage/TabletsPage.tsx new file mode 100644 index 00000000000..4223649aba0 --- /dev/null +++ b/src/modules/TabletsPage/TabletsPage.tsx @@ -0,0 +1,5 @@ +import { ProductsPage } from '../shared/components/ProductsPage/ProductsPage'; + +export const TabletsPage = () => ( + +); diff --git a/src/modules/shared/components/Breadcrumbs/Breadcrumbs.module.scss b/src/modules/shared/components/Breadcrumbs/Breadcrumbs.module.scss new file mode 100644 index 00000000000..9baffbdf5a6 --- /dev/null +++ b/src/modules/shared/components/Breadcrumbs/Breadcrumbs.module.scss @@ -0,0 +1,60 @@ +@use '../../../../styles/variables' as *; + +.breadcrumbs { + display: flex; + align-items: center; + gap: $spacing-xs; + margin-bottom: $spacing-md; +} + +.home { + display: flex; + align-items: center; + transition: $transition; + + img { + width: 16px; + height: 16px; + } + + &:hover { + opacity: 0.7; + } +} + +.item { + display: flex; + align-items: center; + gap: $spacing-xs; +} + +.arrow { + width: 16px; + height: 16px; + opacity: 0.5; +} + +.link { + font-size: $font-size-xs; + font-weight: 600; + color: var(--color-primary); + transition: $transition; + + &:hover { + color: var(--color-secondary); + } +} + +.current { + font-size: $font-size-xs; + font-weight: 600; + color: var(--color-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 165px; + + @media (min-width: $breakpoint-tablet) { + max-width: none; + } +} diff --git a/src/modules/shared/components/Breadcrumbs/Breadcrumbs.tsx b/src/modules/shared/components/Breadcrumbs/Breadcrumbs.tsx new file mode 100644 index 00000000000..147e1a8e141 --- /dev/null +++ b/src/modules/shared/components/Breadcrumbs/Breadcrumbs.tsx @@ -0,0 +1,39 @@ +import { Link } from 'react-router-dom'; +import { getImg } from '../../../../utils/getImageUrl'; +import styles from './Breadcrumbs.module.scss'; + +type BreadcrumbItem = { + label: string; + path?: string; +}; + +type Props = { + items: BreadcrumbItem[]; +}; + +export const Breadcrumbs = ({ items }: Props) => { + return ( + + ); +}; diff --git a/src/modules/shared/components/Dropdown/Dropdown.module.scss b/src/modules/shared/components/Dropdown/Dropdown.module.scss new file mode 100644 index 00000000000..8ee192dcbf7 --- /dev/null +++ b/src/modules/shared/components/Dropdown/Dropdown.module.scss @@ -0,0 +1,84 @@ +@use '../../../../styles/variables' as *; + +.wrapper { + position: relative; + display: inline-flex; + flex-direction: column; + gap: $spacing-xs; + width: fit-content; +} + +.label { + font-size: $font-size-xs; + font-weight: 600; + color: var(--color-secondary); +} + +.select { + display: flex; + align-items: center; + justify-content: space-between; + height: 40px; + width: 100%; + padding: 0 $spacing-sm; + border: 1px solid var(--color-elements); + background-color: var(--color-white); + cursor: pointer; + transition: $transition; + user-select: none; + min-width: 136px; + box-sizing: border-box; + + &:hover { + border-color: var(--color-primary); + } +} + +.selectOpen { + border-color: var(--color-primary); +} + +.selected { + font-size: $font-size-sm; + font-weight: 600; + color: var(--color-primary); +} + +.arrow { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.dropdown { + position: absolute; + top: calc(100% + 4px); + left: 0; + background-color: var(--color-white); + border: 1px solid var(--color-elements); + z-index: 10; + box-shadow: 0 2px 15px rgba(0, 0, 0, 0.05); + min-width: 100%; + width: max-content; +} + +.option { + display: flex; + align-items: center; + height: 40px; + padding: 0 $spacing-sm; + font-size: $font-size-sm; + font-weight: 600; + color: var(--color-primary); + cursor: pointer; + transition: $transition; + + &:hover { + background-color: var(--color-hover); + color: var(--color-primary); + } +} + +.optionActive { + color: var(--color-secondary); +} diff --git a/src/modules/shared/components/Dropdown/Dropdown.tsx b/src/modules/shared/components/Dropdown/Dropdown.tsx new file mode 100644 index 00000000000..56bd2ab8871 --- /dev/null +++ b/src/modules/shared/components/Dropdown/Dropdown.tsx @@ -0,0 +1,77 @@ +/* eslint-disable prettier/prettier */ +import { useState, useRef, useEffect } from 'react'; +import { getImg } from '../../../../utils/getImageUrl'; +import styles from './Dropdown.module.scss'; + +type Option = { + value: string; + label: string; +}; + +type Props = { + label: string; + options: Option[]; + value: string; + onChange: (value: string) => void; + width?: number; +}; + +export const Dropdown = ({ label, options, value, onChange, width }: Props) => { + const [isOpen, setIsOpen] = useState(false); + const ref = useRef(null); + + const selectedLabel = options.find(o => o.value === value)?.label || ''; + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + return ( +
+

{label}

+
setIsOpen(prev => !prev)} + > + {selectedLabel} + arrow +
+ + {isOpen && ( +
    + {options.map(option => ( +
  • { + onChange(option.value); + setIsOpen(false); + }} + > + {option.label} +
  • + ))} +
+ )} +
+ ); +}; diff --git a/src/modules/shared/components/Footer/Footer.module.scss b/src/modules/shared/components/Footer/Footer.module.scss new file mode 100644 index 00000000000..0161f870cad --- /dev/null +++ b/src/modules/shared/components/Footer/Footer.module.scss @@ -0,0 +1,99 @@ +@use '../../../../styles/variables' as *; + +.footer { + border-top: 1px solid var(--color-elements); + margin-top: auto; +} + +.container { + display: flex; + flex-direction: column; + align-items: flex-start; + padding: $spacing-lg $container-padding-mobile; + gap: $spacing-lg; + + @media (min-width: $breakpoint-tablet) { + flex-direction: row; + align-items: center; + justify-content: space-between; + height: 96px; + padding: 0 $container-padding-tablet; + gap: 0; + } + + @media (min-width: $breakpoint-desktop) { + padding: 0 $container-padding-desktop; + } + + @media (min-width: $breakpoint-desktop-xl) { + padding: 0 $container-padding-desktop-xl; + } + +} + +.logo { + img { + width: 89px; + height: 32px; + } +} + +.nav { + display: flex; + flex-direction: column; + gap: $spacing-sm; + + @media (min-width: $breakpoint-tablet) { + flex-direction: row; + gap: $spacing-lg; + } +} + +.navLink { + font-size: $font-size-xs; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--color-secondary); + transition: $transition; + + &:hover { + color: var(--color-primary); + } +} + +.backToTopIcon { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid var(--color-elements); + transition: $transition; + + img { + width: 16px; + height: 16px; + } +} + +.backToTop { + display: flex; + align-items: center; + align-self: center; + gap: $spacing-sm; + font-size: $font-size-xs; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--color-secondary); + transition: $transition; + + &:hover { + color: var(--color-primary); + + .backToTopIcon { + border-color: var(--color-primary); + } + } +} diff --git a/src/modules/shared/components/Footer/Footer.tsx b/src/modules/shared/components/Footer/Footer.tsx new file mode 100644 index 00000000000..9c79fe806ae --- /dev/null +++ b/src/modules/shared/components/Footer/Footer.tsx @@ -0,0 +1,53 @@ +import { NavLink } from 'react-router-dom'; +import { getImg } from '../../../../utils/getImageUrl'; +import styles from './Footer.module.scss'; + +export const Footer = () => { + const handleScrollToTop = () => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + return ( + + ); +}; diff --git a/src/modules/shared/components/Header/Header.module.scss b/src/modules/shared/components/Header/Header.module.scss new file mode 100644 index 00000000000..aec517c7e74 --- /dev/null +++ b/src/modules/shared/components/Header/Header.module.scss @@ -0,0 +1,284 @@ +@use '../../../../styles/variables' as *; + +.header { + position: sticky; + top: 0; + z-index: 100; + background-color: var(--color-white); + border-bottom: 1px solid var(--color-elements); + display: flex; + align-items: center; + justify-content: space-between; + height: 48px; + padding: 0 $container-padding-mobile; + + @media (min-width: $breakpoint-tablet) { + padding: 0 $container-padding-tablet; + } + + @media (min-width: $breakpoint-desktop) { + height: 64px; + padding: 0 $container-padding-desktop; + } + +} + +.logo { + img { + width: 64px; + height: 22px; + + @media (min-width: $breakpoint-desktop) { + width: 80px; + height: 28px; + } + } +} + +.left { + display: flex; + align-items: center; + gap: $spacing-sm; + + @media (min-width: $breakpoint-desktop) { + gap: $spacing-md; + } +} + +/* ─── Descktop nav ─── */ +.nav { + display: none; + + @media (min-width: $breakpoint-tablet) { + display: flex; + align-items: center; + } +} + +.navList { + display: flex; + gap: $spacing-lg; + + @media (min-width: $breakpoint-desktop) { + gap: $spacing-2xl; + } +} + +.navLink { + display: flex; + align-items: center; + height: 48px; + + font-size: $font-size-xs; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--color-secondary); + border-bottom: 3px solid transparent; + transition: $transition; + + @media (min-width: $breakpoint-desktop) { + height: 64px; + } + + &:hover { + color: var(--color-primary); + } +} + +.navLinkActive { + color: var(--color-primary); + border-bottom-color: var(--color-primary); +} + +/* ─── Descktop icons ─── */ +.icons { + display: none; + + @media (min-width: $breakpoint-tablet) { + display: flex; + } +} + + +.iconLink { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border-left: 1px solid var(--color-elements); + color: var(--color-primary); + transition: $transition; + + @media (min-width: $breakpoint-desktop) { + width: 64px; + height: 64px; + } + + img { + width: 16px; + height: 16px; + } + + &:hover { + background-color: var(--color-hover); + } +} + +.iconLinkActive { + border-bottom: 3px solid var(--color-primary); +} + +/* Burger Button */ +.burger { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border-left: 1px solid var(--color-elements); + margin-right: -$container-padding-mobile; + + img { + width: 16px; + height: 16px; + } + + @media (min-width: $breakpoint-tablet) { + display: none; + } +} + + +/* Mobile Menu */ +.mobileMenu { + position: fixed; + inset: 48px 0 0; + background-color: var(--color-white); + z-index: 99; + display: flex; + flex-direction: column; + justify-content: space-between; + + @media (min-width: $breakpoint-tablet) { + display: none; + } +} + +.mobileNavList { + display: flex; + flex-direction: column; + align-items: center; + padding-top: $spacing-md; + gap: $spacing-sm; +} + +.mobileNavLink { + display: inline-block; + padding: $spacing-sm 0; + font-size: $font-size-xs; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--color-secondary); + border-bottom: 2px solid transparent; + transition: $transition; + + &:hover { + color: var(--color-primary); + } +} + +.mobileNavLinkActive { + color: var(--color-primary); + border-bottom-color: var(--color-primary); +} + +/* Icons-bottom-menu */ +.mobileIcons { + display: flex; + border-top: 1px solid var(--color-elements); +} + +.mobileIconLink { + display: flex; + align-items: center; + justify-content: center; + width: 50%; + height: 64px; + border-right: 1px solid var(--color-elements); + transition: $transition; + + &:last-child { + border-right: none; + } + + img { + width: 16px; + height: 16px; + } + + &:hover { + background-color: var(--color-hover); + } +} + +.mobileIconLinkActive { + border-bottom: 2px solid var(--color-primary); +} + +.iconWrapper { + position: relative; + display: flex; + align-items: center; + justify-content: center; + + img { + width: 16px; + height: 16px; + } +} + +.badge { + position: absolute; + top: -6px; + right: -6px; + min-width: 14px; + height: 14px; + padding: 0 3px; + background-color: $color-red; + color: var(--color-white); + font-size: 9px; + font-weight: 700; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +/* Button for Switching Theme */ + +.themeToggle { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border-left: 1px solid var(--color-elements); + transition: $transition; + + img { + width: 16px; + height: 16px; + } + + &:hover { + background-color: var(--color-hover); + } + + @media (min-width: $breakpoint-desktop) { + width: 64px; + height: 64px; + } +} diff --git a/src/modules/shared/components/Header/Header.tsx b/src/modules/shared/components/Header/Header.tsx new file mode 100644 index 00000000000..c67edb1139e --- /dev/null +++ b/src/modules/shared/components/Header/Header.tsx @@ -0,0 +1,270 @@ +import { useState } from 'react'; +import { NavLink } from 'react-router-dom'; +import { useCart } from '../../../../context/CartContext'; +import { useFavorites } from '../../../../context/FavoritesContext'; +import { useTheme } from '../../../../context/ThemeContext'; +import { getImg } from '../../../../utils/getImageUrl'; +import styles from './Header.module.scss'; + +const getNavLinkClass = ( + isActive: boolean, + baseClass: string, + activeClass: string, +) => (isActive ? `${baseClass} ${activeClass}` : baseClass); + +export const Header = () => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const { totalCount: cartCount } = useCart(); + const { totalCount: favCount } = useFavorites(); + const { isDark, toggleTheme } = useTheme(); + + const handleMenuToggle = () => setIsMenuOpen(prev => !prev); + const handleMenuClose = () => setIsMenuOpen(false); + + return ( +
+
+ + Nice Gadgets + + +
+ +
+ {/* Button for swithing theme */} + + + getNavLinkClass(isActive, styles.iconLink, styles.iconLinkActive) + } + > +
+ favorite icon + {favCount > 0 && {favCount}} +
+
+ + getNavLinkClass(isActive, styles.iconLink, styles.iconLinkActive) + } + > +
+ shopping bag cart + {cartCount > 0 && {cartCount}} +
+
+
+ + {/* Burger */} + + + {/* Mobile menu */} + {isMenuOpen && ( +
+
    +
  • + + getNavLinkClass( + isActive, + styles.mobileNavLink, + styles.mobileNavLinkActive, + ) + } + onClick={handleMenuClose} + > + Home + +
  • +
  • + + getNavLinkClass( + isActive, + styles.mobileNavLink, + styles.mobileNavLinkActive, + ) + } + onClick={handleMenuClose} + > + Phones + +
  • +
  • + + getNavLinkClass( + isActive, + styles.mobileNavLink, + styles.mobileNavLinkActive, + ) + } + onClick={handleMenuClose} + > + Tablets + +
  • +
  • + + getNavLinkClass( + isActive, + styles.mobileNavLink, + styles.mobileNavLinkActive, + ) + } + onClick={handleMenuClose} + > + Accessories + +
  • +
+ + {/* Bottom-icons */} +
+ + getNavLinkClass( + isActive, + styles.mobileIconLink, + styles.mobileIconLinkActive, + ) + } + onClick={handleMenuClose} + > +
+ favorites + {favCount > 0 && ( + {favCount} + )} +
+
+ + + getNavLinkClass( + isActive, + styles.mobileIconLink, + styles.mobileIconLinkActive, + ) + } + onClick={handleMenuClose} + > +
+ cart + {cartCount > 0 && ( + {cartCount} + )} +
+
+
+
+ )} +
+ ); +}; diff --git a/src/modules/shared/components/Layout/Layout.module.scss b/src/modules/shared/components/Layout/Layout.module.scss new file mode 100644 index 00000000000..f040b31e29d --- /dev/null +++ b/src/modules/shared/components/Layout/Layout.module.scss @@ -0,0 +1,27 @@ +@use '../../../../styles/variables' as *; + +.app { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.main { + flex: 1; +} + +.container { + padding: 0 $container-padding-mobile; + + @media (min-width: $breakpoint-tablet) { + padding: 0 $container-padding-tablet; + } + + @media (min-width: $breakpoint-desktop) { + padding: 0 $container-padding-desktop; + } + + @media (min-width: $breakpoint-desktop-xl) { + padding: 0 $container-padding-desktop-xl; + } +} diff --git a/src/modules/shared/components/Layout/Layout.tsx b/src/modules/shared/components/Layout/Layout.tsx new file mode 100644 index 00000000000..2e6890663a3 --- /dev/null +++ b/src/modules/shared/components/Layout/Layout.tsx @@ -0,0 +1,18 @@ +import { Outlet } from 'react-router-dom'; +import { Header } from '../Header/Header'; +import { Footer } from '../Footer/Footer'; +import styles from './Layout.module.scss'; + +export const Layout = () => { + return ( +
+
+
+
+ +
+
+
+
+ ); +}; diff --git a/src/modules/shared/components/ProductCard/ProductCard.module.scss b/src/modules/shared/components/ProductCard/ProductCard.module.scss new file mode 100644 index 00000000000..cde0031b137 --- /dev/null +++ b/src/modules/shared/components/ProductCard/ProductCard.module.scss @@ -0,0 +1,186 @@ +@use '../../../../styles/variables' as *; + +.card { + display: flex; + flex-direction: column; + padding: $spacing-lg; + border: 1px solid var(--color-elements); + transition: $transition; + width: 212px; + height: 439px; + flex-shrink: 0; + + @media (min-width: $breakpoint-tablet) { + width: 237px; + height: 512px; + } + + @media (min-width: $breakpoint-desktop) { + flex: 0 0 calc(25% - 12px); + height: 506px; + } + + &:hover { + box-shadow: 0 2px 15px rgba(0, 0, 0, 0.1); + } +} + +.imageLink { + display: flex; + align-items: center; + justify-content: center; + width: 148px; + height: 129px; + margin: 0 auto $spacing-sm; + + @media (min-width: $breakpoint-tablet) { + width: 173px; + height: 202px; + } + + @media (min-width: $breakpoint-desktop) { + width: 196px; + height: 208px; + } + +} + +.cardFullWidth { + width: 100%; + height: auto; + + .imageLink { + width: 100%; + height: 130px; // mobile — img height + + @media (min-width: $breakpoint-tablet) { + height: 196px; + } + } +} + + +.image { + width: 100%; + height: 100%; + object-fit: contain; + transition: $transition; +} + +.name { + font-size: $font-size-sm; + font-weight: 600; + color: var(--color-primary); + margin-bottom: $spacing-xs; + transition: $transition; + + &:hover { + color: var(--color-secondary); + } +} + +.prices { + display: flex; + align-items: center; + gap: $spacing-xs; + margin-bottom: $spacing-sm; +} + +.price { + font-size: $font-size-xl; + font-weight: 700; + color: var(--color-primary); +} + +.fullPrice { + font-size: $font-size-xl; + font-weight: 500; + color: var(--color-secondary); + text-decoration: line-through; +} + +.divider { + height: 1px; + background-color: var(--color-elements); + margin-bottom: $spacing-sm; +} + +.specs { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: $spacing-sm; +} + +.spec { + display: flex; + justify-content: space-between; + align-items: center; +} + +.specName { + font-size: $font-size-xs; + font-weight: 600; + color: var(--color-secondary); +} + +.specValue { + font-size: $font-size-xs; + font-weight: 600; + color: var(--color-primary); +} + +.actions { + display: flex; + gap: $spacing-xs; + margin-top: auto; +} + +.addToCart { + flex: 1; + height: 40px; + background-color: var(--color-primary); + color: var(--color-white); + font-size: $font-size-sm; + font-weight: 700; + transition: $transition; + + &:hover:not(.addToCartActive) { + box-shadow: 0 3px 13px rgba(23, 32, 49, 0.4); + } +} + +.addToCartActive { + background-color: var(--color-white); + border: 1px solid var(--color-elements); + color: $color-green; + + &:hover { + box-shadow: none; + } +} + + +.favorite { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border: 1px solid var(--color-elements); + transition: $transition; + + img { + width: 16px; + height: 16px; + } + + &:hover:not(.favoriteActive) { + border-color: var(--color-primary); + } +} + + +.favoriteActive { + border-color: var(--color-elements); +} diff --git a/src/modules/shared/components/ProductCard/ProductCard.tsx b/src/modules/shared/components/ProductCard/ProductCard.tsx new file mode 100644 index 00000000000..cb2ee641d90 --- /dev/null +++ b/src/modules/shared/components/ProductCard/ProductCard.tsx @@ -0,0 +1,102 @@ +import { Link } from 'react-router-dom'; +import { Product } from '../../../../types'; +import { useCart } from '../../../../context/CartContext'; +import { useFavorites } from '../../../../context/FavoritesContext'; +import { getImg } from '../../../../utils/getImageUrl'; +import styles from './ProductCard.module.scss'; + +type Props = { + product: Product; + showFullPriceOnly?: boolean; + fullWidth?: boolean; +}; + +export const ProductCard = ({ + product, + showFullPriceOnly = false, + fullWidth = false, +}: Props) => { + const { addToCart, cartItems } = useCart(); + const { toggleFavorite, isFavorite } = useFavorites(); + + const isAddedToCart = cartItems.some(item => item.id === product.itemId); + const isLiked = isFavorite(product.itemId); + + const { itemId, category, name, fullPrice, screen, capacity, ram, image } = + product; + + const handleAddToCard = () => { + addToCart(product); + }; + + const handleFavorite = () => { + toggleFavorite(product); + }; + + return ( +
+ {/* Image */} + + {name} + + + {/* Name */} + + {name} + + + {/* Price */} +
+ + ${showFullPriceOnly ? product.fullPrice : product.price} + + {!showFullPriceOnly && ( + ${fullPrice} + )} +
+ + {/* Devider */} +
+ + {/* Specs */} +
+
+ Screen + {screen} +
+
+ Capacity + {capacity} +
+
+ RAM + {ram} +
+
+ + {/* Buttons */} +
+ + +
+
+ ); +}; diff --git a/src/modules/shared/components/ProductsPage/ProductsPage.module.scss b/src/modules/shared/components/ProductsPage/ProductsPage.module.scss new file mode 100644 index 00000000000..c572dd0c01a --- /dev/null +++ b/src/modules/shared/components/ProductsPage/ProductsPage.module.scss @@ -0,0 +1,149 @@ +@use '../../../../styles/variables' as *; + +.page { + padding: $spacing-md 0; +} + +.title { + font-size: $font-size-2xl; + font-weight: 800; + color: var(--color-primary); + margin-bottom: $spacing-xs; + + @media (min-width: $breakpoint-desktop) { + font-size: $font-size-3xl; + } +} + +.count { + font-size: $font-size-sm; + color: var(--color-secondary); + margin-bottom: $spacing-lg; +} + +.filters { + display: flex; + flex-wrap: wrap; + gap: $spacing-md; + margin-bottom: $spacing-lg; + align-items: flex-end; + + & > * { + flex: 0 0 auto; + } +} + +.filter { + display: flex; + flex-direction: column; + gap: $spacing-xs; +} + +.filterLabel { + font-size: $font-size-xs; + font-weight: 600; + color: var(--color-secondary); +} + +.select { + height: 40px; + padding: 0 $spacing-sm; + border: 1px solid var(--color-elements); + font-size: $font-size-sm; + font-weight: 600; + color: var(--color-primary); + cursor: pointer; + transition: $transition; + background-color: var(--color-white); + + &:hover { + border-color: var(--color-primary); + } +} + +// Grid +.grid { + display: grid; + grid-template-columns: 1fr; // mobile — 1 column + gap: $spacing-md; + justify-items: center; + + @media (min-width: $breakpoint-tablet) { + grid-template-columns: repeat(2, 1fr); // tablet — 2 col + } + + @media (min-width: $breakpoint-desktop) { + grid-template-columns: repeat(4, 1fr); // desktop — 4 col + } +} + +// Pagination +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: $spacing-xs; + margin-top: $spacing-xl; +} + +.pageBtn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid var(--color-elements); + font-size: $font-size-sm; + font-weight: 700; + color: var(--color-primary); + transition: $transition; + + img { + width: 16px; + height: 16px; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &:hover:not(:disabled) { + border-color: var(--color-primary); + } +} + +.pageBtnActive { + background-color: var(--color-primary); + color: var(--color-white); + border-color: var(--color-primary); +} + +.loader { + padding: $spacing-xl; + text-align: center; +} + +.error { + padding: $spacing-xl; + text-align: center; + + button { + margin-top: $spacing-sm; + padding: $spacing-xs $spacing-md; + border: 1px solid var(--color-primary); + font-weight: 700; + transition: $transition; + + &:hover { + background-color: var(--color-primary); + color: var(--color-white); + } + } +} + +.empty { + text-align: center; + color: var(--color-secondary); + padding: $spacing-xl; +} diff --git a/src/modules/shared/components/ProductsPage/ProductsPage.tsx b/src/modules/shared/components/ProductsPage/ProductsPage.tsx new file mode 100644 index 00000000000..352f852a430 --- /dev/null +++ b/src/modules/shared/components/ProductsPage/ProductsPage.tsx @@ -0,0 +1,189 @@ +/* eslint-disable prettier/prettier */ +import { useSearchParams } from 'react-router-dom'; +import { useMemo, useState, useEffect } from 'react'; +import { useProductsByCategory } from '../hooks/useProductsByCategory'; +import { Breadcrumbs } from '../Breadcrumbs/Breadcrumbs'; +import { ProductCard } from '../ProductCard/ProductCard'; +import { getImg } from '../../../../utils/getImageUrl'; +import { Dropdown } from '../Dropdown/Dropdown'; +import styles from './ProductsPage.module.scss'; + +type SortType = 'age' | 'name' | 'price'; + +type Props = { + category: 'phones' | 'tablets' | 'accessories'; + title: string; + breadcrumbLabel: string; +}; + +export const ProductsPage = ({ category, title, breadcrumbLabel }: Props) => { + const { products, loading, error } = useProductsByCategory(category); + const [searchParams, setSearchParams] = useSearchParams(); + const [windowWidth, setWindowWidth] = useState(window.innerWidth); + + useEffect(() => { + const handleResize = () => setWindowWidth(window.innerWidth); + + window.addEventListener('resize', handleResize); + + return () => window.removeEventListener('resize', handleResize); + }, []); + + const sort = (searchParams.get('sort') || 'age') as SortType; + const page = Number(searchParams.get('page') || '1'); + const perPage = searchParams.get('perPage') || '16'; + + const sortedProducts = useMemo(() => { + const copy = [...products]; + + switch (sort) { + case 'name': + return copy.sort((a, b) => a.name.localeCompare(b.name)); + case 'price': + return copy.sort((a, b) => a.price - b.price); + case 'age': + default: + return copy.sort((a, b) => b.year - a.year); + } + }, [products, sort]); + + const totalPages = + perPage === 'all' ? 1 : Math.ceil(sortedProducts.length / Number(perPage)); + + const visibleProducts = + perPage === 'all' + ? sortedProducts + : sortedProducts.slice( + (page - 1) * Number(perPage), + page * Number(perPage), + ); + + const getVisiblePages = () => { + if (totalPages <= 4) { + return Array.from({ length: totalPages }, (_, i) => i + 1); + } + + let start = Math.max(1, page - 1); + const end = Math.min(start + 3, totalPages); + + if (end - start < 3) { + start = Math.max(1, end - 3); + } + + return Array.from({ length: end - start + 1 }, (_, i) => start + i); + }; + + const handleSort = (value: SortType) => { + const params: Record = { sort: value }; + + if (perPage !== 'all') { + params.perPage = perPage; + } + + setSearchParams(params); + }; + + const handlePerPage = (value: string) => { + if (value === 'all') { + setSearchParams({ sort }); + } else { + setSearchParams({ sort, perPage: value }); + } + }; + + const handlePage = (value: number) => { + setSearchParams({ sort, perPage, page: String(value) }); + }; + + if (loading) { + return
Loading...
; + } + + if (error) { + return ( +
+

Something went wrong

+ +
+ ); + } + + const sortWidth = windowWidth >= 1200 ? 176 : windowWidth >= 640 ? 187 : 100; + const perPageWidth = windowWidth >= 640 ? 136 : 100; + + return ( +
+ + +

{title}

+

{products.length} models

+ +
+ handleSort(value as SortType)} + options={[ + { value: 'age', label: 'Newest' }, + { value: 'name', label: 'Alphabetically' }, + { value: 'price', label: 'Cheapest' }, + ]} + width={sortWidth} + /> + + +
+ + {visibleProducts.length === 0 ? ( +

There are no {category} yet

+ ) : ( +
+ {visibleProducts.map(product => ( + + ))} +
+ )} + + {totalPages > 1 && ( +
+ + + {getVisiblePages().map(p => ( + + ))} + + +
+ )} +
+ ); +}; diff --git a/src/modules/shared/components/ProductsSlider/ProductsSlider.module.scss b/src/modules/shared/components/ProductsSlider/ProductsSlider.module.scss new file mode 100644 index 00000000000..78757625428 --- /dev/null +++ b/src/modules/shared/components/ProductsSlider/ProductsSlider.module.scss @@ -0,0 +1,101 @@ +@use '../../../../styles/variables' as *; + +.slider { + margin-top: $spacing-xl; + + @media (min-width: $breakpoint-tablet) { + margin-top: $spacing-2xl; + } + + @media (min-width: $breakpoint-desktop) { + margin-top: $spacing-3xl; + } +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: $spacing-md; +} + +.title { + font-size: $font-size-xl; + font-weight: 800; + color: var(--color-primary); + max-width: 136px; + + @media (min-width: $breakpoint-tablet) { + max-width: none; + } + + @media (min-width: $breakpoint-desktop) { + font-size: $font-size-2xl; + } +} + +.buttons { + display: flex; + gap: $spacing-sm; +} + +.btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid var(--color-elements); + transition: $transition; + + img { + width: 16px; + height: 16px; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &:hover:not(:disabled) { + border-color: var(--color-primary); + } + +} + +.listWrapper { + overflow: hidden; + width: 100%; + + // @media (min-width: $breakpoint-tablet) { + // margin-right: -$container-padding-tablet; + // } + + // @media (min-width: $breakpoint-desktop) { + // margin-right: 0; + // width: 100%; + + // } +} + +.list { + display: flex; + flex-wrap: nowrap; + gap: $spacing-sm; + transition: transform 0.3s ease; + transform: translateX(calc(-1 * var(--mobile-offset, 0px))); + + @media (min-width: $breakpoint-tablet) { + transform: translateX(calc(-1 * var(--tablet-offset, 0px))); + } + + @media (min-width: $breakpoint-desktop) { + transform: translateX(calc(-1 * var(--desktop-offset, 0px))); + } + +} + +.item { + min-width: 0; +} diff --git a/src/modules/shared/components/ProductsSlider/ProductsSlider.tsx b/src/modules/shared/components/ProductsSlider/ProductsSlider.tsx new file mode 100644 index 00000000000..694bbc13ee7 --- /dev/null +++ b/src/modules/shared/components/ProductsSlider/ProductsSlider.tsx @@ -0,0 +1,93 @@ +import React, { useState, useRef } from 'react'; +import { Product } from '../../../../types'; +import { ProductCard } from '../ProductCard/ProductCard'; +import { getImg } from '../../../../utils/getImageUrl'; +import styles from './ProductsSlider.module.scss'; + +type Props = { + title: string; + products: Product[]; + showFullPriceOnly?: boolean; +}; + +export const ProductsSlider = ({ + title, + products, + showFullPriceOnly = false, +}: Props) => { + const [startIndex, setStartIndex] = useState(0); + const mobileCardWidth = 212 + 16; + const tabletCardWidth = 237 + 16; + const desktopCardWidth = 272 + 16; + const visibleCount = 4; + const listWrapperRef = useRef(null); + + const handlePrev = () => { + setStartIndex(prev => Math.max(prev - 1, 0)); + }; + + const getCardWidth = () => { + if (!listWrapperRef.current) { + return desktopCardWidth; + } + + const containerWidth = listWrapperRef.current.offsetWidth; + + // 4 cards + 3 gap (16px) + return (containerWidth - 3 * 16) / 4 + 16; + }; + + const handleNext = () => { + setStartIndex(prev => Math.min(prev + 1, products.length - visibleCount)); + }; + + return ( +
+ {/* Header */} +
+

{title}

+ +
+ + +
+
+ + {/* Products */} +
+
+ {products.map(product => ( + + ))} +
+
+
+ ); +}; diff --git a/src/modules/shared/components/hooks/useProductDetails.ts b/src/modules/shared/components/hooks/useProductDetails.ts new file mode 100644 index 00000000000..f6798e85d6b --- /dev/null +++ b/src/modules/shared/components/hooks/useProductDetails.ts @@ -0,0 +1,43 @@ +import { useState, useEffect } from 'react'; +import { ProductDetails } from '../../../../types'; + +const BASE = import.meta.env.DEV ? '/' : '/react_phone-catalog/'; + +export const useProductDetails = (category: string, productId: string) => { + const [product, setProduct] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const [notFound, setNotFound] = useState(false); + + useEffect(() => { + setLoading(true); + setError(false); + setNotFound(false); + + fetch(`${BASE}api/${category}.json`) + .then(res => { + if (!res.ok) { + throw new Error('Failed to fetch'); + } + + return res.json(); + }) + .then((data: ProductDetails[]) => { + const found = data.find(p => p.id === productId); + + if (!found) { + setNotFound(true); + } else { + setProduct(found); + } + + setLoading(false); + }) + .catch(() => { + setError(true); + setLoading(false); + }); + }, [category, productId]); + + return { product, loading, error, notFound }; +}; diff --git a/src/modules/shared/components/hooks/useProducts.ts b/src/modules/shared/components/hooks/useProducts.ts new file mode 100644 index 00000000000..de65df1318e --- /dev/null +++ b/src/modules/shared/components/hooks/useProducts.ts @@ -0,0 +1,31 @@ +import { useState, useEffect } from 'react'; +import { Product } from '../../../../types'; + +const BASE = import.meta.env.DEV ? '/' : '/react_phone-catalog/'; + +export const useProducts = () => { + const [products, setProducts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + + useEffect(() => { + fetch(`${BASE}api/products.json`) + .then(res => { + if (!res.ok) { + throw new Error('Failed to fetch'); + } + + return res.json(); + }) + .then((data: Product[]) => { + setProducts(data); + setLoading(false); + }) + .catch(() => { + setError(true); + setLoading(false); + }); + }, []); + + return { products, loading, error }; +}; diff --git a/src/modules/shared/components/hooks/useProductsByCategory.ts b/src/modules/shared/components/hooks/useProductsByCategory.ts new file mode 100644 index 00000000000..1f109b555ca --- /dev/null +++ b/src/modules/shared/components/hooks/useProductsByCategory.ts @@ -0,0 +1,36 @@ +import { useState, useEffect } from 'react'; +import { Product } from '../../../../types'; + +const BASE = import.meta.env.DEV ? '/' : '/react_phone-catalog/'; + +export const useProductsByCategory = (category: string) => { + const [products, setProducts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + + useEffect(() => { + setLoading(true); + setError(false); + + fetch(`${BASE}api/products.json`) + .then(res => { + if (!res.ok) { + throw new Error('Failed to fetch'); + } + + return res.json(); + }) + .then((data: Product[]) => { + const filtered = data.filter(p => p.category === category); + + setProducts(filtered); + setLoading(false); + }) + .catch(() => { + setError(true); + setLoading(false); + }); + }, [category]); + + return { products, loading, error }; +}; diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss new file mode 100644 index 00000000000..20c52f8ecdb --- /dev/null +++ b/src/styles/_variables.scss @@ -0,0 +1,65 @@ +// Colors +$color-primary: #313237; +$color-secondary: #89939a; +$color-icons: #b4bdc3; +$color-elements: #e2e6e9; +$color-bg: #fafbfc; +$color-hover: #f0f0f0; +$color-white: #fff; +$color-green: #27ae60; +$color-red: #eb5757; +$color-category-phone: #6D6474; +$color-category-tablets: #8D8D92; +$color-category-accessories: #973D5F; + +// Typography +$font-family: 'Mont', 'Helvetica', sans-serif; + +// Font sizes +$font-size-xs: 12px; +$font-size-sm: 14px; +$font-size-md: 16px; +$font-size-lg: 20px; +$font-size-xl: 22px; +$font-size-2xl: 32px; +$font-size-3xl: 48px; + +// Breakpoints +$breakpoint-tablet: 640px; +$breakpoint-desktop: 1200px; +$breakpoint-desktop-xl: 1440px; + +// Spacing +$spacing-xs: 8px; +$spacing-sm: 16px; +$spacing-md: 24px; +$spacing-lg: 32px; +$spacing-xl: 56px; +$spacing-2xl: 64px; +$spacing-3xl: 80px; + +// Container (width of page) +$container-max-width: 1440px; +$container-padding-mobile: 16px; +$container-padding-tablet: 24px; +$container-padding-desktop: 32px; +$container-padding-desktop-xl: 152px; + +// Grid +$grid-gap: 16px; +$grid-columns-mobile: 4; +$grid-columns-tablet: 12; +$grid-columns-desktop: 24; +$grid-column-width-desktop: 32px; + +// Transition +$transition: 0.3s ease; + +// CSS vars +$c-primary: var(--color-primary); +$c-secondary: var(--color-secondary); +$c-bg: var(--color-bg); +$c-white: var(--color-white); +$c-elements: var(--color-elements); +$c-icons: var(--color-icons); +$c-hover: var(--color-hover); diff --git a/src/styles/global.scss b/src/styles/global.scss new file mode 100644 index 00000000000..9bf68e221ae --- /dev/null +++ b/src/styles/global.scss @@ -0,0 +1,59 @@ +@use 'variables' as *; + +// Light theme (default) +:root { + --color-primary: #{$color-primary}; + --color-secondary: #{$color-secondary}; + --color-bg: #{$color-bg}; + --color-white: #{$color-white}; + --color-elements: #{$color-elements}; + --color-icons: #{$color-icons}; + --color-hover: #{$color-hover}; +} + +// Dark theme +:root.dark { + --color-primary: #f1f2f9; + --color-secondary: #89939a; + --color-bg: #0f1121; + --color-white: #161827; + --color-elements: #3b3e4a; + --color-icons: #4a4d58; + --color-hover: #1e1f2e; +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: $font-family; + font-size: $font-size-sm; + color: var(--color-primary); + background-color: var(--color-bg); + transition: background-color 0.3s ease, color 0.3s ease; +} + +a { + text-decoration: none; + color: inherit; +} + +ul { + list-style: none; +} + +button { + cursor: pointer; + border: none; + background: none; +} + +img { + display: block; + max-width: 100%; +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 00000000000..07206e767ce --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,47 @@ +export type Product = { + id: number; + category: 'phones' | 'tablets' | 'accessories'; + itemId: string; + name: string; + fullPrice: number; + price: number; + screen: string; + capacity: string; + color: string; + ram: string; + year: number; + image: string; +}; + +export type Description = { + title: string; + text: string[]; +}; + +export type ProductDetails = { + id: string; + category: 'phones' | 'tablets' | 'accessories'; + namespaceId: string; + name: string; + capacityAvailable: string[]; + capacity: string; + priceRegular: number; + priceDiscount: number; + colorsAvailable: string[]; + color: string; + images: string[]; + description: Description[]; + screen: string; + resolution: string; + processor: string; + ram: string; + camera?: string; + zoom?: string; + cell: string[]; +}; + +export type CartItem = { + id: string; + quantity: number; + product: Product; +}; diff --git a/src/utils/getImageUrl.ts b/src/utils/getImageUrl.ts new file mode 100644 index 00000000000..82105ba9a52 --- /dev/null +++ b/src/utils/getImageUrl.ts @@ -0,0 +1,7 @@ +const BASE = import.meta.env.DEV ? '/' : '/react_phone-catalog/'; + +export const getImg = (path: string): string => { + const cleanPath = path.startsWith('/') ? path.slice(1) : path; + + return `${BASE}${cleanPath}`; +}; diff --git a/tsconfig.json b/tsconfig.json index cfb168bb26c..9ffda021b9c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "src" ], "compilerOptions": { + "jsx": "react-jsx", "sourceMap": false, "types": ["node", "cypress"] } diff --git a/vite.config.ts b/vite.config.ts index 5a33944a9b4..afcb7f2a4e8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' -// https://vitejs.dev/config/ -export default defineConfig({ +export default defineConfig(({ command }) => ({ plugins: [react()], -}) + base: command === 'build' ? '/react_phone-catalog/' : '/', +}))