diff --git a/index.html b/index.html index 095fb3a4537..8e1d1d03de1 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,23 @@ - Vite + React + TS + + + Nice Gadgets Store +
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..137b9c2d951 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react_phone-catalog", - "homepage": "react_phone-catalog", + "homepage": "/react_phone-catalog/", "version": "0.1.0", "keywords": [], "author": "Mate Academy", @@ -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/favicon.svg b/public/favicon.svg new file mode 100644 index 00000000000..e2c25d5170f --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,15 @@ + + + + NG + + diff --git a/public/img/banners/Banner-mobile.png b/public/img/banners/Banner-mobile.png new file mode 100644 index 00000000000..e9c5e37df9e Binary files /dev/null and b/public/img/banners/Banner-mobile.png differ diff --git a/public/img/banners/Banner.png b/public/img/banners/Banner.png new file mode 100644 index 00000000000..ba354554ab8 Binary files /dev/null and b/public/img/banners/Banner.png differ diff --git a/public/img/banners/slide-1.png b/public/img/banners/slide-1.png new file mode 100644 index 00000000000..c8fea5b6ee9 Binary files /dev/null and b/public/img/banners/slide-1.png differ diff --git a/public/img/banners/slide-2.png b/public/img/banners/slide-2.png new file mode 100644 index 00000000000..d8079734bcd Binary files /dev/null and b/public/img/banners/slide-2.png differ diff --git a/public/img/banners/slide-3.png b/public/img/banners/slide-3.png new file mode 100644 index 00000000000..ba41c4e8f0d Binary files /dev/null and b/public/img/banners/slide-3.png differ diff --git a/src/App.scss b/src/App.scss index 71bc413aade..a575cf723df 100644 --- a/src/App.scss +++ b/src/App.scss @@ -1 +1,114 @@ -// not empty +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +@font-face { + font-family: Mont; + src: url('/fonts/Mont-Regular.otf') format('opentype'); + font-weight: 400; + font-style: normal; +} + +@font-face { + font-family: Mont; + src: url('/fonts/Mont-SemiBold.otf') format('opentype'); + font-weight: 600; + font-style: normal; +} + +@font-face { + font-family: Mont; + src: url('/fonts/Mont-Bold.otf') format('opentype'); + font-weight: 700; + font-style: normal; +} + +:root { + --color-primary: #f1f2f9; + --color-secondary: #89939a; + --color-icons: #b4bdc3; + --color-white: #fff; + --color-surface: #161827; + --color-card: #161827; + --color-bg: #0f1121; + --color-border: rgba(255, 255, 255, 0.12); + --color-red: #eb5757; + --color-green: #27ae60; + --color-accent: #905bff; + --color-accent-hover: #a378ff; + --color-header-bg: #0f1121; + --color-header-text: #f1f2f9; + --color-header-border: rgba(255, 255, 255, 0.12); + --font-base: Mont, 'Helvetica Neue', Arial, sans-serif; + --transition-base: 0.2s ease-in-out; + --content-max-width: 1200px; + --header-height: 64px; +} + +html { + scroll-behavior: smooth; +} + +body { + font-family: var(--font-base); + background-color: var(--color-bg); + color: var(--color-primary); + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +a { + text-decoration: none; + color: inherit; +} + +button { + cursor: pointer; + border: none; + background: none; + font-family: inherit; +} + +img { + max-width: 100%; + display: block; +} + +ul { + list-style: none; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +a img, +button img { + transition: transform 0.3s ease-in-out; +} + +a:hover img, +button:hover img { + transform: scale(1.1); +} + +a, +button { + transition: + color var(--transition-base), + background-color var(--transition-base), + border-color var(--transition-base), + opacity var(--transition-base); +} diff --git a/src/App.tsx b/src/App.tsx index 372e4b42066..3430c11c3bd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,44 @@ +import { HashRouter, Route, Routes } from 'react-router-dom'; + +import { CartProvider, FavoritesProvider } from './modules/shared/context'; +import { AppLayout } from './modules/shared/components/AppLayout'; +import { CartPage } from './modules/CartPage'; +import { FavoritesPage } from './modules/FavoritesPage'; +import { HomePage } from './modules/HomePage'; +import { NotFoundPage } from './modules/NotFoundPage'; +import { ProductDetailsPage } from './modules/ProductDetailsPage'; +import { ProductsPage } from './modules/ProductsPage'; + import './App.scss'; export const App = () => ( -
-

Product Catalog

-
+ + + + + }> + } /> + } + /> + } + /> + + } + /> + } /> + } /> + } /> + } /> + + + + + ); diff --git a/src/modules/CartPage/CartPage.module.scss b/src/modules/CartPage/CartPage.module.scss new file mode 100644 index 00000000000..29b5640f985 --- /dev/null +++ b/src/modules/CartPage/CartPage.module.scss @@ -0,0 +1,284 @@ +.page { + padding: 24px 16px 72px; + max-width: var(--content-max-width); + margin: 0 auto; + + @media (max-width: 639px) { + padding: 16px 16px 48px; + } +} + +.backBtn { + display: inline-flex; + align-items: center; + gap: 6px; + margin-bottom: 16px; + color: var(--color-primary); + font-size: 14px; + font-weight: 600; + opacity: 0.9; + + &:hover { + opacity: 1; + } +} + +.title { + font-size: 48px; + font-weight: 800; + color: var(--color-primary); + margin-bottom: 32px; + + @media (max-width: 639px) { + font-size: 40px; + margin-bottom: 24px; + } +} + +.empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + padding: 64px 0; +} + +.emptyText { + font-size: 20px; + color: var(--color-secondary); +} + +.emptyLink { + padding: 10px 24px; + background-color: var(--color-primary); + color: var(--color-bg); + font-size: 14px; + font-weight: 700; + border-radius: 8px; + transition: opacity var(--transition-base); + + &:hover { + opacity: 0.8; + } +} + +.errorText { + color: var(--color-red); + margin-bottom: 16px; +} + +.retryBtn { + padding: 8px 20px; + border: 1px solid var(--color-primary); + border-radius: 8px; + font-size: 14px; + font-weight: 700; + color: var(--color-primary); + transition: + background-color var(--transition-base), + color var(--transition-base); + + &:hover { + background-color: var(--color-primary); + color: var(--color-bg); + } +} + +.layout { + display: flex; + flex-direction: column; + gap: 16px; + + @media (min-width: 900px) { + flex-direction: row; + align-items: flex-start; + } + + @media (max-width: 639px) { + gap: 24px; + } +} + +.list { + list-style: none; + display: flex; + flex-direction: column; + gap: 16px; + flex: 1; +} + +.item { + display: grid; + grid-template-columns: 16px 66px 1fr; + grid-template-areas: + 'remove image name' + 'remove controls price'; + gap: 12px 16px; + align-items: center; + padding: 18px 16px; + background: var(--color-card); + border: 1px solid transparent; + + @media (min-width: 900px) { + grid-template-columns: 16px 66px minmax(0, 1fr) 112px 120px; + grid-template-areas: 'remove image name controls price'; + row-gap: 0; + padding: 24px; + } +} + +.removeBtn { + grid-area: remove; + font-size: 16px; + line-height: 1; + color: var(--color-secondary); + transition: color var(--transition-base); + + &:hover { + color: var(--color-primary); + } +} + +.imageLink { + grid-area: image; + display: block; + width: 66px; + height: 66px; +} + +.itemImage { + width: 66px; + height: 66px; + object-fit: contain; +} + +.itemName { + grid-area: name; + font-size: 14px; + font-weight: 600; + line-height: 1.4; + color: var(--color-primary); + + &:hover { + color: var(--color-secondary); + } +} + +.controls { + grid-area: controls; + display: flex; + align-items: center; + gap: 13px; + justify-self: start; + width: 100%; + justify-content: center; + + @media (min-width: 900px) { + justify-self: stretch; + } +} + +.qtyBtn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid var(--color-border); + border-radius: 0; + font-size: 16px; + color: var(--color-primary); + transition: + border-color var(--transition-base), + background-color var(--transition-base); + + &:disabled { + color: var(--color-secondary); + border-color: var(--color-border); + background: transparent; + cursor: default; + } + + &:hover:not(:disabled) { + border-color: var(--color-primary); + background: rgba(255, 255, 255, 0.06); + } +} + +.qty { + min-width: 8px; + text-align: center; + font-size: 14px; + font-weight: 700; + color: var(--color-primary); +} + +.itemPrice { + grid-area: price; + font-size: 32px; + font-weight: 800; + color: var(--color-primary); + white-space: nowrap; + line-height: 1; + justify-self: end; + width: 100%; + text-align: right; + + @media (max-width: 639px) { + font-size: 30px; + } +} + +.summary { + padding: 24px; + border: 1px solid var(--color-border); + border-radius: 0; + background: transparent; + text-align: center; + + @media (min-width: 900px) { + width: 368px; + flex-shrink: 0; + text-align: unset; + } +} + +.summaryPrice { + font-size: 32px; + font-weight: 800; + color: var(--color-primary); + margin-bottom: 4px; +} + +.summaryMeta { + font-size: 14px; + color: var(--color-secondary); +} + +.summaryDivider { + border: none; + border-top: 1px solid var(--color-border); + margin: 20px 0; +} + +.checkoutBtn { + width: 100%; + height: 48px; + background: #905bff; + color: var(--color-white); + font-size: 14px; + font-weight: 700; + letter-spacing: 0.4px; + border-radius: 0; + border: 1px solid transparent; + transition: + background-color var(--transition-base), + color var(--transition-base), + border-color var(--transition-base); + + &:hover { + background: #a378ff; + border-color: transparent; + color: var(--color-white); + } +} diff --git a/src/modules/CartPage/CartPage.tsx b/src/modules/CartPage/CartPage.tsx new file mode 100644 index 00000000000..a9601b21724 --- /dev/null +++ b/src/modules/CartPage/CartPage.tsx @@ -0,0 +1,195 @@ +import { useMemo } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; + +import { useCart } from '../shared/context'; +import { getProducts } from '../shared/api/apiClient'; +import { useAsync } from '../shared/hooks/useAsync'; +import { Loader } from '../shared/components/Loader'; +import type { Product } from '../shared/types/product'; +import styles from './CartPage.module.scss'; + +type CartRow = { + id: string; + quantity: number; + product: Product; +}; + +export const CartPage = () => { + const navigate = useNavigate(); + const { cart, getTotalQuantity, increment, decrement, remove, clear } = + useCart(); + + const handleCheckout = () => { + const confirmed = window.confirm( + 'Checkout is not implemented yet. Do you want to clear the Cart?', + ); + + if (confirmed) { + clear(); + } + }; + + const { data: products, loading, error, reload } = useAsync(getProducts); + + const rows = useMemo((): CartRow[] => { + if (!products) { + return []; + } + + return cart.reduce((acc, item) => { + const product = products.find(p => p.itemId === item.id); + + if (product) { + acc.push({ id: item.id, quantity: item.quantity, product }); + } + + return acc; + }, []); + }, [cart, products]); + + const totalPrice = useMemo( + () => rows.reduce((sum, row) => sum + row.product.price * row.quantity, 0), + [rows], + ); + + const totalQuantity = getTotalQuantity(); + + if (cart.length === 0) { + return ( +
+ + +

Cart

+ +
+

Your cart is empty

+ + + Browse Phones + +
+
+ ); + } + + if (loading) { + return ; + } + + if (error) { + return ( +
+ + +

Cart

+ +

{error}

+ + +
+ ); + } + + return ( +
+ + +

Cart

+ +
+
    + {rows.map(({ id, quantity, product }) => ( +
  • + + + + {product.name} + + + + {product.name} + + +
    + + + {quantity} + + +
    + + + ${product.price * quantity} + +
  • + ))} +
+ +
+

${totalPrice}

+ +

+ Total for{' '} + {totalQuantity === 1 ? '1 item' : `${totalQuantity} items`} +

+ +
+ + +
+
+
+ ); +}; diff --git a/src/modules/CartPage/index.ts b/src/modules/CartPage/index.ts new file mode 100644 index 00000000000..203fb0ea4bd --- /dev/null +++ b/src/modules/CartPage/index.ts @@ -0,0 +1 @@ +export { CartPage } from './CartPage'; diff --git a/src/modules/FavoritesPage/FavoritesPage.module.scss b/src/modules/FavoritesPage/FavoritesPage.module.scss new file mode 100644 index 00000000000..97dce66a1d5 --- /dev/null +++ b/src/modules/FavoritesPage/FavoritesPage.module.scss @@ -0,0 +1,59 @@ +.page { + max-width: var(--content-max-width); + margin: 0 auto; + padding: 40px 16px 80px; +} + +.title { + font-size: 40px; + font-weight: 800; + color: var(--color-primary); + margin-bottom: 8px; +} + +.count { + font-size: 14px; + color: var(--color-secondary); + margin-bottom: 32px; +} + +.empty { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 16px; + padding: 48px 0; +} + +.emptyText { + font-size: 20px; + font-weight: 600; + color: var(--color-secondary); +} + +.emptyLink { + display: inline-flex; + align-items: center; + padding: 10px 24px; + background-color: var(--color-primary); + color: var(--color-bg); + font-size: 14px; + font-weight: 700; + border-radius: 8px; + transition: opacity var(--transition-base); + + &:hover { + opacity: 0.8; + } +} + +.errorText { + color: var(--color-red); + margin-bottom: 16px; +} + +.noResults { + font-size: 16px; + color: var(--color-secondary); + padding: 24px 0; +} diff --git a/src/modules/FavoritesPage/FavoritesPage.tsx b/src/modules/FavoritesPage/FavoritesPage.tsx new file mode 100644 index 00000000000..b67d85152f5 --- /dev/null +++ b/src/modules/FavoritesPage/FavoritesPage.tsx @@ -0,0 +1,78 @@ +import { useCallback, useMemo } from 'react'; +import { Link, useSearchParams } from 'react-router-dom'; + +import { getProducts } from '../shared/api/apiClient'; +import { useAsync } from '../shared/hooks/useAsync'; +import { useFavorites } from '../shared/context'; +import { Loader } from '../shared/components/Loader'; +import { ProductsList } from '../shared/components/ProductsList'; +import styles from './FavoritesPage.module.scss'; + +export const FavoritesPage = () => { + const { favorites } = useFavorites(); + const [searchParams] = useSearchParams(); + const query = (searchParams.get('query') ?? '').trim().toLowerCase(); + + const fetchProducts = useCallback(() => getProducts(), []); + const { data: products, loading, error, reload } = useAsync(fetchProducts); + + const favoriteProducts = useMemo( + () => (products ?? []).filter(p => favorites.includes(p.itemId)), + [products, favorites], + ); + const filteredFavorites = useMemo(() => { + if (!query) { + return favoriteProducts; + } + + return favoriteProducts.filter(product => + product.name.toLowerCase().includes(query), + ); + }, [favoriteProducts, query]); + + if (favorites.length === 0) { + return ( +
+

Favorites

+
+

You have no favorite items yet.

+ + Browse Phones + +
+
+ ); + } + + if (loading) { + return ; + } + + if (error) { + return ( +
+

+ Something went wrong: {String(error)} +

+ +
+ ); + } + + return ( +
+

Favorites

+

{filteredFavorites.length} items

+ + {filteredFavorites.length === 0 && query ? ( +

+ There are no products matching the query +

+ ) : ( + + )} +
+ ); +}; diff --git a/src/modules/FavoritesPage/index.ts b/src/modules/FavoritesPage/index.ts new file mode 100644 index 00000000000..cc5cab74bca --- /dev/null +++ b/src/modules/FavoritesPage/index.ts @@ -0,0 +1 @@ +export { FavoritesPage } from './FavoritesPage'; diff --git a/src/modules/HomePage/HomePage.module.scss b/src/modules/HomePage/HomePage.module.scss new file mode 100644 index 00000000000..457b72c820a --- /dev/null +++ b/src/modules/HomePage/HomePage.module.scss @@ -0,0 +1,56 @@ +.page { + padding-bottom: 80px; +} + +.heading { + font-size: 32px; + font-weight: 800; + color: var(--color-primary); + line-height: 1.3; + padding: 24px 0; + + @media (min-width: 640px) { + font-size: 48px; + padding: 56px 0; + } +} + +.picturesSlider { + width: 100%; + margin-bottom: 56px; + + @media (min-width: 640px) { + margin-bottom: 64px; + } +} + +.container { + max-width: var(--content-max-width); + margin: 0 auto; + padding: 0 16px; + + @media (min-width: 1200px) { + max-width: 1360px; + } +} + +.section { + width: 100%; + margin-bottom: 80px; + + &:last-child { + margin-bottom: 0; + } +} + +.sectionTitle { + font-size: 22px; + font-weight: 800; + color: var(--color-primary); + margin-bottom: 24px; +} + +.error { + color: var(--color-red); + font-size: 14px; +} diff --git a/src/modules/HomePage/HomePage.tsx b/src/modules/HomePage/HomePage.tsx new file mode 100644 index 00000000000..fbf01fcbfcd --- /dev/null +++ b/src/modules/HomePage/HomePage.tsx @@ -0,0 +1,114 @@ +import { useMemo } from 'react'; + +import { getProducts } from '../shared/api/apiClient'; +import { Loader } from '../shared/components/Loader'; +import { PicturesSlider } from '../shared/components/PicturesSlider'; +import { ProductsSlider } from '../shared/components/ProductsSlider'; +import { ShopByCategory } from '../shared/components/ShopByCategory'; +import { useAsync } from '../shared/hooks/useAsync'; +import styles from './HomePage.module.scss'; + +export const HomePage = () => { + const { data: products, loading, error, reload } = useAsync(getProducts); + + const hotPriceProducts = useMemo(() => { + if (!products) { + return []; + } + + return [...products] + .sort((a, b) => b.fullPrice - b.price - (a.fullPrice - a.price)) + .slice(0, 16); + }, [products]); + + const brandNewProducts = useMemo(() => { + if (!products) { + return []; + } + + return [...products].sort((a, b) => b.year - a.year).slice(0, 16); + }, [products]); + + const categoryCounts = useMemo(() => { + if (!products) { + return { phones: 0, tablets: 0, accessories: 0 }; + } + + return { + phones: products.filter(p => p.category === 'phones').length, + tablets: products.filter(p => p.category === 'tablets').length, + accessories: products.filter(p => p.category === 'accessories').length, + }; + }, [products]); + + return ( +
+
+

Product Catalog

+

Welcome to Nice Gadgets store!

+
+ +
+
+ +
+
+ +
+
+ {loading && } + + {error && ( +

+ {error}{' '} + +

+ )} + + {!loading && !error && ( + + )} +
+ +
+

+ Shop by category +

+ + +
+ +
+ {loading && } + + {error && ( +

+ {error}{' '} + +

+ )} + + {!loading && !error && ( + + )} +
+
+
+ ); +}; diff --git a/src/modules/HomePage/index.ts b/src/modules/HomePage/index.ts new file mode 100644 index 00000000000..0799f479a25 --- /dev/null +++ b/src/modules/HomePage/index.ts @@ -0,0 +1 @@ +export { HomePage } from './HomePage'; diff --git a/src/modules/NotFoundPage/NotFoundPage.module.scss b/src/modules/NotFoundPage/NotFoundPage.module.scss new file mode 100644 index 00000000000..3694c13bb55 --- /dev/null +++ b/src/modules/NotFoundPage/NotFoundPage.module.scss @@ -0,0 +1,37 @@ +.page { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 80px 24px; + text-align: center; + gap: 24px; +} + +.image { + max-width: 320px; + width: 100%; +} + +.title { + font-size: 32px; + font-weight: 800; + color: var(--color-primary); +} + +.link { + display: inline-flex; + align-items: center; + padding: 12px 32px; + background-color: var(--color-primary); + color: var(--color-bg); + font-size: 14px; + font-weight: 700; + letter-spacing: 0.4px; + border-radius: 2px; + transition: opacity var(--transition-base); + + &:hover { + opacity: 0.8; + } +} diff --git a/src/modules/NotFoundPage/NotFoundPage.tsx b/src/modules/NotFoundPage/NotFoundPage.tsx new file mode 100644 index 00000000000..6dc0869bb63 --- /dev/null +++ b/src/modules/NotFoundPage/NotFoundPage.tsx @@ -0,0 +1,17 @@ +import { Link } from 'react-router-dom'; + +import styles from './NotFoundPage.module.scss'; + +export const NotFoundPage = () => ( +
+ Page not found +

Page not found

+ + Go to home page + +
+); diff --git a/src/modules/NotFoundPage/index.ts b/src/modules/NotFoundPage/index.ts new file mode 100644 index 00000000000..642c600088e --- /dev/null +++ b/src/modules/NotFoundPage/index.ts @@ -0,0 +1 @@ +export { NotFoundPage } from './NotFoundPage'; diff --git a/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss b/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss new file mode 100644 index 00000000000..602b4b4f6c8 --- /dev/null +++ b/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss @@ -0,0 +1,482 @@ +.page { + max-width: var(--content-max-width); + margin: 0 auto; + padding: 40px 16px 80px; +} + +.statusBlock { + margin: 40px auto 0; + max-width: 560px; + padding: 32px; + border: 1px solid var(--color-border); + background: var(--color-card); +} + +.statusTitle { + font-size: 32px; + font-weight: 800; + color: var(--color-primary); + margin-bottom: 12px; +} + +.statusText { + font-size: 14px; + color: var(--color-secondary); + margin-bottom: 24px; +} + +.statusActions { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.statusPrimaryBtn { + display: inline-flex; + align-items: center; + justify-content: center; + height: 40px; + padding: 0 24px; + border: 1px solid transparent; + background: var(--color-accent); + color: var(--color-white); + font-size: 14px; + font-weight: 700; + transition: background-color var(--transition-base); + + &:hover { + background: var(--color-accent-hover); + } +} + +.statusSecondaryLink { + display: inline-flex; + align-items: center; + justify-content: center; + height: 40px; + padding: 0 24px; + border: 1px solid var(--color-border); + color: var(--color-primary); + font-size: 14px; + font-weight: 700; + transition: border-color var(--transition-base); + + &:hover { + border-color: var(--color-primary); + } +} + +.backBtn { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 14px; + font-weight: 700; + color: var(--color-secondary); + margin-bottom: 16px; + transition: color var(--transition-base); + + &:hover { + color: var(--color-primary); + } +} + +.breadcrumb { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + margin-bottom: 24px; +} + +.breadcrumbLink { + color: var(--color-secondary); + transition: color var(--transition-base); + + &:hover { + color: var(--color-primary); + } +} + +.breadcrumbSep { + color: var(--color-secondary); +} + +.breadcrumbCurrent { + color: var(--color-primary); + font-weight: 700; +} + +.title { + font-size: 32px; + font-weight: 800; + color: var(--color-primary); + margin-bottom: 32px; +} + +.hero { + display: grid; + grid-template-columns: 1fr; + gap: 16px; + margin-bottom: 56px; + + @media (min-width: 900px) { + grid-template-columns: 80px minmax(0, 560px) minmax(280px, 320px); + gap: 64px; + align-items: start; + } +} + +.selectors { + margin-bottom: 24px; +} + +.selectorGroup { + margin-bottom: 16px; + padding-bottom: 16px; + border-bottom: 1px solid var(--color-border); +} + +.selectorGroup:last-child { + margin-bottom: 0; +} + +.selectorHeader { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} + +.selectorLabel { + font-size: 12px; + color: var(--color-secondary); + margin-bottom: 8px; +} + +.productCode { + font-size: 12px; + color: var(--color-secondary); + margin-bottom: 8px; +} + +.colorList { + display: flex; + gap: 8px; +} + +.colorInput { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.colorBtn { + display: block; + width: 32px; + height: 32px; + border-radius: 50%; + cursor: pointer; + border: 2px solid var(--color-bg); + box-shadow: 0 0 0 1px var(--color-border); + transition: box-shadow var(--transition-base); + + &:hover { + box-shadow: 0 0 0 1px var(--color-secondary); + } + .colorInput:checked + & { + box-shadow: 0 0 0 2px var(--color-primary); + } +} + +.capacityList { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.capacityInput { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.capacityBtn { + display: inline-flex; + cursor: pointer; + padding: 8px 16px; + border: 1px solid var(--color-border); + border-radius: 4px; + font-size: 14px; + font-weight: 700; + color: var(--color-primary); + transition: + border-color var(--transition-base), + background-color var(--transition-base), + color var(--transition-base); + + &:hover { + border-color: var(--color-primary); + } + + .capacityInput:checked + & { + background-color: var(--color-primary); + color: var(--color-bg); + border-color: var(--color-primary); + } +} + +.thumbnails { + display: flex; + flex-direction: row; + gap: 8px; + overflow-x: auto; + order: 2; + + @media (min-width: 900px) { + flex-direction: column; + overflow: visible; + order: 1; + } +} + +.thumbBtn { + width: 80px; + height: 80px; + border: 2px solid var(--color-border); + border-radius: 8px; + overflow: hidden; + padding: 4px; + transition: border-color var(--transition-base); + + &:hover { + border-color: var(--color-secondary); + } + + img { + width: 100%; + height: 100%; + object-fit: contain; + } +} + +.thumbBtnActive { + border-color: var(--color-primary); +} + +.details { + order: 3; + + @media (min-width: 900px) { + order: 3; + max-width: 320px; + } +} + +.priceRow { + display: flex; + align-items: baseline; + gap: 8px; + margin: 0 0 16px; +} + +.priceDiscount { + font-size: 32px; + font-weight: 800; + color: var(--color-primary); + line-height: 1; +} + +.priceRegular { + font-size: 22px; + color: var(--color-secondary); + text-decoration: line-through; +} + +.actions { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 24px; +} + +.cartButton { + flex: 1; + height: 48px; + background-color: var(--color-accent); + color: var(--color-white); + font-size: 14px; + font-weight: 600; + letter-spacing: 0.4px; + border-radius: 0; + border: 1px solid transparent; + transition: + background-color var(--transition-base), + color var(--transition-base), + border-color var(--transition-base); + + &:hover { + background-color: var(--color-accent-hover); + border-color: var(--color-accent-hover); + color: var(--color-white); + } +} + +.cartButtonAdded { + background-color: var(--color-surface); + color: var(--color-primary); + border-color: var(--color-primary); + cursor: default; +} + +.favoriteButton { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 48px; + height: 48px; + border: 1px solid var(--color-border); + border-radius: 0; + color: var(--color-primary); + transition: border-color var(--transition-base); + + &:hover { + border-color: var(--color-primary); + } +} + +.favoriteButtonActive { + color: var(--color-red); + border-color: var(--color-red); + + svg { + fill: currentColor; + } +} + +.shortSpecs { + display: flex; + flex-direction: column; + gap: 8px; +} + +.shortSpecRow { + display: flex; + justify-content: space-between; + gap: 12px; + font-size: 12px; +} + +.shortSpecLabel { + color: var(--color-secondary); +} + +.shortSpecValue { + color: var(--color-primary); + font-weight: 700; + text-align: right; +} + +.infoGrid { + display: grid; + grid-template-columns: 1fr; + gap: 56px; + margin-bottom: 56px; + + @media (min-width: 900px) { + grid-template-columns: minmax(0, 1fr) minmax(280px, 1fr); + gap: 64px; + align-items: start; + } +} + +.about { + border-top: 1px solid var(--color-border); + padding-top: 32px; +} + +.aboutTitle { + font-size: 22px; + font-weight: 800; + color: var(--color-primary); + margin-bottom: 32px; +} + +.aboutSection { + margin-bottom: 32px; +} + +.aboutSectionTitle { + font-size: 20px; + font-weight: 700; + color: var(--color-primary); + margin-bottom: 16px; +} + +.aboutText { + font-size: 14px; + line-height: 1.75; + color: var(--color-secondary); + + & + & { + margin-top: 12px; + } +} + +.techSpecs { + border-top: 1px solid var(--color-border); + padding-top: 32px; +} + +.techSpecsTitle { + font-size: 22px; + font-weight: 800; + color: var(--color-primary); + margin-bottom: 24px; +} + +.specRow { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid var(--color-border); +} + +.specLabel { + font-size: 14px; + color: var(--color-secondary); +} + +.specValue { + font-size: 14px; + font-weight: 700; + color: var(--color-primary); + text-align: right; + max-width: 60%; +} + +.suggested { + border-top: 1px solid var(--color-border); + padding-top: 32px; + margin-bottom: 56px; +} + +.mainImage { + display: flex; + justify-content: center; + align-items: center; + min-height: 320px; + order: 1; + + img { + max-height: 520px; + max-width: 100%; + object-fit: contain; + } + + @media (min-width: 900px) { + order: 2; + } +} diff --git a/src/modules/ProductDetailsPage/ProductDetailsPage.tsx b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx new file mode 100644 index 00000000000..1277ff6c476 --- /dev/null +++ b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx @@ -0,0 +1,555 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Link, useNavigate, useParams } from 'react-router-dom'; + +import { + getCategoryProductDetails, + getProductDetails, + getProducts, + getSuggestedProducts, +} from '../shared/api/apiClient'; +import { ProductsSlider } from '../shared/components/ProductsSlider'; +import { useAsync } from '../shared/hooks/useAsync'; +import { Loader } from '../shared/components/Loader'; +import { useCart, useFavorites } from '../shared/context'; +import type { ProductCategory } from '../shared/types/product'; +import type { ProductDetails } from '../shared/types/productDetails'; +import styles from './ProductDetailsPage.module.scss'; + +const CATEGORY_LABELS: Record = { + phones: 'Phones', + tablets: 'Tablets', + accessories: 'Accessories', +}; + +const COLOR_MAP: Record = { + midnight: '#1F2020', + starlight: '#F2E8D9', + purple: '#B9A2C7', + yellow: '#FAE04C', + green: '#3D6B54', + blue: '#276787', + red: '#BF0000', + black: '#1B1B1B', + white: '#F5F5F0', + pink: '#F9C0BB', + spaceblack: '#403E3D', + spacegray: '#57595D', + silver: '#CBCBCB', + gold: '#F9E4C8', + graphite: '#54524F', + sierrablue: '#A7C1D9', + alpinegreen: '#576856', + skyblue: '#8AB4C8', + rosegold: '#E8BCAC', +}; + +type VariantItem = { + id: string; + namespaceId: string; + color: string; + capacity: string; +}; + +type DetailsPayload = { + product: ProductDetails | null; + category: ProductCategory | null; + productCode: number | null; +}; + +const normalizeVariantValue = (value: string): string => + value.toLowerCase().replace(/[\s-]/g, ''); + +const HEART_PATH = + 'M8 13c-.24 0-.47-.09-.65-.25C5.48 11.13 2 8.09 2 5.25 ' + + '2 3.46 3.4 2 5.12 2 6.16 2 7.13 2.53 7.7 3.39L8 3.84l.3-.45' + + 'C8.87 2.53 9.84 2 10.88 2 12.6 2 14 3.46 14 5.25c0 2.84-3.48 ' + + '5.88-5.35 7.5-.18.16-.41.25-.65.25z'; + +const findVariantId = ( + namespaceId: string, + color: string, + capacity: string, + variants: VariantItem[], +): string | null => { + const normalizedNamespace = normalizeVariantValue(namespaceId); + const normalizedColor = normalizeVariantValue(color); + const normalizedCapacity = normalizeVariantValue(capacity); + + const match = variants.find( + item => + normalizeVariantValue(item.namespaceId) === normalizedNamespace && + normalizeVariantValue(item.color) === normalizedColor && + normalizeVariantValue(item.capacity) === normalizedCapacity, + ); + + return match?.id ?? null; +}; + +const smoothScrollToTop = (duration = 700) => { + const startY = window.scrollY; + const root = document.documentElement; + const previousScrollBehavior = root.style.scrollBehavior; + + if (startY <= 0) { + return () => {}; + } + + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + window.scrollTo(0, 0); + + return () => {}; + } + + const startTime = performance.now(); + let frameId = 0; + + root.style.scrollBehavior = 'auto'; + + const easeInOutCubic = (t: number) => + t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; + + const tick = (now: number) => { + const elapsed = now - startTime; + const progress = Math.min(elapsed / duration, 1); + const eased = easeInOutCubic(progress); + + window.scrollTo(0, startY * (1 - eased)); + + if (progress < 1) { + frameId = window.requestAnimationFrame(tick); + } else { + root.style.scrollBehavior = previousScrollBehavior; + } + }; + + frameId = window.requestAnimationFrame(tick); + + return () => { + if (frameId) { + window.cancelAnimationFrame(frameId); + } + + root.style.scrollBehavior = previousScrollBehavior; + }; +}; + +export const ProductDetailsPage = () => { + const { productId = '' } = useParams(); + const navigate = useNavigate(); + const { isInCart, add } = useCart(); + const { isFavorite, toggle } = useFavorites(); + + const fetchDetails = useCallback(async (): Promise => { + if (!productId) { + return { product: null, category: null, productCode: null }; + } + + const products = await getProducts(); + const base = products.find(p => p.itemId === productId); + + if (!base) { + return { product: null, category: null, productCode: null }; + } + + try { + const details = await getProductDetails(base.category, productId); + + return { + product: details, + category: base.category, + productCode: base.id, + }; + } catch (err: unknown) { + if (err instanceof Error && err.message.includes('404')) { + return { product: null, category: base.category, productCode: null }; + } + + throw err; + } + }, [productId]); + + const { data, loading, error, reload } = useAsync(fetchDetails); + const product = data?.product ?? null; + const fallbackCategory = data?.category ?? null; + const productCode = data?.productCode ?? null; + + const fetchSuggested = useCallback(async () => { + if (!product) { + return []; + } + + return getSuggestedProducts(productId, product.category); + }, [product, productId]); + + const { data: suggested, loading: suggestedLoading } = + useAsync(fetchSuggested); + + const [activeImage, setActiveImage] = useState(''); + + const fetchVariants = useCallback(async (): Promise => { + if (!product) { + return []; + } + + const [products, categoryDetails] = await Promise.all([ + getProducts(), + getCategoryProductDetails(product.category), + ]); + + const availableIds = new Set(products.map(item => item.itemId)); + + return categoryDetails + .filter( + item => + item.namespaceId === product.namespaceId && availableIds.has(item.id), + ) + .map(item => ({ + id: item.id, + namespaceId: item.namespaceId, + color: item.color, + capacity: item.capacity, + })); + }, [product]); + + const { data: variants } = useAsync(fetchVariants); + + useEffect(() => { + return smoothScrollToTop(); + }, [productId]); + + useEffect(() => { + if (product) { + setActiveImage(product.images[0]); + } + }, [product]); + + if (loading && !product) { + return ; + } + + if (error) { + return ( +
+
+

Something went wrong

+

{String(error)}

+
+ +
+
+
+ ); + } + + if (!product) { + const categoryPath = fallbackCategory ? `/${fallbackCategory}` : '/'; + const categoryLabel = fallbackCategory + ? CATEGORY_LABELS[fallbackCategory] + : 'Home'; + + const handleFallbackBack = () => { + if (window.history.state?.idx > 0) { + navigate(-1); + } else { + navigate(categoryPath); + } + }; + + return ( +
+
+

Product was not found

+

+ The requested product does not exist or has no details. +

+
+ + + + Go to {categoryLabel} + +
+
+
+ ); + } + + const category = product.category; + + const handleBack = () => { + if (window.history.state?.idx > 0) { + navigate(-1); + } else { + navigate(`/${category}`); + } + }; + + const handleVariantChange = (nextColor: string, nextCapacity: string) => { + const matchedId = findVariantId( + product.namespaceId, + nextColor, + nextCapacity, + variants ?? [], + ); + + if (!matchedId || matchedId === productId) { + return; + } + + navigate(`/product/${matchedId}`, { replace: true }); + }; + + const handleSuggestedOpen = () => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + const inCart = isInCart(product.id); + const favorite = isFavorite(product.id); + + return ( +
+ + + + +

{product.name}

+ +
+
    + {product.images.map(img => ( +
  • + +
  • + ))} +
+ +
+ {product.name} +
+ +
+
+
+
+

Available colors

+ {productCode && ( +

ID: {productCode}

+ )} +
+
    + {product.colorsAvailable.map(color => ( +
  • + + handleVariantChange(color, product.capacity) + } + className={styles.colorInput} + /> + +
  • + ))} +
+
+ +
+

Select capacity

+
    + {product.capacityAvailable.map(cap => ( +
  • + handleVariantChange(product.color, cap)} + className={styles.capacityInput} + /> + +
  • + ))} +
+
+
+ +
+ + ${product.priceDiscount} + + ${product.priceRegular} +
+ +
+ + + +
+ +
+
+ Screen + {product.screen} +
+
+ Resolution + + {product.resolution} + +
+
+ Processor + {product.processor} +
+
+ RAM + {product.ram} +
+
+
+
+ +
+
+

About

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

{section.title}

+ {section.text.map(paragraph => ( +

+ {paragraph} +

+ ))} +
+ ))} +
+ +
+

Tech specs

+ {[ + { label: 'Screen', value: product.screen }, + { label: 'Resolution', value: product.resolution }, + { label: 'Processor', value: product.processor }, + { label: 'RAM', value: product.ram }, + { label: 'Capacity', value: product.capacity }, + ...(product.camera + ? [{ label: 'Camera', value: product.camera }] + : []), + ...(product.zoom ? [{ label: 'Zoom', value: product.zoom }] : []), + { label: 'Cell', value: product.cell.join(', ') }, + ].map(({ label, value }) => ( +
+ {label} + {value} +
+ ))} +
+
+ +
+ {suggestedLoading && !suggested?.length && } + {suggested && ( + + )} +
+
+ ); +}; diff --git a/src/modules/ProductDetailsPage/index.ts b/src/modules/ProductDetailsPage/index.ts new file mode 100644 index 00000000000..ec50c119343 --- /dev/null +++ b/src/modules/ProductDetailsPage/index.ts @@ -0,0 +1 @@ +export { ProductDetailsPage } from './ProductDetailsPage'; diff --git a/src/modules/ProductsPage/ProductsPage.module.scss b/src/modules/ProductsPage/ProductsPage.module.scss new file mode 100644 index 00000000000..de6a3cb320b --- /dev/null +++ b/src/modules/ProductsPage/ProductsPage.module.scss @@ -0,0 +1,167 @@ +.page { + max-width: var(--content-max-width); + margin: 0 auto; + padding: 40px 16px 80px; + + @media (min-width: 1200px) { + padding: 24px 32px 80px; + } +} + +.breadcrumb { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 24px; +} + +.breadcrumbLink { + display: inline-flex; + color: var(--color-primary); +} + +.breadcrumbSep, +.breadcrumbCurrent { + font-size: 12px; + color: var(--color-secondary); +} + +.title { + font-size: 48px; + font-weight: 800; + line-height: 1.15; + color: var(--color-primary); + margin-bottom: 8px; +} + +.count { + font-size: 14px; + color: var(--color-secondary); + margin-bottom: 40px; +} + +.status { + font-size: 16px; + color: var(--color-secondary); + margin-bottom: 16px; +} + +.reloadBtn { + padding: 12px 24px; + background-color: var(--color-primary); + color: var(--color-bg); + font-size: 14px; + font-weight: 700; + border-radius: 8px; +} + +.noResults { + font-size: 16px; + color: var(--color-secondary); + padding: 40px 0; +} + +.controls { + display: flex; + gap: 16px; + flex-wrap: wrap; + margin-bottom: 32px; +} + +.controlGroup { + display: flex; + flex-direction: column; + gap: 4px; +} + +.controlLabel { + font-size: 12px; + font-weight: 600; + color: var(--color-secondary); + text-transform: none; + letter-spacing: 0; +} + +.selectWrapper { + position: relative; + + &::after { + content: ''; + position: absolute; + top: 50%; + right: 12px; + width: 0; + height: 0; + transform: translateY(-50%); + border-top: 5px solid var(--color-primary); + border-right: 4px solid transparent; + border-left: 4px solid transparent; + pointer-events: none; + } +} + +.select { + height: 56px; + min-width: 136px; + padding: 0 36px 0 16px; + border: 1px solid transparent; + border-radius: 0; + background-color: #323542; + color: var(--color-primary); + font-size: 22px; + font-weight: 700; + cursor: pointer; + appearance: none; + transition: border-color var(--transition-base); + + &:hover, + &:focus { + border-color: var(--color-icons); + outline: none; + } +} + +.selectWide { + width: 176px; +} + +.selectNarrow { + width: 128px; +} + +.pagination { + display: flex; + gap: 8px; + justify-content: center; + flex-wrap: wrap; + margin-top: 40px; +} + +.pageBtn { + display: flex; + align-items: center; + justify-content: center; + min-width: 32px; + height: 32px; + padding: 0 8px; + border: 1px solid var(--color-border); + border-radius: 8px; + background-color: var(--color-surface); + color: var(--color-primary); + font-size: 14px; + font-weight: 700; + transition: + border-color var(--transition-base), + background-color var(--transition-base), + color var(--transition-base); + + &:hover { + border-color: var(--color-primary); + } +} + +.pageBtnActive { + background-color: var(--color-primary); + color: var(--color-bg); + border-color: var(--color-primary); +} diff --git a/src/modules/ProductsPage/ProductsPage.tsx b/src/modules/ProductsPage/ProductsPage.tsx new file mode 100644 index 00000000000..e600fc2beb7 --- /dev/null +++ b/src/modules/ProductsPage/ProductsPage.tsx @@ -0,0 +1,343 @@ +import { type ChangeEvent, useCallback, useEffect, useMemo } from 'react'; +import { Link, useSearchParams } from 'react-router-dom'; +import cn from 'classnames'; + +import { getProducts } from '../shared/api/apiClient'; +import { useAsync } from '../shared/hooks/useAsync'; +import { ProductsList } from '../shared/components/ProductsList'; +import { Loader } from '../shared/components/Loader'; +import type { Product, ProductCategory } from '../shared/types/product'; +import styles from './ProductsPage.module.scss'; + +type SortKey = 'age' | 'title' | 'price'; +type PerPage = '4' | '8' | '16' | 'all'; + +type Props = { + category: ProductCategory; +}; + +type ParamsUpdate = { + query?: string; + sort?: SortKey; + perPage?: PerPage; + page?: number; +}; + +const TITLES: Record = { + phones: 'Mobile phones', + tablets: 'Tablets', + accessories: 'Accessories', +}; + +const BREADCRUMB_LABELS: Record = { + phones: 'Phones', + tablets: 'Tablets', + accessories: 'Accessories', +}; + +const SORT_OPTIONS: { value: SortKey; label: string }[] = [ + { value: 'age', label: 'Newest' }, + { value: 'title', label: 'Alphabetically' }, + { value: 'price', label: 'Cheapest' }, +]; + +const PER_PAGE_OPTIONS: PerPage[] = ['4', '8', '16', 'all']; + +const VALID_SORT: SortKey[] = ['age', 'title', 'price']; +const VALID_PER_PAGE: PerPage[] = ['4', '8', '16', 'all']; + +const getDiscountedPrice = (p: Product): number => p.price; + +const sortProducts = (list: Product[], sort: SortKey): Product[] => { + const copy = [...list]; + + switch (sort) { + case 'title': + return copy.sort((a, b) => a.name.localeCompare(b.name)); + case 'price': + return copy.sort((a, b) => getDiscountedPrice(a) - getDiscountedPrice(b)); + case 'age': + default: + return copy.sort((a, b) => b.year - a.year); + } +}; + +const paginate = (items: T[], page: number, perPage: number): T[] => + items.slice((page - 1) * perPage, page * perPage); + +export const ProductsPage = ({ category }: Props) => { + const [searchParams, setSearchParams] = useSearchParams(); + + const rawSort = searchParams.get('sort'); + const rawPerPage = searchParams.get('perPage'); + const rawPage = searchParams.get('page'); + const rawQuery = searchParams.get('query'); + + const sort: SortKey = VALID_SORT.includes(rawSort as SortKey) + ? (rawSort as SortKey) + : 'age'; + + const perPage: PerPage = VALID_PER_PAGE.includes(rawPerPage as PerPage) + ? (rawPerPage as PerPage) + : '16'; + + const page = Math.max(1, Number(rawPage) || 1); + const query = rawQuery ?? ''; + + const fetchByCategory = useCallback( + () => getProducts().then(all => all.filter(p => p.category === category)), + [category], + ); + + const { data: products, loading, error, reload } = useAsync(fetchByCategory); + + const filtered = useMemo(() => { + const all = products ?? []; + + if (!query) { + return all; + } + + const q = query.toLowerCase(); + + return all.filter(p => p.name.toLowerCase().includes(q)); + }, [products, query]); + + const sorted = useMemo(() => sortProducts(filtered, sort), [filtered, sort]); + + const totalItems = sorted.length; + + const perPageNum = useMemo( + () => (perPage === 'all' ? Math.max(totalItems, 1) : Number(perPage)), + [perPage, totalItems], + ); + + const totalPages = Math.ceil(totalItems / perPageNum) || 1; + const clampedPage = Math.max(1, Math.min(page, totalPages)); + + const visible = useMemo( + () => + perPage === 'all' ? sorted : paginate(sorted, clampedPage, perPageNum), + [perPage, sorted, clampedPage, perPageNum], + ); + + useEffect(() => { + if (products && clampedPage !== page) { + setSearchParams( + prev => { + const next = new URLSearchParams(prev); + + if (clampedPage === 1) { + next.delete('page'); + } else { + next.set('page', String(clampedPage)); + } + + return next; + }, + { replace: true }, + ); + } + }, [clampedPage, page, products, setSearchParams]); + + const setParams = (update: ParamsUpdate) => { + setSearchParams(prev => { + const next = new URLSearchParams(prev); + + if ('query' in update) { + if (!update.query) { + next.delete('query'); + } else { + next.set('query', update.query); + } + } + + if ('sort' in update) { + if (!update.sort || update.sort === 'age') { + next.delete('sort'); + } else { + next.set('sort', update.sort); + } + } + + if ('perPage' in update) { + if (!update.perPage || update.perPage === '16') { + next.delete('perPage'); + } else { + next.set('perPage', update.perPage); + } + } + + if ('page' in update) { + const p = update.page ?? 1; + + next.set('page', String(p)); + } + + if ('sort' in update || 'perPage' in update || 'query' in update) { + next.delete('page'); + } + + if (next.get('page') === '1') { + next.delete('page'); + } + + if (next.get('perPage') === 'all') { + next.delete('page'); + } + + return next; + }); + }; + + const handleSortChange = (e: ChangeEvent) => { + setParams({ sort: e.target.value as SortKey }); + }; + + const handlePerPageChange = (e: ChangeEvent) => { + setParams({ perPage: e.target.value as PerPage }); + }; + + const handlePageChange = (newPage: number) => { + setParams({ page: newPage }); + }; + + const showPagination = perPage !== 'all' && totalPages > 1; + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+

Something went wrong: {error}

+ +
+ ); + } + + if (products?.length === 0) { + return ( +
+

{TITLES[category]}

+

+ There are no {TITLES[category].toLowerCase()} yet +

+
+ ); + } + + return ( +
+ + +

{TITLES[category]}

+

{totalItems} models

+ +
+
+ +
+ +
+
+ +
+ +
+ +
+
+
+ + {totalItems === 0 && query ? ( +

+ There are no {TITLES[category].toLowerCase()} matching «{query}» +

+ ) : ( + + )} + + {showPagination && ( +
+ {Array.from({ length: totalPages }, (_, i) => i + 1).map(p => ( + + ))} +
+ )} +
+ ); +}; diff --git a/src/modules/ProductsPage/index.ts b/src/modules/ProductsPage/index.ts new file mode 100644 index 00000000000..0569c38ebe0 --- /dev/null +++ b/src/modules/ProductsPage/index.ts @@ -0,0 +1 @@ +export { ProductsPage } from './ProductsPage'; diff --git a/src/modules/shared/api/apiClient.ts b/src/modules/shared/api/apiClient.ts new file mode 100644 index 00000000000..2e62cb60ca4 --- /dev/null +++ b/src/modules/shared/api/apiClient.ts @@ -0,0 +1,75 @@ +import type { Product, ProductCategory } from '../types/product'; +import type { ProductDetails } from '../types/productDetails'; +import { + detailsImagesOverrides, + productImageOverrides, +} from '../config/imageOverrides'; + +const BASE_URL = `${import.meta.env.BASE_URL}api`; + +const fetchJson = async (url: string): Promise => { + const response = await fetch(url); + + if (!response.ok) { + throw new Error( + `Failed to load data: ${response.status} ${response.statusText}`, + ); + } + + return response.json() as Promise; +}; + +export const getProducts = async (): Promise => { + const products = await fetchJson(`${BASE_URL}/products.json`); + + return products.map(product => ({ + ...product, + image: productImageOverrides[product.itemId] ?? product.image, + })); +}; + +export const getProductDetails = async ( + category: ProductCategory, + productId: string, +): Promise => { + const items = await fetchJson( + `${BASE_URL}/${category}.json`, + ); + const details = items.find(item => item.id === productId); + + if (!details) { + return null; + } + + return { + ...details, + images: detailsImagesOverrides[details.id] ?? details.images, + }; +}; + +export const getCategoryProductDetails = ( + category: ProductCategory, +): Promise => + fetchJson(`${BASE_URL}/${category}.json`); + +export const getSuggestedProducts = async ( + currentId: string, + category: ProductCategory, + limit = 12, +): Promise => { + const products = await getProducts(); + + const pool = products.filter( + p => p.category === category && p.itemId !== currentId, + ); + + const shuffled = [...pool]; + + for (let i = shuffled.length - 1; i > 0; i -= 1) { + const j = Math.floor(Math.random() * (i + 1)); + + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + + return shuffled.slice(0, limit); +}; diff --git a/src/modules/shared/components/AppLayout/AppLayout.module.scss b/src/modules/shared/components/AppLayout/AppLayout.module.scss new file mode 100644 index 00000000000..8a0f6c2e2b2 --- /dev/null +++ b/src/modules/shared/components/AppLayout/AppLayout.module.scss @@ -0,0 +1,10 @@ +.layout { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.main { + flex: 1; + padding-top: var(--header-height); +} diff --git a/src/modules/shared/components/AppLayout/AppLayout.tsx b/src/modules/shared/components/AppLayout/AppLayout.tsx new file mode 100644 index 00000000000..d3333fa2339 --- /dev/null +++ b/src/modules/shared/components/AppLayout/AppLayout.tsx @@ -0,0 +1,15 @@ +import { Outlet } from 'react-router-dom'; + +import { Footer } from '../Footer'; +import { Header } from '../Header'; +import styles from './AppLayout.module.scss'; + +export const AppLayout = () => ( +
+
+
+ +
+
+
+); diff --git a/src/modules/shared/components/AppLayout/index.ts b/src/modules/shared/components/AppLayout/index.ts new file mode 100644 index 00000000000..763c036689d --- /dev/null +++ b/src/modules/shared/components/AppLayout/index.ts @@ -0,0 +1 @@ +export { AppLayout } from './AppLayout'; 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..ee2f7145642 --- /dev/null +++ b/src/modules/shared/components/Footer/Footer.module.scss @@ -0,0 +1,109 @@ +.footer { + background-color: var(--color-header-bg); + border-top: 1px solid var(--color-header-border); +} + +.inner { + max-width: 1360px; + margin: 0 auto; + padding: 0 16px; + height: 80px; + display: flex; + align-items: center; + justify-content: space-between; + + @media (max-width: 639px) { + height: auto; + min-height: 250px; + width: 100%; + padding: 24px 20px; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 12px; + } +} + +.logo { + font-size: 11px; + font-weight: 800; + color: var(--color-header-text); + text-transform: uppercase; + letter-spacing: 2px; + line-height: 1.4; +} + +.logoOk { + display: inline-block; + color: #f8d449; + font-size: 10px; + line-height: 1; + transform: translateY(-2px); +} + +.nav { + display: flex; + gap: 32px; + + @media (max-width: 639px) { + flex-direction: column; + gap: 10px; + align-items: flex-start; + } +} + +.link { + font-size: 12px; + font-weight: 700; + letter-spacing: 1px; + text-transform: uppercase; + color: #f1f2f9; + + &:hover { + color: var(--color-header-text); + } +} + +.backToTop { + display: flex; + align-items: center; + gap: 16px; + color: var(--color-secondary); + + @media (max-width: 639px) { + gap: 8px; + align-self: center; + margin-top: 20px; + } + + &:hover { + color: var(--color-header-text); + } +} + +.backToTopText { + font-size: 12px; + font-weight: 700; + letter-spacing: 1px; + text-transform: uppercase; + + @media (max-width: 639px) { + letter-spacing: 0.5px; + text-transform: none; + } +} + +.backToTopIcon { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + background-color: rgba(255, 255, 255, 0.12); + color: var(--color-white); + transition: background-color var(--transition-base); + + .backToTop:hover & { + background-color: rgba(255, 255, 255, 0.2); + } +} diff --git a/src/modules/shared/components/Footer/Footer.tsx b/src/modules/shared/components/Footer/Footer.tsx new file mode 100644 index 00000000000..6741c4789f9 --- /dev/null +++ b/src/modules/shared/components/Footer/Footer.tsx @@ -0,0 +1,69 @@ +import styles from './Footer.module.scss'; + +const GITHUB_REPO_URL = 'https://github.com/ProKesha/react_phone-catalog'; +const GITHUB_PROFILE_URL = 'https://github.com/ProKesha'; +const LICENSE_URL = + 'https://github.com/ProKesha/react_phone-catalog/blob/HEAD/LICENSE'; + +const handleScrollToTop = () => { + window.scrollTo({ top: 0, behavior: 'smooth' }); +}; + +export const Footer = () => ( + +); diff --git a/src/modules/shared/components/Footer/index.ts b/src/modules/shared/components/Footer/index.ts new file mode 100644 index 00000000000..65e2506faf5 --- /dev/null +++ b/src/modules/shared/components/Footer/index.ts @@ -0,0 +1 @@ +export { Footer } from './Footer'; 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..4326f4bedde --- /dev/null +++ b/src/modules/shared/components/Header/Header.module.scss @@ -0,0 +1,299 @@ +.header { + position: fixed; + top: 0; + left: 0; + right: 0; + height: var(--header-height); + background-color: var(--color-header-bg); + border-bottom: 1px solid var(--color-header-border); + z-index: 100; +} + +.inner { + display: flex; + align-items: stretch; + justify-content: space-between; + height: 100%; +} + +.left { + display: flex; + align-items: stretch; +} + +.logo { + display: flex; + align-items: center; + padding: 0 24px; + flex-shrink: 0; + + &Text { + font-size: 10px; + font-weight: 800; + color: var(--color-header-text); + text-transform: uppercase; + letter-spacing: 0.8px; + line-height: 1.3; + } + + &Ok { + display: inline-block; + color: #f8d449; + font-size: 9px; + line-height: 1; + transform: translateY(-2px); + } +} + +.nav { + display: flex; + align-items: stretch; + + @media (max-width: 639px) { + display: none; + } +} + +.navLink { + display: flex; + align-items: center; + padding: 0 16px; + font-size: 12px; + font-weight: 700; + letter-spacing: 1px; + text-transform: uppercase; + color: var(--color-secondary); + position: relative; + + &::after { + content: ''; + position: absolute; + bottom: 0; + left: 16px; + right: 16px; + height: 3px; + background-color: transparent; + transition: background-color var(--transition-base); + } + + &:hover { + color: var(--color-header-text); + + &::after { + background-color: var(--color-header-text); + } + } +} + +.navLinkActive { + color: var(--color-header-text); + + &::after { + background-color: var(--color-header-text); + } +} + +.actions { + display: flex; + align-items: stretch; + + @media (max-width: 639px) { + display: none; + } +} + +.iconLink svg, +.mobileIconLink svg { + transition: opacity var(--transition-base); +} + +.iconLink { + display: flex; + align-items: center; + justify-content: center; + width: 64px; + border-left: 1px solid var(--color-header-border); + color: var(--color-header-text); + position: relative; + + &::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 3px; + background-color: transparent; + transition: background-color var(--transition-base); + } + + &:hover svg { + opacity: 0.7; + } +} + +.iconLinkActive { + &::after { + background-color: var(--color-header-text); + } +} + +.hamburger { + display: none; + align-items: center; + justify-content: center; + width: 64px; + border-left: 1px solid var(--color-header-border); + color: var(--color-header-text); + flex-shrink: 0; + + @media (max-width: 639px) { + display: flex; + } +} + +.mobileMenu { + position: fixed; + inset: var(--header-height) 0 0 0; + background-color: var(--color-header-bg); + z-index: 99; + display: flex; + flex-direction: column; + border-top: 1px solid var(--color-header-border); +} + +.mobileNav { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + padding-top: 24px; +} + +.mobileNavLink { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + padding: 20px 24px; + font-size: 12px; + font-weight: 700; + letter-spacing: 1px; + text-transform: uppercase; + color: var(--color-secondary); + border-bottom: none; + transition: color var(--transition-base); + + &:hover { + color: var(--color-header-text); + } +} + +.mobileNavLinkActive { + color: var(--color-header-text); +} + +.mobileActions { + margin-top: auto; + display: flex; + border-top: 1px solid var(--color-header-border); +} + +.mobileIconLink { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + height: 64px; + border-right: 1px solid var(--color-header-border); + color: var(--color-secondary); + position: relative; + + &:last-child { + border-right: none; + } + + &:hover svg { + opacity: 0.7; + } +} + +.mobileIconLinkActive { + color: var(--color-header-text); + + &::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 3px; + background-color: var(--color-header-text); + } +} + +.search { + display: flex; + align-items: center; + gap: 8px; + padding: 0 16px; + border-left: 1px solid var(--color-header-border); + min-width: 176px; +} + +.searchIcon { + flex-shrink: 0; + color: var(--color-secondary); +} + +.searchInput { + flex: 1; + background: transparent; + border: none; + outline: none; + color: var(--color-header-text); + font-size: 14px; + min-width: 0; + + &::placeholder { + color: var(--color-secondary); + } + + &::-webkit-search-cancel-button { + display: none; + } +} + +.searchClear { + background: none; + border: none; + padding: 0; + color: var(--color-secondary); + cursor: pointer; + font-size: 14px; + line-height: 1; + flex-shrink: 0; + transition: color var(--transition-base); + + &:hover { + color: var(--color-header-text); + } +} + +.badge { + position: absolute; + top: 8px; + right: 8px; + min-width: 16px; + height: 16px; + background-color: var(--color-red); + color: var(--color-white); + border-radius: 8px; + font-size: 10px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + padding: 0 4px; + pointer-events: none; +} diff --git a/src/modules/shared/components/Header/Header.tsx b/src/modules/shared/components/Header/Header.tsx new file mode 100644 index 00000000000..0f6c96c46bc --- /dev/null +++ b/src/modules/shared/components/Header/Header.tsx @@ -0,0 +1,319 @@ +import { type ChangeEvent, useEffect, useState } from 'react'; +import { NavLink, useLocation, useSearchParams } from 'react-router-dom'; +import cn from 'classnames'; + +import { useDebounce } from '../../hooks/useDebounce'; +import { useCart, useFavorites } from '../../context'; +import styles from './Header.module.scss'; + +type NavLinkState = { isActive: boolean }; + +const NAV_LINKS = [ + { path: '/', label: 'Home', end: true }, + { path: '/phones', label: 'Phones', end: false }, + { path: '/tablets', label: 'Tablets', end: false }, + { path: '/accessories', label: 'Accessories', end: false }, +]; + +const SEARCH_ROUTES = ['/phones', '/tablets', '/accessories', '/favorites']; + +const SEARCH_PLACEHOLDER: Record = { + '/phones': 'Search in Phones...', + '/tablets': 'Search in Tablets...', + '/accessories': 'Search in Accessories...', + '/favorites': 'Search in Favorites...', +}; + +const getNavClass = ({ isActive }: NavLinkState) => + cn(styles.navLink, { [styles.navLinkActive]: isActive }); + +const getIconClass = ({ isActive }: NavLinkState) => + cn(styles.iconLink, { [styles.iconLinkActive]: isActive }); + +const HEART_PATH = + 'M8 13c-.24 0-.47-.09-.65-.25C5.48 11.13 2 8.09 2 5.25 ' + + '2 3.46 3.4 2 5.12 2 6.16 2 7.13 2.53 7.7 3.39L8 3.84l.3-.45' + + 'C8.87 2.53 9.84 2 10.88 2 12.6 2 14 3.46 14 5.25c0 2.84-3.48 ' + + '5.88-5.35 7.5-.18.16-.41.25-.65.25z'; + +const CART_HANDLE_PATH = 'M5 6V4.5a3 3 0 1 1 6 0V6'; +const CART_BODY_PATH = 'M3 6h10l-.75 8H3.75L3 6z'; + +const SEARCH_PATH = + 'M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001q.044.06' + + '.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1 ' + + '1 0 0 0-.115-.099zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0'; + +const HAMBURGER_PATH = 'M1 3h14M1 8h14M1 13h14'; +const CLOSE_PATH = 'M2 2l12 12M14 2L2 14'; + +const getMobileNavClass = ({ isActive }: NavLinkState) => + cn(styles.mobileNavLink, { [styles.mobileNavLinkActive]: isActive }); + +const getMobileIconClass = ({ isActive }: NavLinkState) => + cn(styles.mobileIconLink, { [styles.mobileIconLinkActive]: isActive }); + +export const Header = () => { + const { favoritesCount } = useFavorites(); + const { getTotalQuantity } = useCart(); + const cartCount = getTotalQuantity(); + + const { pathname } = useLocation(); + const [searchParams, setSearchParams] = useSearchParams(); + + const showSearch = SEARCH_ROUTES.includes(pathname); + const placeholder = SEARCH_PLACEHOLDER[pathname] ?? 'Search...'; + + const urlQuery = searchParams.get('query') ?? ''; + const [inputValue, setInputValue] = useState(urlQuery); + const debouncedValue = useDebounce(inputValue, 300); + + const [isMenuOpen, setIsMenuOpen] = useState(false); + const toggleMenu = () => setIsMenuOpen(prev => !prev); + const closeMenu = () => setIsMenuOpen(false); + + useEffect(() => { + setInputValue(searchParams.get('query') ?? ''); + setIsMenuOpen(false); + }, [pathname, searchParams]); + + useEffect(() => { + if (!showSearch) { + return; + } + + setSearchParams( + prev => { + const next = new URLSearchParams(prev); + + if (debouncedValue) { + next.set('query', debouncedValue); + } else { + next.delete('query'); + } + + return next; + }, + { replace: true }, + ); + }, [debouncedValue, setSearchParams, showSearch]); + + const handleChange = (e: ChangeEvent) => { + setInputValue(e.target.value); + }; + + const handleClear = () => setInputValue(''); + + return ( +
+
+
+ + + Nice 👌 +
+ Gadgets +
+
+ + +
+ +
+ {showSearch && ( +
+ + + + + {inputValue && ( + + )} +
+ )} + + + {favoritesCount > 0 && ( + {favoritesCount} + )} + + + + + {cartCount > 0 && {cartCount}} + + +
+ + +
+ + {isMenuOpen && ( +
+ + +
+ + {favoritesCount > 0 && ( + {favoritesCount} + )} + + + + + {cartCount > 0 && ( + {cartCount} + )} + + +
+
+ )} +
+ ); +}; diff --git a/src/modules/shared/components/Header/index.ts b/src/modules/shared/components/Header/index.ts new file mode 100644 index 00000000000..29429dc97e8 --- /dev/null +++ b/src/modules/shared/components/Header/index.ts @@ -0,0 +1 @@ +export { Header } from './Header'; diff --git a/src/modules/shared/components/Loader/Loader.module.scss b/src/modules/shared/components/Loader/Loader.module.scss new file mode 100644 index 00000000000..a272d80f210 --- /dev/null +++ b/src/modules/shared/components/Loader/Loader.module.scss @@ -0,0 +1,22 @@ +.wrapper { + display: flex; + justify-content: center; + align-items: center; + padding: 80px 0; +} + +.spinner { + display: block; + width: 48px; + height: 48px; + border: 4px solid var(--color-border); + border-top-color: var(--color-primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/src/modules/shared/components/Loader/Loader.tsx b/src/modules/shared/components/Loader/Loader.tsx new file mode 100644 index 00000000000..a4d25c5cbcb --- /dev/null +++ b/src/modules/shared/components/Loader/Loader.tsx @@ -0,0 +1,7 @@ +import styles from './Loader.module.scss'; + +export const Loader = () => ( +
+ +
+); diff --git a/src/modules/shared/components/Loader/index.ts b/src/modules/shared/components/Loader/index.ts new file mode 100644 index 00000000000..d7027885251 --- /dev/null +++ b/src/modules/shared/components/Loader/index.ts @@ -0,0 +1 @@ +export { Loader } from './Loader'; diff --git a/src/modules/shared/components/PicturesSlider/PicturesSlider.module.scss b/src/modules/shared/components/PicturesSlider/PicturesSlider.module.scss new file mode 100644 index 00000000000..954b35a2c7e --- /dev/null +++ b/src/modules/shared/components/PicturesSlider/PicturesSlider.module.scss @@ -0,0 +1,109 @@ +.slider { + width: 100%; +} + +.content { + aspect-ratio: 1 / 1; + + @media (min-width: 640px) { + display: grid; + grid-template-columns: 32px minmax(0, 1fr) 32px; + height: 400px; + aspect-ratio: auto; + } +} + +.viewport { + overflow: hidden; + height: 100%; + background: #000; +} + +.track { + display: flex; + width: 100%; + height: 100%; + transition: transform 0.5s ease-in-out; +} + +.slide { + flex: 0 0 100%; + max-width: 100%; + height: 100%; + overflow: hidden; +} + +.picture { + display: block; + width: 100%; + height: 100%; + overflow: hidden; +} + +.image { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + + @media (min-width: 640px) { + object-fit: cover; + object-position: center; + } +} + +.prevBtn { + grid-column: 1; +} + +.nextBtn { + grid-column: 3; +} + +.prevBtn, +.nextBtn { + display: none; + + @media (min-width: 640px) { + align-self: stretch; + width: 32px; + height: 100%; + border: 1px solid var(--color-border); + background: #323542; + color: var(--color-primary); + display: flex; + align-items: center; + justify-content: center; + transition: + border-color var(--transition-base), + background-color var(--transition-base); + + &:hover { + border-color: var(--color-primary); + background: #4a4d58; + } + } +} + +.dots { + display: flex; + justify-content: center; + gap: 8px; + margin-top: 18px; +} + +.dot { + width: 14px; + height: 4px; + border-radius: 2px; + background: var(--color-secondary); + transition: background-color var(--transition-base); + + &:hover { + background: var(--color-primary); + } +} + +.dotActive { + background: var(--color-primary); +} diff --git a/src/modules/shared/components/PicturesSlider/PicturesSlider.tsx b/src/modules/shared/components/PicturesSlider/PicturesSlider.tsx new file mode 100644 index 00000000000..dea01bb3dab --- /dev/null +++ b/src/modules/shared/components/PicturesSlider/PicturesSlider.tsx @@ -0,0 +1,127 @@ +import { useEffect, useState } from 'react'; + +import styles from './PicturesSlider.module.scss'; + +const BASE = import.meta.env.BASE_URL; + +const SLIDES = [ + { + src: `${BASE}img/banners/Banner.png`, + mobileSrc: `${BASE}img/banners/Banner-mobile.png`, + alt: 'iPhone 14 Pro banner', + }, + { src: `${BASE}img/banners/slide-2.png`, alt: 'New tablets' }, + { src: `${BASE}img/banners/slide-3.png`, alt: 'New accessories' }, +]; + +const PREV_PATH = 'M11 2L5 8l6 6'; +const NEXT_PATH = 'M5 2l6 6-6 6'; + +export const PicturesSlider = () => { + const [index, setIndex] = useState(0); + const count = SLIDES.length; + + useEffect(() => { + const timer = setInterval(() => { + setIndex(i => (i + 1) % count); + }, 5000); + + return () => clearInterval(timer); + }, [count]); + + const prev = () => setIndex(i => (i - 1 + count) % count); + const next = () => setIndex(i => (i + 1) % count); + + return ( +
+
+ + +
+
+ {SLIDES.map(slide => ( +
+ + {slide.mobileSrc && ( + + )} + {slide.alt} + +
+ ))} +
+
+ + +
+ +
+ {SLIDES.map((slide, i) => ( +
+
+ ); +}; diff --git a/src/modules/shared/components/PicturesSlider/index.ts b/src/modules/shared/components/PicturesSlider/index.ts new file mode 100644 index 00000000000..3d16f0bbb89 --- /dev/null +++ b/src/modules/shared/components/PicturesSlider/index.ts @@ -0,0 +1 @@ +export { PicturesSlider } from './PicturesSlider'; 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..14b8099e83e --- /dev/null +++ b/src/modules/shared/components/ProductCard/ProductCard.module.scss @@ -0,0 +1,162 @@ +.card { + display: flex; + flex-direction: column; + height: 100%; + background-color: var(--color-card); + border: 1px solid var(--color-border); + border-radius: 0; + padding: 24px; + transition: + border-color var(--transition-base), + box-shadow var(--transition-base); + + &:hover { + border-color: var(--color-primary); + box-shadow: 4px 4px 16px rgba(0, 0, 0, 0.08); + } +} + +.imageLink { + display: flex; + align-items: center; + justify-content: center; + height: 196px; + margin-bottom: 16px; +} + +.image { + max-height: 100%; + object-fit: contain; + transition: transform 0.3s ease-in-out; + + .card:hover & { + transform: scale(1.1); + } +} + +.name { + font-size: 14px; + font-weight: 600; + line-height: 1.5; + color: var(--color-primary); + margin-bottom: 12px; + display: -webkit-box; + overflow: hidden; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + + &:hover { + color: var(--color-secondary); + } +} + +.prices { + display: flex; + align-items: baseline; + gap: 8px; + margin-bottom: 8px; +} + +.price { + font-size: 22px; + font-weight: 800; + color: var(--color-primary); +} + +.fullPrice { + font-size: 14px; + font-weight: 600; + color: var(--color-secondary); + text-decoration: line-through; +} + +.divider { + border: none; + border-top: 1px solid var(--color-border); + margin-bottom: 8px; +} + +.specs { + flex: 1; + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 12px; +} + +.specRow { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; +} + +.specLabel { + color: var(--color-secondary); +} + +.specValue { + font-weight: 700; + color: var(--color-primary); +} + +.actions { + display: flex; + gap: 8px; + align-items: center; +} + +.cartButton { + flex: 1; + height: 40px; + background-color: var(--color-accent); + color: var(--color-white); + font-size: 14px; + font-weight: 600; + letter-spacing: 0.4px; + border-radius: 0; + border: 1px solid transparent; + transition: + background-color var(--transition-base), + color var(--transition-base), + border-color var(--transition-base); + + &:hover { + background-color: var(--color-accent-hover); + border-color: var(--color-accent-hover); + color: var(--color-white); + } +} + +.cartButtonAdded { + background-color: var(--color-surface); + color: var(--color-primary); + border-color: var(--color-primary); + cursor: default; +} + +.favoriteButton { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 40px; + height: 40px; + border: 1px solid var(--color-border); + border-radius: 0; + color: var(--color-primary); + transition: border-color var(--transition-base); + + &:hover { + border-color: var(--color-primary); + } +} + +.favoriteButtonActive { + color: var(--color-red); + border-color: var(--color-red); + + svg { + fill: currentColor; + } +} diff --git a/src/modules/shared/components/ProductCard/ProductCard.tsx b/src/modules/shared/components/ProductCard/ProductCard.tsx new file mode 100644 index 00000000000..397f98f5a6c --- /dev/null +++ b/src/modules/shared/components/ProductCard/ProductCard.tsx @@ -0,0 +1,119 @@ +import { Link } from 'react-router-dom'; + +import type { Product } from '../../types/product'; +import { useCart, useFavorites } from '../../context'; +import styles from './ProductCard.module.scss'; + +const HEART_PATH = + 'M8 13c-.24 0-.47-.09-.65-.25C5.48 11.13 2 8.09 2 5.25 ' + + '2 3.46 3.4 2 5.12 2 6.16 2 7.13 2.53 7.7 3.39L8 3.84l.3-.45' + + 'C8.87 2.53 9.84 2 10.88 2 12.6 2 14 3.46 14 5.25c0 2.84-3.48 ' + + '5.88-5.35 7.5-.18.16-.41.25-.65.25z'; + +type Props = { + product: Product; + showFullPrice?: boolean; + onOpen?: () => void; +}; + +type SpecRowProps = { + label: string; + value: string; +}; + +const SpecRow = ({ label, value }: SpecRowProps) => ( +
+ {label} + {value} +
+); + +export const ProductCard = ({ + product, + showFullPrice = true, + onOpen, +}: Props) => { + const { itemId, name, image, price, fullPrice, screen, capacity, ram } = + product; + + const { isFavorite, toggle } = useFavorites(); + const { isInCart, add } = useCart(); + const hasDiscount = showFullPrice && fullPrice > price; + + return ( +
+ + {name} + + + + {name} + + +
+ ${price} + {hasDiscount && ${fullPrice}} +
+ +
+ +
+ + + +
+ +
+ + + +
+
+ ); +}; diff --git a/src/modules/shared/components/ProductCard/index.ts b/src/modules/shared/components/ProductCard/index.ts new file mode 100644 index 00000000000..c4f2778191c --- /dev/null +++ b/src/modules/shared/components/ProductCard/index.ts @@ -0,0 +1 @@ +export { ProductCard } from './ProductCard'; diff --git a/src/modules/shared/components/ProductsList/ProductsList.module.scss b/src/modules/shared/components/ProductsList/ProductsList.module.scss new file mode 100644 index 00000000000..d7ced3469f1 --- /dev/null +++ b/src/modules/shared/components/ProductsList/ProductsList.module.scss @@ -0,0 +1,17 @@ +.list { + display: grid; + gap: 16px; + grid-template-columns: 1fr; + + @media (min-width: 640px) { + grid-template-columns: repeat(2, 1fr); + } + + @media (min-width: 1200px) { + grid-template-columns: repeat(4, 1fr); + } +} + +.list > li { + display: flex; +} diff --git a/src/modules/shared/components/ProductsList/ProductsList.tsx b/src/modules/shared/components/ProductsList/ProductsList.tsx new file mode 100644 index 00000000000..add777cfc95 --- /dev/null +++ b/src/modules/shared/components/ProductsList/ProductsList.tsx @@ -0,0 +1,17 @@ +import type { Product } from '../../types/product'; +import { ProductCard } from '../ProductCard'; +import styles from './ProductsList.module.scss'; + +type Props = { + products: Product[]; +}; + +export const ProductsList = ({ products }: Props) => ( +
    + {products.map(product => ( +
  • + +
  • + ))} +
+); diff --git a/src/modules/shared/components/ProductsList/index.ts b/src/modules/shared/components/ProductsList/index.ts new file mode 100644 index 00000000000..ae9d590cdbd --- /dev/null +++ b/src/modules/shared/components/ProductsList/index.ts @@ -0,0 +1 @@ +export { ProductsList } from './ProductsList'; 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..846cd442d44 --- /dev/null +++ b/src/modules/shared/components/ProductsSlider/ProductsSlider.module.scss @@ -0,0 +1,86 @@ +.wrapper { + width: 100%; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 24px; +} + +.title { + font-size: 22px; + font-weight: 800; + line-height: 1.4; + color: var(--color-primary); + + @media (max-width: 639px) { + max-width: 136px; + line-height: 1.35; + } +} + +.controls { + display: flex; + gap: 8px; + flex-shrink: 0; +} + +.ctrlBtn { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--color-border); + border-radius: 0; + background: var(--color-surface); + color: var(--color-primary); + cursor: pointer; + transition: + border-color var(--transition-base), + background-color var(--transition-base); + + &:hover { + border-color: var(--color-primary); + background: rgba(255, 255, 255, 0.04); + } +} + +.ctrlBtnDisabled { + color: var(--color-icons); + background: transparent; + border-color: var(--color-border); + cursor: default; + pointer-events: none; +} + +.track { + --desktop-track-shift: 0px; + + display: flex; + align-items: stretch; + gap: 16px; + overflow-x: auto; + scrollbar-width: none; + + @media (min-width: 1200px) { + margin-left: var(--desktop-track-shift); + } + + &::-webkit-scrollbar { + display: none; + } +} + +.item { + display: flex; + flex-shrink: 0; + width: 272px; + + @media (min-width: 1200px) { + width: calc((100% - 48px) / 4); + } +} diff --git a/src/modules/shared/components/ProductsSlider/ProductsSlider.tsx b/src/modules/shared/components/ProductsSlider/ProductsSlider.tsx new file mode 100644 index 00000000000..1a2205df3da --- /dev/null +++ b/src/modules/shared/components/ProductsSlider/ProductsSlider.tsx @@ -0,0 +1,166 @@ +import { useEffect, useRef, useState } from 'react'; + +import type { Product } from '../../types/product'; +import { ProductCard } from '../ProductCard'; +import styles from './ProductsSlider.module.scss'; + +const PREV_PATH = 'M11 2L5 8l6 6'; +const NEXT_PATH = 'M5 2l6 6-6 6'; + +const ITEM_GAP = 16; + +type Props = { + title: string; + titleId: string; + products: Product[]; + showFullPrice?: boolean; + onProductSelect?: () => void; + shiftTrackDesktopPx?: number; +}; + +export const ProductsSlider = ({ + title, + titleId, + products, + showFullPrice = true, + onProductSelect, + shiftTrackDesktopPx = 0, +}: Props) => { + const trackRef = useRef(null); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(true); + + const updateControls = () => { + const track = trackRef.current; + + if (!track) { + return; + } + + const maxScrollLeft = track.scrollWidth - track.clientWidth; + const tolerance = 1; + + setCanScrollLeft(track.scrollLeft > tolerance); + setCanScrollRight(track.scrollLeft < maxScrollLeft - tolerance); + }; + + useEffect(() => { + updateControls(); + + const track = trackRef.current; + + if (!track) { + return; + } + + track.addEventListener('scroll', updateControls); + window.addEventListener('resize', updateControls); + + return () => { + track.removeEventListener('scroll', updateControls); + window.removeEventListener('resize', updateControls); + }; + }, [products.length]); + + const scroll = (dir: 1 | -1) => { + const firstItem = trackRef.current + ?.firstElementChild as HTMLLIElement | null; + const itemWidth = firstItem?.getBoundingClientRect().width ?? 272; + const scrollStep = itemWidth + ITEM_GAP; + + trackRef.current?.scrollBy({ + left: dir * scrollStep, + behavior: 'smooth', + }); + + setTimeout(updateControls, 350); + }; + + return ( +
+
+

+ {title} +

+ +
+ + + +
+
+ +
    0 + ? { '--desktop-track-shift': `${shiftTrackDesktopPx}px` } + : undefined + } + > + {products.map(product => ( +
  • + +
  • + ))} +
+
+ ); +}; diff --git a/src/modules/shared/components/ProductsSlider/index.ts b/src/modules/shared/components/ProductsSlider/index.ts new file mode 100644 index 00000000000..0a5bb986628 --- /dev/null +++ b/src/modules/shared/components/ProductsSlider/index.ts @@ -0,0 +1 @@ +export { ProductsSlider } from './ProductsSlider'; diff --git a/src/modules/shared/components/ShopByCategory/ShopByCategory.module.scss b/src/modules/shared/components/ShopByCategory/ShopByCategory.module.scss new file mode 100644 index 00000000000..ac50e3655a3 --- /dev/null +++ b/src/modules/shared/components/ShopByCategory/ShopByCategory.module.scss @@ -0,0 +1,48 @@ +.grid { + display: grid; + grid-template-columns: 1fr; + gap: 16px; + + @media (min-width: 640px) { + grid-template-columns: repeat(3, 1fr); + } +} + +.card { + display: block; +} + +.imageWrapper { + overflow: hidden; + border-radius: 0; + background: #6d6474; + margin-bottom: 16px; + aspect-ratio: 1 / 1; +} + +li:nth-child(2) .imageWrapper { + background: #8d8d92; +} + +li:nth-child(3) .imageWrapper { + background: #973d5f; +} + +.image { + width: 100%; + height: 100%; + display: block; + object-fit: contain; +} + +.title { + font-size: 22px; + font-weight: 800; + color: var(--color-primary); + margin-bottom: 4px; +} + +.count { + font-size: 14px; + color: var(--color-secondary); +} diff --git a/src/modules/shared/components/ShopByCategory/ShopByCategory.tsx b/src/modules/shared/components/ShopByCategory/ShopByCategory.tsx new file mode 100644 index 00000000000..8632ba5926e --- /dev/null +++ b/src/modules/shared/components/ShopByCategory/ShopByCategory.tsx @@ -0,0 +1,46 @@ +import { Link } from 'react-router-dom'; + +import styles from './ShopByCategory.module.scss'; + +const BASE = import.meta.env.BASE_URL; + +const CATEGORIES = [ + { + title: 'Mobile phones', + image: `${BASE}img/category-phones.webp`, + to: '/phones', + countKey: 'phones', + }, + { + title: 'Tablets', + image: `${BASE}img/category-tablets.webp`, + to: '/tablets', + countKey: 'tablets', + }, + { + title: 'Accessories', + image: `${BASE}img/category-accessories.webp`, + to: '/accessories', + countKey: 'accessories', + }, +]; + +type Props = { + counts: Record; +}; + +export const ShopByCategory = ({ counts }: Props) => ( +
    + {CATEGORIES.map(({ title, image, to, countKey }) => ( +
  • + +
    + {title} +
    +

    {title}

    +

    {counts[countKey]} models

    + +
  • + ))} +
+); diff --git a/src/modules/shared/components/ShopByCategory/index.ts b/src/modules/shared/components/ShopByCategory/index.ts new file mode 100644 index 00000000000..767e814b1f2 --- /dev/null +++ b/src/modules/shared/components/ShopByCategory/index.ts @@ -0,0 +1 @@ +export { ShopByCategory } from './ShopByCategory'; diff --git a/src/modules/shared/config/imageOverrides.ts b/src/modules/shared/config/imageOverrides.ts new file mode 100644 index 00000000000..19a37890153 --- /dev/null +++ b/src/modules/shared/config/imageOverrides.ts @@ -0,0 +1,3 @@ +export const productImageOverrides: Record = {}; + +export const detailsImagesOverrides: Record = {}; diff --git a/src/modules/shared/context/CartContext.tsx b/src/modules/shared/context/CartContext.tsx new file mode 100644 index 00000000000..e2faa60a9df --- /dev/null +++ b/src/modules/shared/context/CartContext.tsx @@ -0,0 +1,152 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useReducer, + type ReactNode, +} from 'react'; + +const STORAGE_KEY = 'cart'; + +export type CartItem = { + id: string; + quantity: number; +}; + +type Action = + | { type: 'ADD'; id: string } + | { type: 'REMOVE'; id: string } + | { type: 'INCREMENT'; id: string } + | { type: 'DECREMENT'; id: string } + | { type: 'CLEAR' }; + +type CartContextValue = { + cart: CartItem[]; + isInCart: (id: string) => boolean; + getTotalQuantity: () => number; + add: (id: string) => void; + remove: (id: string) => void; + increment: (id: string) => void; + decrement: (id: string) => void; + clear: () => void; +}; + +const CartContext = createContext(null); + +const loadFromStorage = (): CartItem[] => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + + return raw ? (JSON.parse(raw) as CartItem[]) : []; + } catch { + return []; + } +}; + +const reducer = (state: CartItem[], action: Action): CartItem[] => { + switch (action.type) { + case 'ADD': + if (state.some(item => item.id === action.id)) { + return state; + } + + return [...state, { id: action.id, quantity: 1 }]; + + case 'REMOVE': + return state.filter(item => item.id !== action.id); + + case 'INCREMENT': + return state.map(item => + item.id === action.id ? { ...item, quantity: item.quantity + 1 } : item, + ); + + case 'DECREMENT': + return state.map(item => + item.id === action.id + ? { ...item, quantity: Math.max(1, item.quantity - 1) } + : item, + ); + + case 'CLEAR': + return []; + + default: + return state; + } +}; + +type Props = { children: ReactNode }; + +export const CartProvider = ({ children }: Props) => { + const [cart, dispatch] = useReducer(reducer, loadFromStorage()); + + useEffect(() => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(cart)); + }, [cart]); + + const isInCart = useCallback( + (id: string) => cart.some(item => item.id === id), + [cart], + ); + + const getTotalQuantity = useCallback( + () => cart.reduce((sum, item) => sum + item.quantity, 0), + [cart], + ); + + const add = useCallback((id: string) => dispatch({ type: 'ADD', id }), []); + + const remove = useCallback( + (id: string) => dispatch({ type: 'REMOVE', id }), + [], + ); + + const increment = useCallback( + (id: string) => dispatch({ type: 'INCREMENT', id }), + [], + ); + + const decrement = useCallback( + (id: string) => dispatch({ type: 'DECREMENT', id }), + [], + ); + + const clear = useCallback(() => dispatch({ type: 'CLEAR' }), []); + + const value = useMemo( + () => ({ + cart, + isInCart, + getTotalQuantity, + add, + remove, + increment, + decrement, + clear, + }), + [ + cart, + isInCart, + getTotalQuantity, + add, + remove, + increment, + decrement, + clear, + ], + ); + + return {children}; +}; + +export const useCart = (): CartContextValue => { + const ctx = useContext(CartContext); + + if (!ctx) { + throw new Error('useCart must be used within CartProvider'); + } + + return ctx; +}; diff --git a/src/modules/shared/context/FavoritesContext.tsx b/src/modules/shared/context/FavoritesContext.tsx new file mode 100644 index 00000000000..5fa9b1f4b4a --- /dev/null +++ b/src/modules/shared/context/FavoritesContext.tsx @@ -0,0 +1,107 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useReducer, + type ReactNode, +} from 'react'; + +const STORAGE_KEY = 'favorites'; + +type Action = + | { type: 'ADD'; id: string } + | { type: 'REMOVE'; id: string } + | { type: 'TOGGLE'; id: string }; + +type FavoritesContextValue = { + favorites: string[]; + favoritesCount: number; + isFavorite: (id: string) => boolean; + toggle: (id: string) => void; + add: (id: string) => void; + remove: (id: string) => void; +}; + +const FavoritesContext = createContext(null); + +const loadFromStorage = (): string[] => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + + return raw ? (JSON.parse(raw) as string[]) : []; + } catch { + return []; + } +}; + +const reducer = (state: string[], action: Action): string[] => { + switch (action.type) { + case 'ADD': + return [...state, action.id]; + case 'REMOVE': + return state.filter(id => id !== action.id); + case 'TOGGLE': + return state.includes(action.id) + ? state.filter(id => id !== action.id) + : [...state, action.id]; + default: + return state; + } +}; + +type Props = { children: ReactNode }; + +export const FavoritesProvider = ({ children }: Props) => { + const [favorites, dispatch] = useReducer(reducer, loadFromStorage()); + + useEffect(() => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites)); + }, [favorites]); + + const isFavorite = useCallback( + (id: string) => favorites.includes(id), + [favorites], + ); + + const toggle = useCallback( + (id: string) => dispatch({ type: 'TOGGLE', id }), + [], + ); + + const add = useCallback((id: string) => dispatch({ type: 'ADD', id }), []); + + const remove = useCallback( + (id: string) => dispatch({ type: 'REMOVE', id }), + [], + ); + + const value = useMemo( + () => ({ + favorites, + favoritesCount: favorites.length, + isFavorite, + toggle, + add, + remove, + }), + [favorites, isFavorite, toggle, add, remove], + ); + + return ( + + {children} + + ); +}; + +export const useFavorites = (): FavoritesContextValue => { + const ctx = useContext(FavoritesContext); + + if (!ctx) { + throw new Error('useFavorites must be used within FavoritesProvider'); + } + + return ctx; +}; diff --git a/src/modules/shared/context/index.ts b/src/modules/shared/context/index.ts new file mode 100644 index 00000000000..2078e7481c0 --- /dev/null +++ b/src/modules/shared/context/index.ts @@ -0,0 +1,3 @@ +export { FavoritesProvider, useFavorites } from './FavoritesContext'; +export { CartProvider, useCart } from './CartContext'; +export type { CartItem } from './CartContext'; diff --git a/src/modules/shared/hooks/useAsync.ts b/src/modules/shared/hooks/useAsync.ts new file mode 100644 index 00000000000..c86c759087a --- /dev/null +++ b/src/modules/shared/hooks/useAsync.ts @@ -0,0 +1,50 @@ +import { useCallback, useEffect, useState } from 'react'; + +type AsyncState = { + data: T | null; + loading: boolean; + error: string | null; +}; + +type UseAsyncResult = AsyncState & { + reload: () => void; +}; + +export const useAsync = (asyncFn: () => Promise): UseAsyncResult => { + const [state, setState] = useState>({ + data: null, + loading: true, + error: null, + }); + + const [reloadKey, setReloadKey] = useState(0); + + const reload = useCallback(() => setReloadKey(k => k + 1), []); + + useEffect(() => { + let cancelled = false; + + setState(s => ({ ...s, loading: true, error: null })); + + asyncFn() + .then(data => { + if (!cancelled) { + setState({ data, loading: false, error: null }); + } + }) + .catch((err: unknown) => { + if (!cancelled) { + const message = + err instanceof Error ? err.message : 'Something went wrong'; + + setState({ data: null, loading: false, error: message }); + } + }); + + return () => { + cancelled = true; + }; + }, [asyncFn, reloadKey]); + + return { ...state, reload }; +}; diff --git a/src/modules/shared/hooks/useDebounce.ts b/src/modules/shared/hooks/useDebounce.ts new file mode 100644 index 00000000000..d50bd1d6783 --- /dev/null +++ b/src/modules/shared/hooks/useDebounce.ts @@ -0,0 +1,13 @@ +import { useEffect, useState } from 'react'; + +export const useDebounce = (value: T, delay: number): T => { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delay); + + return () => clearTimeout(timer); + }, [value, delay]); + + return debounced; +}; diff --git a/src/modules/shared/types/product.ts b/src/modules/shared/types/product.ts new file mode 100644 index 00000000000..d136c4ea7b1 --- /dev/null +++ b/src/modules/shared/types/product.ts @@ -0,0 +1,16 @@ +export type ProductCategory = 'phones' | 'tablets' | 'accessories'; + +export type Product = { + id: number; + category: ProductCategory; + itemId: string; + name: string; + fullPrice: number; + price: number; + screen: string; + capacity: string; + color: string; + ram: string; + year: number; + image: string; +}; diff --git a/src/modules/shared/types/productDetails.ts b/src/modules/shared/types/productDetails.ts new file mode 100644 index 00000000000..5283dcefcc8 --- /dev/null +++ b/src/modules/shared/types/productDetails.ts @@ -0,0 +1,28 @@ +import type { ProductCategory } from './product'; + +export type ProductDescription = { + title: string; + text: string[]; +}; + +export type ProductDetails = { + id: string; + category: ProductCategory; + namespaceId: string; + name: string; + capacityAvailable: string[]; + capacity: string; + priceRegular: number; + priceDiscount: number; + colorsAvailable: string[]; + color: string; + images: string[]; + description: ProductDescription[]; + screen: string; + resolution: string; + processor: string; + ram: string; + camera?: string; + zoom?: string; + cell: string[]; +}; diff --git a/vite.config.ts b/vite.config.ts index 5a33944a9b4..31b4bc5c5d9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,4 +4,5 @@ import react from '@vitejs/plugin-react' // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], + base: '/react_phone-catalog/', })