diff --git a/README.md b/README.md index 3e1213ef5f7..db846063b90 100644 --- a/README.md +++ b/README.md @@ -1,143 +1,574 @@ -# React Product Catalog - -Implement the catalog with a shopping cart and favorites page according to one of the next designs: - -- [Original](https://www.figma.com/file/T5ttF21UnT6RRmCQQaZc6L/Phone-catalog-(V2)-Original) -- [Original Dark](https://www.figma.com/file/BUusqCIMAWALqfBahnyIiH/Phone-catalog-(V2)-Original-Dark) -- [Rounded Blue](https://www.figma.com/file/FRxncC4lfyhs6og1L6FGEU/Phone-catalog-(V2)-Rounded-Style-2?node-id=0%3A1) -- [Rounded Purple](https://www.figma.com/file/xMK2Dy0mfBbJJSNctmOuLW/Phone-catalog-(V2)-Rounded-Style-1?node-id=0%3A1) -- [Rounded Orange](https://www.figma.com/file/7JTa0q8n3dTSAyMNaA0u8o/Phone-catalog-(V2)-Rounded-Style-3?node-id=0%3A1) - -You may also implement color theme switching! - -## If you work in a team - -Follow the [Work in a team guideline](https://github.com/mate-academy/react_task-guideline/blob/master/team-flow.md#how-to-work-in-a-team) - -## Project Setup from scratch - -Follow the [Instruction](https://github.com/mate-academy/react_phone-catalog/blob/master/setup.md) to setup your project, add Eslint, Prettier, Husky and enable auto deploy. - -## Data - -Use the data from `/public/api` and images from `/public/img` folders. You can reorganize them the way you like. - -## App - -1. Put components into the `src/components` folder. - - Each component should be a folder with `index.ts`, `ComponentName.tsx`, `ComponentName.module.scss` files. - - Use CSS modules. - - Keep `.module.scss` files together with their components. -2. Advanced project structure: - - `src/modules` folder. Inside per page modules `HomePage`, `CartPage`, etc., and `shared` folder with shared content between modules. - - Inside each module its own `components` folder with the structure described above. And optionally other files/folders: `hooks`, `constants`, and so on. -3. Add the sticky header with a logo, navigation, favorites, and cart. -4. The footer with the link to the GitHub repo and `Back to top` button. - - The content should be limited to the same width as the page content; - - `Back to top` button should scroll to the top smoothly; -5. Add `NotFoundPage` containing text `Page not found` for all the unknown URLs. -6. All changes the hover effects should be smooth. -7. Scale all image links by 10% on hover. -8. Implement all form elements and icons according to the UI Kit. - -## Home page - -Implement Home page at available at `/`. - -1. `

Product Catalog

` should be visually hidden. -2. `PicturesSlider`: - - Find your own images to personalize the App; - - Change pictures automatically every 5 seconds; - - The next buttons should show the first image after the last one; - - Dashes at the bottom should allow choosing an exact picture. -3. `ProductsSlider` for the `Hot prices` block: - - The products with a discount starting from the biggest absolute value; - - `<` and `>` buttons should scroll products. -4. `Shop by category` block with links to `/phones`, `/tablets`, and `/accessories`. -5. Add Brand new block using ProductsSlider with products that are the newest according to the year field. - -## Product pages - -There should be 3 separate pages `/phones`, `/tablets`, and `/accessories`. - -1. Each page loads the data of the required `type`. -2. Add an `h1` with `Phones/Tablets/Accessories page` (choose required). -3. Add `ProductsList` component showing all the `products`. -4. Implement a `Loader` to show it while waiting for the data from the server. -5. In case of a loading error show the something went wrong message with a reload button. -6. If there are no products available show the `There are no phones/tablets/accessories yet` message (choose required). -7. Add a ` onChange(e.target.value)} + > + {options.map(option => ( + + ))} + + +); diff --git a/src/components/Dropdown/index.ts b/src/components/Dropdown/index.ts new file mode 100644 index 00000000000..c0ad316fc26 --- /dev/null +++ b/src/components/Dropdown/index.ts @@ -0,0 +1 @@ +export { Dropdown } from './Dropdown'; diff --git a/src/components/Footer/Footer.module.scss b/src/components/Footer/Footer.module.scss new file mode 100644 index 00000000000..7c43e2a23de --- /dev/null +++ b/src/components/Footer/Footer.module.scss @@ -0,0 +1,152 @@ +@use '../../styles/variables' as *; + +.footer { + border-top: 1px solid $color-elements; + margin-top: auto; + + &__content { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 32px; + padding: 32px 16px; + max-width: $content-max-width; + margin: 0 auto; + + @include on-tablet { + flex-direction: row; + align-items: center; + justify-content: space-between; + padding: 32px 24px; + } + + @include on-desktop { + padding: 32px 152px; + } + } + + // Logo section — flex: 1 on desktop + &__logoWrapper { + flex-shrink: 0; + + @include on-tablet { + flex: 1; + } + } + + &__logo { + display: inline-flex; + } + + &__logoContainer { + position: relative; + width: 89px; + height: 32px; + overflow: hidden; + } + + &__logoText { + display: block; + width: 100%; + height: 100%; + } + + &__logoFlame { + position: absolute; + top: 0; + left: 50%; + width: 14%; + height: 52%; + object-fit: contain; + } + + // Navigation links — flex: 1 on desktop, distributed + &__nav { + width: 100%; + + @include on-tablet { + flex: 1; + } + } + + &__links { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 16px; + + @include on-tablet { + flex-direction: row; + align-items: center; + justify-content: space-between; + } + } + + &__link { + font-size: 12px; + font-weight: 700; + line-height: 11px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: $color-secondary; + transition: color 0.3s; + + &:hover { + color: $color-primary; + } + } + + // Back to top — flex: 1 on desktop, right-aligned + &__backToTop { + width: 100%; + display: flex; + justify-content: center; + + @include on-tablet { + flex: 1; + justify-content: flex-end; + } + } + + &__backToTopBtn { + display: flex; + align-items: center; + gap: 16px; + cursor: pointer; + background: none; + border: none; + padding: 0; + } + + &__backToTopText { + font-size: 12px; + font-weight: 600; + line-height: 1; + color: $color-secondary; + white-space: nowrap; + transition: color 0.3s; + } + + &__backToTopBtn:hover &__backToTopText { + color: $color-primary; + } + + &__backToTopIcon { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid $color-icons; + transition: border-color 0.3s; + + img { + width: 16px; + height: 16px; + } + } + + &__backToTopBtn:hover &__backToTopIcon { + border-color: $color-primary; + } +} diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 00000000000..f39171592ac --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,84 @@ +import { Link } from 'react-router-dom'; +import styles from './Footer.module.scss'; + +export const Footer = () => { + const scrollToTop = () => { + window.scrollTo({ + top: 0, + behavior: 'smooth', + }); + }; + + return ( + + ); +}; diff --git a/src/components/Footer/index.ts b/src/components/Footer/index.ts new file mode 100644 index 00000000000..65e2506faf5 --- /dev/null +++ b/src/components/Footer/index.ts @@ -0,0 +1 @@ +export { Footer } from './Footer'; diff --git a/src/components/Header/Header.module.scss b/src/components/Header/Header.module.scss new file mode 100644 index 00000000000..79e275709c9 --- /dev/null +++ b/src/components/Header/Header.module.scss @@ -0,0 +1,440 @@ +@use '../../styles/variables' as *; + +.header { + position: sticky; + top: 0; + z-index: 100; + display: flex; + align-items: center; + justify-content: space-between; + background-color: $color-white; + border-bottom: 1px solid $color-elements; + height: 48px; + + @include on-tablet { + height: 64px; + } + + &__left { + display: flex; + align-items: center; + height: 100%; + } + + &__logo { + display: flex; + align-items: center; + padding: 0 16px; + height: 100%; + + @include on-tablet { + padding: 0 24px; + } + } + + &__logoContainer { + position: relative; + width: 64px; + height: 22px; + overflow: hidden; + + @include on-tablet { + width: 80px; + height: 28px; + } + } + + &__logoText { + display: block; + width: 100%; + height: 100%; + } + + &__logoFlame { + position: absolute; + top: 0; + left: 50%; + width: 14%; + height: 52%; + object-fit: contain; + } + + &__right { + display: flex; + align-items: center; + height: 100%; + } + + // Search + &__searchBtn { + display: flex; + align-items: center; + justify-content: center; + padding: 0; + background: none; + border: none; + cursor: pointer; + } + + // Theme Toggle + &__themeToggle { + display: none; + align-items: center; + justify-content: center; + height: 100%; + padding: 0 8px; + border-left: 1px solid $color-elements; + transition: border-color 0.3s; + + @include on-tablet { + display: flex; + padding: 0 12px; + } + } + + &__searchIcon { + width: 16px; + height: 16px; + opacity: 0.5; + transition: opacity 0.3s; + + &:hover { + opacity: 1; + } + } + + &__search { + display: none; + + @include on-tablet { + display: flex; + align-items: center; + gap: 8px; + height: 100%; + padding: 0 16px; + border-left: 1px solid $color-elements; + transition: border-color 0.3s; + } + + &:hover { + .header__searchIcon { + opacity: 1; + } + } + + &:focus-within { + border-left-color: $color-primary; + box-shadow: inset 0 -3px 0 $color-primary; + + .header__searchIcon { + opacity: 1; + } + } + } + + &__searchInput { + border: none; + outline: none; + font-family: $font-family; + font-size: 14px; + font-weight: 600; + line-height: 21px; + color: $color-primary; + background: transparent; + width: 160px; + transition: border-color 0.3s; + + @include on-desktop { + width: 200px; + } + + &::placeholder { + color: $color-secondary; + font-weight: 600; + transition: color 0.3s; + } + + &:focus::placeholder { + color: $color-icons; + } + } + + &__searchClear { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 0; + background: none; + border: none; + cursor: pointer; + opacity: 0.5; + transition: opacity 0.3s; + + &:hover { + opacity: 1; + } + + img { + width: 16px; + height: 16px; + } + } + + // Icon buttons (favorites, cart) — hidden on mobile per Figma + &__icon { + display: none; + align-items: center; + justify-content: center; + height: 100%; + width: 48px; + border-left: 1px solid $color-elements; + position: relative; + transition: box-shadow 0.3s; + + @include on-tablet { + display: flex; + width: 64px; + } + + &:hover { + box-shadow: inset 0 -3px 0 $color-primary; + } + + &--active { + box-shadow: inset 0 -3px 0 $color-primary; + } + + img { + width: 16px; + height: 16px; + } + } + + &__iconWrap { + position: relative; + display: inline-flex; + } + + &__badge { + position: absolute; + top: -6px; + right: -10px; + display: flex; + align-items: center; + justify-content: center; + min-width: 16px; + height: 16px; + padding: 0 3px; + font-size: 10px; + font-weight: 700; + line-height: 1; + text-align: center; + color: #fff; + background-color: $color-red; + border: 1px solid #fff; + border-radius: 50%; + box-sizing: border-box; + } + + // Hamburger + &__burger { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 4px; + width: 48px; + height: 100%; + padding: 0; + background: none; + border: none; + border-left: 1px solid $color-elements; + cursor: pointer; + + @include on-tablet { + display: none; + } + } + + &__burgerLine { + display: block; + width: 16px; + height: 2px; + background-color: $color-primary; + border-radius: 1px; + transition: transform 0.3s; + } +} + +// ================ +// Navigation +// ================ +.nav { + display: none; + + @include on-tablet { + display: flex; + align-items: center; + height: 100%; + } + + &__list { + display: flex; + gap: 0; + height: 100%; + } + + &__item { + display: flex; + align-items: center; + height: 100%; + } + + &__link { + display: flex; + align-items: center; + height: 100%; + padding: 0 16px; + font-size: 12px; + font-weight: 700; + line-height: 11px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: $color-secondary; + transition: + color 0.3s, + box-shadow 0.3s; + + &:hover { + color: $color-primary; + box-shadow: inset 0 -3px 0 $color-primary; + } + + &--active { + color: $color-primary; + box-shadow: inset 0 -3px 0 $color-primary; + } + } +} + +// ================ +// Mobile menu overlay +// ================ +.mobileMenu { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + flex-direction: column; + background-color: $color-white; + + @include on-tablet { + display: none; + } + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + height: 48px; + border-bottom: 1px solid $color-elements; + } + + &__close { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 100%; + padding: 0; + background: none; + border: none; + border-left: 1px solid $color-elements; + cursor: pointer; + + img { + width: 16px; + height: 16px; + } + } + + &__nav { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + padding-top: 24px; + flex: 1; + } + + &__link { + font-size: 12px; + font-weight: 700; + line-height: 11px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: $color-secondary; + padding: 8px 0; + border-bottom: 3px solid transparent; + transition: + color 0.3s, + border-color 0.3s; + + &:hover { + color: $color-primary; + } + + &--active { + color: $color-primary; + border-bottom-color: $color-primary; + } + } + + &__bottom { + display: flex; + height: 64px; + border-top: 1px solid $color-elements; + } + + &__themeToggle { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + border-right: 1px solid $color-elements; + } + + &__icon { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + position: relative; + border-right: 1px solid $color-elements; + transition: box-shadow 0.3s; + + &:last-child { + border-right: none; + } + + &:hover { + box-shadow: inset 0 -3px 0 $color-primary; + } + + &--active { + box-shadow: inset 0 -3px 0 $color-primary; + } + + img { + width: 16px; + height: 16px; + } + } + + &__iconWrap { + position: relative; + display: inline-flex; + } +} diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 00000000000..4ac1d3d6048 --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,299 @@ +import { useEffect, useState, useRef, useCallback } from 'react'; +import { NavLink, Link, useLocation, useSearchParams } from 'react-router-dom'; +import classNames from 'classnames'; +import { useCart } from '../../context/CartContext'; +import { useFavorites } from '../../context/FavoritesContext'; +import { ThemeToggle } from '../ThemeToggle'; +import styles from './Header.module.scss'; + +const DEBOUNCE_DELAY = 300; + +const getLinkClass = ({ isActive }: { isActive: boolean }) => + classNames(styles.nav__link, { + [styles['nav__link--active']]: isActive, + }); + +const getMobileLinkClass = ({ isActive }: { isActive: boolean }) => + classNames(styles.mobileMenu__link, { + [styles['mobileMenu__link--active']]: isActive, + }); + +export const Header = () => { + const location = useLocation(); + const { totalItems } = useCart(); + const { totalFavorites } = useFavorites(); + const [searchParams, setSearchParams] = useSearchParams(); + const [isMenuOpen, setIsMenuOpen] = useState(false); + + const searchPages = ['/phones', '/tablets', '/accessories', '/favorites']; + const showSearch = searchPages.includes(location.pathname); + + const currentQuery = searchParams.get('query') || ''; + const [inputValue, setInputValue] = useState(currentQuery); + const timerRef = useRef | null>(null); + const inputRef = useRef(null); + + const handleSearchIconClick = () => { + inputRef.current?.focus(); + }; + + useEffect(() => { + setInputValue(currentQuery); + }, [currentQuery]); + + useEffect(() => { + setIsMenuOpen(false); + }, [location.pathname]); + + const applySearch = useCallback( + (value: string) => { + const params = new URLSearchParams(searchParams); + + if (value.trim()) { + params.set('query', value.trim()); + } else { + params.delete('query'); + } + + params.set('page', '1'); + setSearchParams(params); + }, + [searchParams, setSearchParams], + ); + + const handleSearchChange = (e: React.ChangeEvent) => { + const { value } = e.target; + + setInputValue(value); + + if (timerRef.current) { + clearTimeout(timerRef.current); + } + + timerRef.current = setTimeout(() => { + applySearch(value); + }, DEBOUNCE_DELAY); + }; + + const handleClearSearch = () => { + setInputValue(''); + applySearch(''); + }; + + const toggleMenu = () => setIsMenuOpen(prev => !prev); + + const getIconClass = ({ isActive }: { isActive: boolean }) => + classNames(styles.header__icon, { + [styles['header__icon--active']]: isActive, + }); + + const getMobileIconClass = ({ isActive }: { isActive: boolean }) => + classNames(styles.mobileMenu__icon, { + [styles['mobileMenu__icon--active']]: isActive, + }); + + const pageName = location.pathname.slice(1) || 'products'; + + return ( + <> +
+
+ +
+ Nice Gadgets + +
+ + + +
+ +
+ {showSearch && ( +
+ + + {inputValue && ( + + )} +
+ )} + +
+ +
+ + + + Favorites + {totalFavorites > 0 && ( + {totalFavorites} + )} + + + + + + Cart + {totalItems > 0 && ( + {totalItems} + )} + + + + +
+
+ + {isMenuOpen && ( +
+
+ setIsMenuOpen(false)} + > +
+ Nice Gadgets + +
+ + + +
+ + + +
+
+ +
+ + setIsMenuOpen(false)} + > + + Favorites + {totalFavorites > 0 && ( + {totalFavorites} + )} + + + + setIsMenuOpen(false)} + > + + Cart + {totalItems > 0 && ( + {totalItems} + )} + + +
+
+ )} + + ); +}; diff --git a/src/components/Header/index.ts b/src/components/Header/index.ts new file mode 100644 index 00000000000..29429dc97e8 --- /dev/null +++ b/src/components/Header/index.ts @@ -0,0 +1 @@ +export { Header } from './Header'; diff --git a/src/components/Loader/Loader.module.scss b/src/components/Loader/Loader.module.scss new file mode 100644 index 00000000000..f46084c1bdd --- /dev/null +++ b/src/components/Loader/Loader.module.scss @@ -0,0 +1,23 @@ +@use '../../styles/variables' as *; + +.loader { + display: flex; + justify-content: center; + align-items: center; + padding: 40px 0; + + &__content { + width: 40px; + height: 40px; + border: 4px solid $color-elements; + border-top-color: $color-button; + border-radius: 50%; + animation: spin 0.8s linear infinite; + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/src/components/Loader/Loader.tsx b/src/components/Loader/Loader.tsx new file mode 100644 index 00000000000..38d7117151d --- /dev/null +++ b/src/components/Loader/Loader.tsx @@ -0,0 +1,7 @@ +import styles from './Loader.module.scss'; + +export const Loader = () => ( +
+
+
+); diff --git a/src/components/Loader/index.ts b/src/components/Loader/index.ts new file mode 100644 index 00000000000..d7027885251 --- /dev/null +++ b/src/components/Loader/index.ts @@ -0,0 +1 @@ +export { Loader } from './Loader'; diff --git a/src/components/Pagination/Pagination.module.scss b/src/components/Pagination/Pagination.module.scss new file mode 100644 index 00000000000..e3d7d02f645 --- /dev/null +++ b/src/components/Pagination/Pagination.module.scss @@ -0,0 +1,74 @@ +@use '../../styles/variables' as *; + +.pagination { + display: flex; + justify-content: center; + gap: 8px; + list-style: none; + margin-top: 24px; + + @include on-tablet { + margin-top: 40px; + } + + &__page { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + font-family: $font-family; + font-size: 14px; + font-weight: 600; + line-height: 21px; + color: $color-primary; + background-color: transparent; + border: 1px solid $color-elements; + cursor: pointer; + transition: + border-color 0.3s, + background-color 0.3s, + color 0.3s; + + &:hover { + border-color: $color-primary; + } + + &--active { + color: $color-button-text; + background-color: $color-button; + border-color: $color-button; + cursor: default; + + &:hover { + border-color: $color-button; + } + } + } + + &__arrow { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background-color: transparent; + border: 1px solid $color-elements; + cursor: pointer; + transition: border-color 0.3s; + + &:hover:not(:disabled) { + border-color: $color-primary; + } + + &--disabled { + opacity: 0.5; + cursor: not-allowed; + } + + img { + width: 16px; + height: 16px; + } + } +} diff --git a/src/components/Pagination/Pagination.tsx b/src/components/Pagination/Pagination.tsx new file mode 100644 index 00000000000..de1ccba782b --- /dev/null +++ b/src/components/Pagination/Pagination.tsx @@ -0,0 +1,92 @@ +import classNames from 'classnames'; +import styles from './Pagination.module.scss'; + +const VISIBLE_PAGES = 4; + +type Props = { + total: number; + perPage: number; + currentPage: number; + onPageChange: (page: number) => void; +}; + +export const Pagination: React.FC = ({ + total, + perPage, + currentPage, + onPageChange, +}) => { + const totalPages = Math.ceil(total / perPage); + + if (totalPages <= 1) { + return null; + } + + // Calculate the visible window of pages + let start = Math.max(1, currentPage - 1); + const end = Math.min(totalPages, start + VISIBLE_PAGES - 1); + + start = Math.max(1, end - VISIBLE_PAGES + 1); + + const pages = Array.from({ length: end - start + 1 }, (_, i) => start + i); + + const handlePrev = () => { + if (currentPage > 1) { + onPageChange(currentPage - 1); + } + }; + + const handleNext = () => { + if (currentPage < totalPages) { + onPageChange(currentPage + 1); + } + }; + + return ( +
    +
  • + +
  • + + {pages.map(page => ( +
  • + +
  • + ))} + +
  • + +
  • +
+ ); +}; diff --git a/src/components/Pagination/index.ts b/src/components/Pagination/index.ts new file mode 100644 index 00000000000..0a1fd4dad6c --- /dev/null +++ b/src/components/Pagination/index.ts @@ -0,0 +1 @@ +export { Pagination } from './Pagination'; diff --git a/src/components/PicturesSlider/PicturesSlider.module.scss b/src/components/PicturesSlider/PicturesSlider.module.scss new file mode 100644 index 00000000000..d5f203f655c --- /dev/null +++ b/src/components/PicturesSlider/PicturesSlider.module.scss @@ -0,0 +1,104 @@ +@use '../../styles/variables' as *; + +.slider { + margin-bottom: 56px; + + @include on-tablet { + margin-bottom: 64px; + } + + @include on-desktop { + margin-bottom: 80px; + } + + &__container { + display: flex; + align-items: center; + gap: 16px; + } + + &__button { + display: none; + align-items: center; + justify-content: center; + width: 32px; + height: 400px; + border: 1px solid $color-icons; + background-color: $color-surface; + flex-shrink: 0; + cursor: pointer; + transition: + border-color 0.3s, + box-shadow 0.3s; + + @include on-tablet { + display: flex; + } + + &:hover { + border-color: $color-primary; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + } + + img { + width: 16px; + height: 16px; + } + } + + &__viewport { + width: 100%; + overflow: hidden; + border-radius: 0; + } + + &__track { + display: flex; + transition: transform 0.5s ease-in-out; + } + + &__slide { + min-width: 100%; + display: flex; + align-items: center; + justify-content: center; + } + + &__image { + width: 100%; + height: 200px; + object-fit: cover; + + @include on-tablet { + height: 320px; + } + + @include on-desktop { + height: 400px; + } + } + + &__dots { + display: flex; + justify-content: center; + gap: 14px; + margin-top: 18px; + } + + &__dot { + width: 14px; + height: 4px; + border: none; + background-color: $color-elements; + cursor: pointer; + transition: background-color 0.3s; + + &:hover { + background-color: $color-icons; + } + + &--active { + background-color: $color-primary; + } + } +} diff --git a/src/components/PicturesSlider/PicturesSlider.tsx b/src/components/PicturesSlider/PicturesSlider.tsx new file mode 100644 index 00000000000..af876d374c6 --- /dev/null +++ b/src/components/PicturesSlider/PicturesSlider.tsx @@ -0,0 +1,102 @@ +import { useState, useEffect, useCallback } from 'react'; +import styles from './PicturesSlider.module.scss'; + +const bannerImages = [ + { + id: 1, + src: 'img/banner-iphone-14.png', + alt: 'Phone 14 Pro', + }, + { + id: 2, + src: 'img/banner-phones.png', + alt: 'Phones banner', + }, + { + id: 3, + src: 'img/banner-tablets.png', + alt: 'Tablets banner', + }, + { + id: 4, + src: 'img/banner-accessories.png', + alt: 'Accessories banner', + }, +]; + +export const PicturesSlider = () => { + const [currentIndex, setCurrentIndex] = useState(0); + + const goToNext = useCallback(() => { + setCurrentIndex(prev => (prev === bannerImages.length - 1 ? 0 : prev + 1)); + }, []); + + const goToPrev = useCallback(() => { + setCurrentIndex(prev => (prev === 0 ? bannerImages.length - 1 : prev - 1)); + }, []); + + const goToSlide = (index: number) => { + setCurrentIndex(index); + }; + + useEffect(() => { + const interval = setInterval(goToNext, 5000); + + return () => clearInterval(interval); + }, [goToNext]); + + return ( +
+
+ + +
+
+ {bannerImages.map(image => ( +
+ {image.alt} +
+ ))} +
+
+ + +
+ +
+ {bannerImages.map((image, index) => ( +
+
+ ); +}; diff --git a/src/components/PicturesSlider/index.ts b/src/components/PicturesSlider/index.ts new file mode 100644 index 00000000000..3d16f0bbb89 --- /dev/null +++ b/src/components/PicturesSlider/index.ts @@ -0,0 +1 @@ +export { PicturesSlider } from './PicturesSlider'; diff --git a/src/components/ProductCard/ProductCard.module.scss b/src/components/ProductCard/ProductCard.module.scss new file mode 100644 index 00000000000..6102e08f97a --- /dev/null +++ b/src/components/ProductCard/ProductCard.module.scss @@ -0,0 +1,173 @@ +@use '../../styles/variables' as *; + +.card { + display: flex; + flex-direction: column; + padding: 32px; + border: 1px solid $color-elements; + background-color: $color-surface; + width: 100%; + min-width: 212px; + max-width: 272px; + flex-shrink: 0; + transition: box-shadow 0.3s; + + &:hover { + box-shadow: 0 2px 16px rgba(0, 0, 0, 0.1); + } + + &__imageLink { + display: flex; + align-items: center; + justify-content: center; + height: 196px; + margin-bottom: 8px; + } + + &__image { + max-width: 100%; + max-height: 196px; + object-fit: contain; + transition: transform 0.3s; + + &:hover { + transform: scale(1.1); + } + } + + &__title { + font-size: 14px; + font-weight: 600; + line-height: 21px; + color: $color-primary; + margin-bottom: 8px; + min-height: 42px; + display: -webkit-box; + line-clamp: 2; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + transition: color 0.3s; + + &:hover { + color: $color-accent; + } + } + + &__prices { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + } + + &__price { + font-size: 22px; + font-weight: 700; + line-height: 31px; + color: $color-primary; + } + + &__fullPrice { + font-size: 22px; + font-weight: 600; + line-height: 28px; + color: $color-secondary; + text-decoration: line-through; + } + + &__divider { + width: 100%; + height: 1px; + background-color: $color-elements; + margin-bottom: 8px; + } + + &__specs { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 16px; + } + + &__spec { + display: flex; + justify-content: space-between; + align-items: center; + } + + &__specName { + font-size: 12px; + font-weight: 600; + line-height: 15px; + color: $color-secondary; + } + + &__specValue { + font-size: 12px; + font-weight: 700; + line-height: 15px; + color: $color-primary; + } + + &__actions { + display: flex; + gap: 8px; + margin-top: auto; + } + + &__addToCart { + flex: 1; + height: 40px; + font-size: 14px; + font-weight: 600; + line-height: 21px; + color: $color-button-text; + background-color: $color-button; + border: none; + cursor: pointer; + transition: + background-color 0.3s, + box-shadow 0.3s; + + &:hover { + box-shadow: 0 3px 13px $color-button-shadow; + } + + &--active { + color: $color-green; + background-color: $color-hover-and-bg; + border: 1px solid $color-elements; + + &:hover { + box-shadow: none; + border-color: $color-primary; + } + } + } + + &__favourite { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border: 1px solid $color-icons; + background-color: $color-hover-and-bg; + cursor: pointer; + transition: border-color 0.3s; + + &:hover { + border-color: $color-primary; + } + + &--active { + border-color: $color-elements; + } + + img { + width: 16px; + height: 16px; + } + } +} diff --git a/src/components/ProductCard/ProductCard.tsx b/src/components/ProductCard/ProductCard.tsx new file mode 100644 index 00000000000..d3634bb2d6f --- /dev/null +++ b/src/components/ProductCard/ProductCard.tsx @@ -0,0 +1,105 @@ +import { Link } from 'react-router-dom'; +import classNames from 'classnames'; +import { Product } from '../../types/Product'; +import { useCart } from '../../context/CartContext'; +import { useFavorites } from '../../context/FavoritesContext'; +import styles from './ProductCard.module.scss'; + +type Props = { + product: Product; + hasDiscount?: boolean; +}; + +export const ProductCard: React.FC = ({ + product, + hasDiscount: hasDiscountProp, +}) => { + const { itemId, name, fullPrice, price, screen, capacity, ram } = product; + const hasDiscount = hasDiscountProp ?? fullPrice > price; + const displayedPrice = hasDiscount ? price : fullPrice; + + const { addToCart, isInCart } = useCart(); + const { toggleFavorite, isFavorite } = useFavorites(); + + const inCart = isInCart(product.id); + const favorited = isFavorite(product.id); + + const handleAddToCart = () => { + addToCart(product); + }; + + const handleToggleFav = () => { + toggleFavorite(product); + }; + + return ( +
+ + {name} + + + + {name} + + +
+ ${displayedPrice} + {hasDiscount && ( + ${fullPrice} + )} +
+ +
+ +
+
+ Screen + {screen} +
+ +
+ Capacity + {capacity} +
+ +
+ RAM + {ram} +
+
+ +
+ + + +
+
+ ); +}; diff --git a/src/components/ProductCard/index.ts b/src/components/ProductCard/index.ts new file mode 100644 index 00000000000..c4f2778191c --- /dev/null +++ b/src/components/ProductCard/index.ts @@ -0,0 +1 @@ +export { ProductCard } from './ProductCard'; diff --git a/src/components/ProductsSlider/ProductsSlider.module.scss b/src/components/ProductsSlider/ProductsSlider.module.scss new file mode 100644 index 00000000000..e238a8622e8 --- /dev/null +++ b/src/components/ProductsSlider/ProductsSlider.module.scss @@ -0,0 +1,82 @@ +@use '../../styles/variables' as *; + +.productsSlider { + margin-bottom: 56px; + + @include on-tablet { + margin-bottom: 64px; + } + + @include on-desktop { + margin-bottom: 80px; + } + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + } + + &__title { + font-size: 22px; + font-weight: 700; + line-height: 31px; + color: $color-primary; + + @include on-desktop { + font-size: 32px; + line-height: 41px; + letter-spacing: -0.01em; + } + } + + &__buttons { + display: flex; + gap: 16px; + } + + &__button { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid $color-icons; + background-color: $color-surface; + cursor: pointer; + transition: + border-color 0.3s, + opacity 0.3s; + + &:hover:not(:disabled) { + border-color: $color-primary; + } + + &--disabled { + opacity: 0.5; + cursor: not-allowed; + } + + img { + width: 16px; + height: 16px; + } + } + + &__viewport { + overflow: hidden; + } + + &__track { + display: flex; + gap: 16px; + transition: transform 0.5s ease; + + // Cards inside slider keep fixed width + > * { + width: 272px; + max-width: 272px; + } + } +} diff --git a/src/components/ProductsSlider/ProductsSlider.tsx b/src/components/ProductsSlider/ProductsSlider.tsx new file mode 100644 index 00000000000..19023e6baac --- /dev/null +++ b/src/components/ProductsSlider/ProductsSlider.tsx @@ -0,0 +1,86 @@ +import { useState, useRef } from 'react'; +import { Product } from '../../types/Product'; +import { ProductCard } from '../ProductCard'; +import styles from './ProductsSlider.module.scss'; + +type Props = { + title: string; + products: Product[]; + hasDiscount?: boolean; +}; + +const CARD_WIDTH = 272; +const GAP = 16; + +export const ProductsSlider: React.FC = ({ + title, + products, + hasDiscount, +}) => { + const [scrollPosition, setScrollPosition] = useState(0); + const trackRef = useRef(null); + + const step = CARD_WIDTH + GAP; + const maxScroll = Math.max(0, products.length * step - step * 4); + + const handlePrev = () => { + setScrollPosition(prev => Math.max(0, prev - step)); + }; + + const handleNext = () => { + setScrollPosition(prev => Math.min(maxScroll, prev + step)); + }; + + const isPrevDisabled = scrollPosition <= 0; + const isNextDisabled = scrollPosition >= maxScroll; + + return ( +
+
+

{title}

+ +
+ + + +
+
+ +
+
+ {products.map(product => ( + + ))} +
+
+
+ ); +}; diff --git a/src/components/ProductsSlider/index.ts b/src/components/ProductsSlider/index.ts new file mode 100644 index 00000000000..0a5bb986628 --- /dev/null +++ b/src/components/ProductsSlider/index.ts @@ -0,0 +1 @@ +export { ProductsSlider } from './ProductsSlider'; diff --git a/src/components/ThemeToggle/ThemeToggle.module.scss b/src/components/ThemeToggle/ThemeToggle.module.scss new file mode 100644 index 00000000000..3836d935899 --- /dev/null +++ b/src/components/ThemeToggle/ThemeToggle.module.scss @@ -0,0 +1,55 @@ +@use '../../styles/variables' as *; + +.toggle { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: none; + background: none; + cursor: pointer; + border-radius: 50%; + transition: + background-color 0.3s, + transform 0.3s; + color: $color-primary; + + &:hover { + background-color: $color-hover-and-bg; + transform: rotate(15deg); + } + + &:active { + transform: scale(0.9); + } +} + +.toggle__icon { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; +} + +.toggle__sun, +.toggle__moon { + position: absolute; + inset: 0; + width: 16px; + height: 16px; + transition: + opacity 0.4s ease, + transform 0.4s ease; + opacity: 0; + transform: rotate(-90deg) scale(0.5); +} + +.toggle__sun--visible, +.toggle__moon--visible { + opacity: 1; + transform: rotate(0deg) scale(1); +} diff --git a/src/components/ThemeToggle/ThemeToggle.tsx b/src/components/ThemeToggle/ThemeToggle.tsx new file mode 100644 index 00000000000..d0e65472e11 --- /dev/null +++ b/src/components/ThemeToggle/ThemeToggle.tsx @@ -0,0 +1,65 @@ +import classNames from 'classnames'; +import { useTheme } from '../../context/ThemeContext'; +import styles from './ThemeToggle.module.scss'; + +export const ThemeToggle = () => { + const { theme, toggleTheme } = useTheme(); + const isDark = theme === 'dark'; + + return ( + + ); +}; diff --git a/src/components/ThemeToggle/index.ts b/src/components/ThemeToggle/index.ts new file mode 100644 index 00000000000..0f27a513f02 --- /dev/null +++ b/src/components/ThemeToggle/index.ts @@ -0,0 +1 @@ +export { ThemeToggle } from './ThemeToggle'; diff --git a/src/context/CartContext.tsx b/src/context/CartContext.tsx new file mode 100644 index 00000000000..e76e2043e9e --- /dev/null +++ b/src/context/CartContext.tsx @@ -0,0 +1,147 @@ +import React, { createContext, useContext, useReducer, useEffect } from 'react'; +import { Product } from '../types/Product'; +import { CartItem } from '../types/CartItem'; + +/* ---------- helpers ---------- */ + +const STORAGE_KEY = 'cart'; + +function loadCart(): CartItem[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } +} + +function saveCart(items: CartItem[]) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); +} + +/* ---------- actions ---------- */ + +type Action = + | { type: 'ADD'; product: Product } + | { type: 'REMOVE'; productId: number } + | { type: 'INCREMENT'; productId: number } + | { type: 'DECREMENT'; productId: number } + | { type: 'CLEAR' }; + +function cartReducer(state: CartItem[], action: Action): CartItem[] { + switch (action.type) { + case 'ADD': { + const exists = state.find(i => i.product.id === action.product.id); + + if (exists) { + return state; + } + + return [ + ...state, + { id: action.product.id, quantity: 1, product: action.product }, + ]; + } + + case 'REMOVE': + return state.filter(i => i.product.id !== action.productId); + + case 'INCREMENT': + return state.map(i => + i.product.id === action.productId + ? { ...i, quantity: i.quantity + 1 } + : i, + ); + + case 'DECREMENT': + return state.map(i => + i.product.id === action.productId && i.quantity > 1 + ? { ...i, quantity: i.quantity - 1 } + : i, + ); + + case 'CLEAR': + return []; + + default: + return state; + } +} + +/* ---------- context ---------- */ + +interface CartContextType { + items: CartItem[]; + addToCart: (product: Product) => void; + removeFromCart: (productId: number) => void; + increment: (productId: number) => void; + decrement: (productId: number) => void; + clearCart: () => void; + isInCart: (productId: number) => boolean; + totalItems: number; + totalPrice: number; +} + +const CartContext = createContext({ + items: [], + addToCart: () => {}, + removeFromCart: () => {}, + increment: () => {}, + decrement: () => {}, + clearCart: () => {}, + isInCart: () => false, + totalItems: 0, + totalPrice: 0, +}); + +export const useCart = () => useContext(CartContext); + +/* ---------- provider ---------- */ + +export const CartProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [items, dispatch] = useReducer(cartReducer, [], loadCart); + + useEffect(() => { + saveCart(items); + }, [items]); + + const addToCart = (product: Product) => dispatch({ type: 'ADD', product }); + + const removeFromCart = (productId: number) => + dispatch({ type: 'REMOVE', productId }); + + const increment = (productId: number) => + dispatch({ type: 'INCREMENT', productId }); + + const decrement = (productId: number) => + dispatch({ type: 'DECREMENT', productId }); + + const clearCart = () => dispatch({ type: 'CLEAR' }); + + const isInCart = (productId: number) => + items.some(i => i.product.id === productId); + + const totalItems = items.reduce((sum, i) => sum + i.quantity, 0); + + const totalPrice = items.reduce( + (sum, i) => sum + i.product.price * i.quantity, + 0, + ); + + const value: CartContextType = { + items, + addToCart, + removeFromCart, + increment, + decrement, + clearCart, + isInCart, + totalItems, + totalPrice, + }; + + return {children}; +}; diff --git a/src/context/FavoritesContext.tsx b/src/context/FavoritesContext.tsx new file mode 100644 index 00000000000..e99f8a65d71 --- /dev/null +++ b/src/context/FavoritesContext.tsx @@ -0,0 +1,86 @@ +import React, { + createContext, + useContext, + useState, + useEffect, + useCallback, +} from 'react'; +import { Product } from '../types/Product'; + +/* ---------- helpers ---------- */ + +const STORAGE_KEY = 'favorites'; + +function loadFavorites(): Product[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } +} + +function saveFavorites(items: Product[]) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); +} + +/* ---------- context ---------- */ + +interface FavoritesContextType { + favorites: Product[]; + toggleFavorite: (product: Product) => void; + isFavorite: (productId: number) => boolean; + totalFavorites: number; +} + +const FavoritesContext = createContext({ + favorites: [], + toggleFavorite: () => {}, + isFavorite: () => false, + totalFavorites: 0, +}); + +export const useFavorites = () => useContext(FavoritesContext); + +/* ---------- provider ---------- */ + +export const FavoritesProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [favorites, setFavorites] = useState(loadFavorites); + + useEffect(() => { + saveFavorites(favorites); + }, [favorites]); + + const toggleFavorite = useCallback((product: Product) => { + setFavorites(prev => { + const exists = prev.some(p => p.id === product.id); + + if (exists) { + return prev.filter(p => p.id !== product.id); + } + + return [...prev, product]; + }); + }, []); + + const isFavorite = useCallback( + (productId: number) => favorites.some(p => p.id === productId), + [favorites], + ); + + const value: FavoritesContextType = { + favorites, + toggleFavorite, + isFavorite, + totalFavorites: favorites.length, + }; + + return ( + + {children} + + ); +}; diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx new file mode 100644 index 00000000000..a9c8ed9777d --- /dev/null +++ b/src/context/ThemeContext.tsx @@ -0,0 +1,56 @@ +import React, { createContext, useContext, useState, useEffect } from 'react'; + +type Theme = 'light' | 'dark'; + +interface ThemeContextType { + theme: Theme; + toggleTheme: () => void; +} + +const STORAGE_KEY = 'theme'; + +function getInitialTheme(): Theme { + try { + const stored = localStorage.getItem(STORAGE_KEY); + + if (stored === 'light' || stored === 'dark') { + return stored; + } + } catch { + /* empty */ + } + + if (window.matchMedia('(prefers-color-scheme: dark)').matches) { + return 'dark'; + } + + return 'light'; +} + +const ThemeContext = createContext({ + theme: 'light', + toggleTheme: () => {}, +}); + +export const useTheme = () => useContext(ThemeContext); + +export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [theme, setTheme] = useState(getInitialTheme); + + useEffect(() => { + document.documentElement.setAttribute('data-theme', theme); + localStorage.setItem(STORAGE_KEY, theme); + }, [theme]); + + const toggleTheme = () => { + setTheme(prev => (prev === 'light' ? 'dark' : 'light')); + }; + + return ( + + {children} + + ); +}; diff --git a/src/index.tsx b/src/index.tsx index 50470f1508d..35a704d1003 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,9 @@ import { createRoot } from 'react-dom/client'; +import { HashRouter as Router } from 'react-router-dom'; import { App } from './App'; -createRoot(document.getElementById('root') as HTMLElement).render(); +createRoot(document.getElementById('root') as HTMLElement).render( + + + , +); diff --git a/src/modules/AccessoriesPage/AccessoriesPage.tsx b/src/modules/AccessoriesPage/AccessoriesPage.tsx new file mode 100644 index 00000000000..ea6a3d08643 --- /dev/null +++ b/src/modules/AccessoriesPage/AccessoriesPage.tsx @@ -0,0 +1,5 @@ +import { ProductsPage } from '../ProductsPage'; + +export const AccessoriesPage = () => ( + +); diff --git a/src/modules/AccessoriesPage/index.ts b/src/modules/AccessoriesPage/index.ts new file mode 100644 index 00000000000..83dcf696d14 --- /dev/null +++ b/src/modules/AccessoriesPage/index.ts @@ -0,0 +1 @@ +export { AccessoriesPage } from './AccessoriesPage'; diff --git a/src/modules/CartPage/CartPage.module.scss b/src/modules/CartPage/CartPage.module.scss new file mode 100644 index 00000000000..d46504ee345 --- /dev/null +++ b/src/modules/CartPage/CartPage.module.scss @@ -0,0 +1,249 @@ +@use '../../styles/variables' as *; + +.cart { + &__empty { + padding: 40px; + text-align: center; + font-size: 16px; + color: $color-secondary; + } + + &__content { + display: flex; + flex-direction: column; + gap: 32px; + margin-top: 32px; + + @include on-desktop { + flex-direction: row; + gap: 40px; + } + } + + &__list { + flex: 1; + display: flex; + flex-direction: column; + gap: 16px; + } + + // Cart Item — matches Figma "Cart item" component + &__item { + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px; + border: 1px solid $color-elements; + background-color: $color-surface; + + @include on-tablet { + flex-direction: row; + align-items: center; + gap: 24px; + padding: 24px; + } + } + + // Mobile: first row (close + image + name) + &__itemTop { + display: flex; + align-items: center; + gap: 16px; + width: 100%; + + @include on-tablet { + display: contents; + } + } + + // Mobile: second row (counter + price) + &__itemBottom { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + + @include on-tablet { + display: contents; + } + } + + // Close (remove) button + &__remove { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + flex-shrink: 0; + padding: 0; + background: none; + border: none; + cursor: pointer; + opacity: 0.5; + transition: opacity 0.3s; + + &:hover { + opacity: 1; + } + + img { + width: 16px; + height: 16px; + } + } + + // Product image + &__imageLink { + display: flex; + align-items: center; + justify-content: center; + width: 80px; + height: 80px; + flex-shrink: 0; + } + + &__image { + max-width: 100%; + max-height: 100%; + object-fit: contain; + } + + // Product name + &__name { + flex: 1; + font-size: 14px; + font-weight: 500; + line-height: 21px; + color: $color-primary; + min-width: 0; + transition: color 0.3s; + + &:hover { + color: $color-accent; + } + } + + // Counter (– count +) + &__counter { + display: flex; + align-items: center; + gap: 0; + flex-shrink: 0; + } + + &__counterBtn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + font-size: 16px; + font-weight: 600; + color: $color-primary; + background: transparent; + border: 1px solid $color-icons; + cursor: pointer; + transition: border-color 0.3s; + + &:hover:not(:disabled) { + border-color: $color-primary; + } + + &--disabled { + color: $color-elements; + border-color: $color-elements; + cursor: not-allowed; + } + } + + &__quantity { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + font-size: 14px; + font-weight: 500; + line-height: 21px; + color: $color-primary; + text-align: center; + } + + // Price + &__price { + font-size: 22px; + font-weight: 800; + line-height: 1.4; + color: $color-primary; + white-space: nowrap; + text-align: right; + + @include on-tablet { + width: 80px; + flex-shrink: 0; + } + } + + // ================ + // Summary section + // ================ + &__summary { + display: flex; + flex-direction: column; + align-items: center; + padding: 24px; + border: 1px solid $color-elements; + width: 100%; + + @include on-desktop { + width: 368px; + flex-shrink: 0; + align-self: flex-start; + } + } + + &__totalPrice { + font-size: 32px; + font-weight: 700; + line-height: 41px; + letter-spacing: -0.01em; + color: $color-primary; + text-align: center; + margin-bottom: 8px; + } + + &__totalLabel { + font-size: 14px; + font-weight: 600; + line-height: 21px; + color: $color-secondary; + text-align: center; + margin-bottom: 16px; + } + + &__summaryDivider { + width: 100%; + height: 1px; + background-color: $color-elements; + margin-bottom: 16px; + } + + &__checkout { + width: 100%; + height: 48px; + font-family: $font-family; + font-size: 14px; + font-weight: 600; + line-height: 21px; + color: $color-button-text; + background-color: $color-button; + border: none; + cursor: pointer; + transition: box-shadow 0.3s; + + &:hover { + box-shadow: 0 3px 13px $color-button-shadow; + } + } +} diff --git a/src/modules/CartPage/CartPage.tsx b/src/modules/CartPage/CartPage.tsx new file mode 100644 index 00000000000..e75c078891f --- /dev/null +++ b/src/modules/CartPage/CartPage.tsx @@ -0,0 +1,131 @@ +import { Link } from 'react-router-dom'; +import classNames from 'classnames'; +import { BackButton } from '../../components/BackButton'; +import { useCart } from '../../context/CartContext'; +import styles from './CartPage.module.scss'; + +export const CartPage = () => { + const { + items, + removeFromCart, + increment, + decrement, + clearCart, + totalItems, + totalPrice, + } = useCart(); + + const handleCheckout = () => { + // eslint-disable-next-line no-alert + if ( + window.confirm( + 'Checkout is not implemented yet. Do you want to clear the Cart?', + ) + ) { + clearCart(); + } + }; + + return ( +
+ + +

Cart

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

Your cart is empty

+ ) : ( +
+
+ {items.map(({ product, quantity }) => ( +
+
+ + + + {product.name} + + + + {product.name} + +
+ +
+
+ + + + {quantity} + + + +
+ + + ${product.price * quantity} + +
+
+ ))} +
+ +
+

${totalPrice}

+

+ {`Total for ${totalItems} item${totalItems !== 1 ? 's' : ''}`} +

+ +
+ + +
+
+ )} +
+ ); +}; 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..82dbfb403bc --- /dev/null +++ b/src/modules/FavoritesPage/FavoritesPage.module.scss @@ -0,0 +1,25 @@ +@use '../../styles/variables' as *; + +.favPage { + &__grid { + display: grid; + grid-template-columns: 1fr; + gap: 16px; + justify-items: center; + + @include on-tablet { + grid-template-columns: repeat(2, 1fr); + } + + @include on-desktop { + grid-template-columns: repeat(4, 1fr); + } + } + + &__empty { + padding: 40px; + text-align: center; + font-size: 16px; + color: $color-secondary; + } +} diff --git a/src/modules/FavoritesPage/FavoritesPage.tsx b/src/modules/FavoritesPage/FavoritesPage.tsx new file mode 100644 index 00000000000..cf9071ae68b --- /dev/null +++ b/src/modules/FavoritesPage/FavoritesPage.tsx @@ -0,0 +1,40 @@ +import { useSearchParams } from 'react-router-dom'; +import { Breadcrumbs } from '../../components/Breadcrumbs'; +import { ProductCard } from '../../components/ProductCard'; +import { useFavorites } from '../../context/FavoritesContext'; +import styles from './FavoritesPage.module.scss'; + +export const FavoritesPage = () => { + const { favorites, totalFavorites } = useFavorites(); + const [searchParams] = useSearchParams(); + const query = searchParams.get('query') || ''; + + const normalizedQuery = query.toLowerCase().trim(); + + const filtered = query + ? favorites.filter(p => p.name.toLowerCase().includes(normalizedQuery)) + : favorites; + + return ( +
+ + +

Favourites

+

{totalFavorites} items

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

+ {query + ? `No search results for "${query}"` + : "You don't have any favourites yet"} +

+ ) : ( +
+ {filtered.map(product => ( + + ))} +
+ )} +
+ ); +}; 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..316be3610b0 --- /dev/null +++ b/src/modules/HomePage/HomePage.module.scss @@ -0,0 +1,172 @@ +@use '../../styles/variables' as *; + +.homePage { + padding-top: 8px; + + @include on-tablet { + padding-top: 16px; + } + + &__title { + font-size: 32px; + font-weight: 700; + line-height: 41px; + letter-spacing: -0.01em; + margin-bottom: 24px; + color: $color-primary; + + @include on-tablet { + font-size: 48px; + line-height: 56px; + margin-bottom: 32px; + } + } +} + +.error { + font-size: 18px; + color: $color-red; + margin-bottom: 16px; + + &__button { + padding: 8px 24px; + font-size: 14px; + font-weight: 600; + color: $color-button-text; + background-color: $color-button; + border: none; + cursor: pointer; + transition: filter 0.3s; + + &:hover { + filter: brightness(1.3); + } + } +} + +.categories { + margin-bottom: 56px; + + @include on-tablet { + margin-bottom: 64px; + } + + @include on-desktop { + margin-bottom: 80px; + } + + &__title { + font-size: 22px; + font-weight: 700; + line-height: 31px; + letter-spacing: -0.32px; + color: $color-primary; + margin-bottom: 24px; + + @include on-desktop { + font-size: 32px; + line-height: 41px; + margin-bottom: 40px; + } + } + + &__grid { + display: grid; + grid-template-columns: 1fr; + gap: 32px; + + @include on-tablet { + grid-template-columns: repeat(3, 1fr); + gap: 16px; + } + } +} + +.category { + display: flex; + flex-direction: column; + text-decoration: none; + + &__image { + display: block; + width: 100%; + aspect-ratio: 1 / 1; + overflow: hidden; + margin-bottom: 24px; + position: relative; + + @include on-desktop { + aspect-ratio: 1 / 1; + } + + &--phones { + background-color: #6d6474; + } + + &--tablets { + background-color: #8d8d92; + } + + &--accessories { + background-color: #973d5f; + } + } + + &__img { + position: absolute; + max-width: none; + object-fit: cover; + transition: transform 0.3s; + + &:hover { + transform: scale(1.05); + } + } + + // Mobile Phones (1st) - proportional: 386x457 at offset (49, 40) in 368 container + &:nth-child(1) &__img { + left: 13.32%; + top: 10.87%; + width: 104.89%; + height: 124.18%; + } + + // Tablets (2nd) - proportional: 546x546 at offset (22, 20) in 368 container + &:nth-child(2) &__img { + left: 5.98%; + top: 5.43%; + width: 148.37%; + height: 148.37%; + } + + // Accessories (3rd) - proportional: 1472x880 at offset (-368, -458) in 368 container + &:nth-child(3) &__img { + left: -100%; + top: -124.46%; + width: 400%; + height: 239.13%; + } + + &__name { + font-size: 20px; + font-weight: 700; + line-height: normal; + color: $color-primary; + margin-bottom: 4px; + + @include on-desktop { + font-weight: 600; + } + } + + &__count { + font-size: 14px; + font-weight: 600; + line-height: 21px; + color: $color-secondary; + + @include on-desktop { + font-weight: 500; + } + } +} diff --git a/src/modules/HomePage/HomePage.tsx b/src/modules/HomePage/HomePage.tsx new file mode 100644 index 00000000000..f8c3e4c25c8 --- /dev/null +++ b/src/modules/HomePage/HomePage.tsx @@ -0,0 +1,123 @@ +import { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import { Product } from '../../types/Product'; +import { getProducts } from '../../utils/api'; +import { PicturesSlider } from '../../components/PicturesSlider'; +import { ProductsSlider } from '../../components/ProductsSlider'; +import { Loader } from '../../components/Loader'; +import styles from './HomePage.module.scss'; + +export const HomePage = () => { + const [products, setProducts] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + setIsLoading(true); + + getProducts() + .then(setProducts) + .catch(() => setError('Something went wrong')) + .finally(() => setIsLoading(false)); + }, []); + + const hotPrices = [...products] + .filter(p => p.fullPrice > p.price) + .sort((a, b) => b.fullPrice - b.price - (a.fullPrice - a.price)); + + const brandNew = [...products].sort((a, b) => b.year - a.year); + + const phonesCount = products.filter(p => p.category === 'phones').length; + const tabletsCount = products.filter(p => p.category === 'tablets').length; + const accessoriesCount = products.filter( + p => p.category === 'accessories', + ).length; + + if (isLoading) { + return ; + } + + if (error) { + return ( +
+

{error}

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

+ Welcome to Nice Gadgets store! +

+ + + + +
+

Shop by category

+ +
+ +
+ Phones +
+

Mobile phones

+

{phonesCount} models

+ + + +
+ Tablets +
+

Tablets

+

{tabletsCount} models

+ + + +
+ Accessories +
+

Accessories

+

+ {accessoriesCount} models +

+ +
+
+ + +
+
+ ); +}; 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..1a4c25af43e --- /dev/null +++ b/src/modules/NotFoundPage/NotFoundPage.module.scss @@ -0,0 +1,41 @@ +@use '../../styles/variables' as *; + +.notFound { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 24px; + padding: 40px 16px; + min-height: 50vh; + + &__title { + font-size: 32px; + font-weight: 700; + line-height: 41px; + color: $color-primary; + + @include on-tablet { + font-size: 48px; + line-height: 56px; + } + } + + &__image { + max-width: 300px; + width: 100%; + } + + &__link { + font-size: 14px; + font-weight: 600; + color: $color-accent; + border-bottom: 1px solid $color-accent; + transition: color 0.3s; + + &:hover { + color: $color-primary; + border-color: $color-primary; + } + } +} diff --git a/src/modules/NotFoundPage/NotFoundPage.tsx b/src/modules/NotFoundPage/NotFoundPage.tsx new file mode 100644 index 00000000000..be6a9f9cb54 --- /dev/null +++ b/src/modules/NotFoundPage/NotFoundPage.tsx @@ -0,0 +1,16 @@ +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/PhonesPage/PhonesPage.tsx b/src/modules/PhonesPage/PhonesPage.tsx new file mode 100644 index 00000000000..8cb5cba3598 --- /dev/null +++ b/src/modules/PhonesPage/PhonesPage.tsx @@ -0,0 +1,5 @@ +import { ProductsPage } from '../ProductsPage'; + +export const PhonesPage = () => ( + +); diff --git a/src/modules/PhonesPage/index.ts b/src/modules/PhonesPage/index.ts new file mode 100644 index 00000000000..6054067fc66 --- /dev/null +++ b/src/modules/PhonesPage/index.ts @@ -0,0 +1 @@ +export { PhonesPage } from './PhonesPage'; diff --git a/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss b/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss new file mode 100644 index 00000000000..5b23822da5f --- /dev/null +++ b/src/modules/ProductDetailsPage/ProductDetailsPage.module.scss @@ -0,0 +1,398 @@ +@use '../../styles/variables' as *; + +.details { + &__title { + font-size: 22px; + font-weight: 700; + line-height: 31px; + color: $color-primary; + margin-bottom: 32px; + + @include on-tablet { + font-size: 32px; + line-height: 41px; + letter-spacing: -0.01em; + margin-bottom: 40px; + } + } + + // ---------- Main (gallery + controls) ---------- + + &__main { + display: flex; + flex-direction: column; + gap: 40px; + margin-bottom: 56px; + + @include on-tablet { + flex-direction: row; + gap: 64px; + margin-bottom: 64px; + } + } + + // Gallery + &__gallery { + display: flex; + flex-direction: column-reverse; + gap: 16px; + + @include on-tablet { + flex-direction: row; + flex: 1; + } + + @include on-desktop { + gap: 16px; + } + } + + &__mainImage { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + aspect-ratio: 1; + overflow: hidden; + + @include on-desktop { + width: 464px; + height: 464px; + } + } + + &__mainImg { + max-width: 100%; + max-height: 100%; + object-fit: contain; + } + + &__thumbs { + display: flex; + gap: 8px; + + @include on-tablet { + flex-direction: column; + } + } + + &__thumb { + width: 52px; + height: 52px; + padding: 4px; + border: 1px solid $color-elements; + background: $color-surface; + cursor: pointer; + transition: border-color 0.3s; + display: flex; + align-items: center; + justify-content: center; + + @include on-desktop { + width: 80px; + height: 80px; + padding: 8px; + } + + &:hover { + border-color: $color-secondary; + } + + &--active { + border-color: $color-primary; + } + + img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + } + } + + // Controls + &__controls { + width: 100%; + + @include on-tablet { + width: 320px; + flex-shrink: 0; + } + } + + &__section { + margin-bottom: 24px; + } + + &__sectionLabel { + font-size: 12px; + font-weight: 600; + line-height: 15px; + color: $color-secondary; + margin-bottom: 8px; + } + + &__divider { + width: 100%; + height: 1px; + background-color: $color-elements; + margin-bottom: 24px; + } + + // Colors + &__colors { + display: flex; + gap: 8px; + } + + &__colorCircle { + width: 32px; + height: 32px; + border-radius: 50%; + border: 2px solid $color-elements; + cursor: pointer; + transition: border-color 0.3s; + outline: 2px solid transparent; + outline-offset: 2px; + + &:hover { + outline-color: $color-icons; + } + + &--active { + outline-color: $color-primary; + } + } + + // Capacity + &__capacities { + display: flex; + gap: 8px; + } + + &__capButton { + padding: 7px 8px; + font-family: $font-family; + font-size: 14px; + font-weight: 600; + line-height: 21px; + color: $color-primary; + border: 1px solid $color-icons; + background-color: transparent; + cursor: pointer; + text-align: center; + transition: + background-color 0.3s, + color 0.3s, + border-color 0.3s; + + &:hover { + border-color: $color-primary; + } + + &--active { + color: $color-button-text; + background-color: $color-button; + border-color: $color-button; + cursor: default; + } + } + + // Price + &__priceRow { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; + } + + &__price { + font-size: 32px; + font-weight: 700; + line-height: 41px; + letter-spacing: -0.01em; + color: $color-primary; + } + + &__fullPrice { + font-size: 22px; + font-weight: 600; + line-height: 28px; + color: $color-secondary; + text-decoration: line-through; + } + + // Action buttons + &__actions { + display: flex; + gap: 8px; + margin-bottom: 32px; + } + + &__addToCart { + flex: 1; + height: 48px; + font-family: $font-family; + font-size: 14px; + font-weight: 600; + line-height: 21px; + color: $color-button-text; + background-color: $color-button; + border: none; + cursor: pointer; + transition: + background-color 0.3s, + box-shadow 0.3s; + + &:hover { + box-shadow: 0 3px 13px $color-button-shadow; + } + + &--active { + color: $color-green; + background-color: $color-hover-and-bg; + border: 1px solid $color-elements; + + &:hover { + box-shadow: none; + border-color: $color-primary; + } + } + } + + &__favButton { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border: 1px solid $color-icons; + background: $color-hover-and-bg; + cursor: pointer; + transition: border-color 0.3s; + + &:hover { + border-color: $color-primary; + } + + &--active { + border-color: $color-elements; + } + + img { + width: 16px; + height: 16px; + } + } + + // Short specs + &__shortSpecs { + display: flex; + flex-direction: column; + gap: 8px; + } + + &__specRow { + display: flex; + justify-content: space-between; + } + + &__specLabel { + font-size: 12px; + font-weight: 600; + line-height: 15px; + color: $color-secondary; + } + + &__specValue { + font-size: 12px; + font-weight: 700; + line-height: 15px; + color: $color-primary; + text-align: right; + } + + // ---------- Info (About + Tech specs) ---------- + + &__info { + display: flex; + flex-direction: column; + gap: 56px; + margin-bottom: 56px; + + @include on-desktop { + flex-direction: row; + gap: 64px; + margin-bottom: 80px; + } + } + + &__about { + flex: 1; + } + + &__techSpecs { + @include on-desktop { + width: 512px; + flex-shrink: 0; + } + } + + &__infoTitle { + font-size: 22px; + font-weight: 700; + line-height: 31px; + color: $color-primary; + margin-bottom: 16px; + } + + &__infoDivider { + width: 100%; + height: 1px; + background-color: $color-elements; + margin-bottom: 32px; + } + + &__aboutSection { + margin-bottom: 32px; + + &:last-child { + margin-bottom: 0; + } + } + + &__aboutSubtitle { + font-size: 16px; + font-weight: 600; + line-height: 20px; + color: $color-primary; + margin-bottom: 16px; + } + + &__aboutText { + font-size: 14px; + font-weight: 400; + line-height: 21px; + color: $color-secondary; + margin-bottom: 16px; + + &:last-child { + margin-bottom: 0; + } + } + + &__techList { + display: flex; + flex-direction: column; + gap: 8px; + } + + // ---------- Suggested ---------- + + &__suggested { + margin-bottom: 56px; + + @include on-desktop { + margin-bottom: 80px; + } + } +} diff --git a/src/modules/ProductDetailsPage/ProductDetailsPage.tsx b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx new file mode 100644 index 00000000000..b3e6561e367 --- /dev/null +++ b/src/modules/ProductDetailsPage/ProductDetailsPage.tsx @@ -0,0 +1,346 @@ +import { useEffect, useState } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import classNames from 'classnames'; +import { Breadcrumbs } from '../../components/Breadcrumbs'; +import { BackButton } from '../../components/BackButton'; +import { ProductsSlider } from '../../components/ProductsSlider'; +import { Loader } from '../../components/Loader'; +import { useCart } from '../../context/CartContext'; +import { useFavorites } from '../../context/FavoritesContext'; +import { ProductDetails } from '../../types/ProductDetails'; +import { Product } from '../../types/Product'; +import { + getProductDetails, + getProducts, + getSuggestedProducts, +} from '../../utils/api'; +import styles from './ProductDetailsPage.module.scss'; + +const CATEGORY_TITLES: Record = { + phones: 'Phones', + tablets: 'Tablets', + accessories: 'Accessories', +}; + +const COLOR_HEX: Record = { + black: '#1F2020', + white: '#F9F6EF', + red: '#BA0C2E', + blue: '#215E7C', + green: '#ADE1CD', + yellow: '#FFE681', + purple: '#D1CDDA', + pink: '#FAE0D8', + orange: '#F0D4B0', + gold: '#FCDBC1', + silver: '#F5F5F0', + coral: '#EE7762', + midnight: '#171E27', + starlight: '#FAF6F2', + rosegold: '#E6C7C2', + 'rose gold': '#E6C7C2', + graphite: '#41424C', + sierrablue: '#69ABCE', + spaceblack: '#403E3D', + spacegray: '#535150', + 'space gray': '#535150', + 'space black': '#403E3D', + midnightgreen: '#4E5851', + 'sky blue': '#276787', +}; + +export const ProductDetailsPage = () => { + const { productId = '' } = useParams(); + const [details, setDetails] = useState(null); + const [product, setProduct] = useState(null); + const [suggested, setSuggested] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [selectedImage, setSelectedImage] = useState(0); + const { addToCart, isInCart } = useCart(); + const { toggleFavorite, isFavorite } = useFavorites(); + + useEffect(() => { + setIsLoading(true); + setSelectedImage(0); + + Promise.all([ + getProductDetails(productId), + getProducts(), + getSuggestedProducts(), + ]) + .then(([det, allProducts, sug]) => { + setDetails(det); + + const found = allProducts.find(p => p.itemId === productId) || null; + + setProduct(found); + setSuggested(sug.slice(0, 12)); + }) + .finally(() => setIsLoading(false)); + }, [productId]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!details || !product) { + return ( +
+

Product was not found

+
+ ); + } + + const { + name, + images, + colorsAvailable, + color, + capacityAvailable, + capacity, + priceRegular, + priceDiscount, + screen, + resolution, + processor, + ram, + description, + camera, + zoom, + cell, + namespaceId, + category, + } = details; + + const inCart = isInCart(product.id); + const favorited = isFavorite(product.id); + + const buildLink = (newColor?: string, newCapacity?: string) => { + const c = (newColor || color).toLowerCase().replace(/\s+/g, '-'); + const cap = (newCapacity || capacity).toLowerCase().replace(/\s+/g, '-'); + + return `/product/${namespaceId}-${cap}-${c}`; + }; + + const categoryTitle = CATEGORY_TITLES[category] || category; + + return ( +
+ + + + +

{name}

+ +
+ {/* ---- Image gallery ---- */} +
+
+ {name} +
+ +
+ {images.map((img, i) => ( + + ))} +
+
+ + {/* ---- Controls ---- */} +
+ {/* Colors */} +
+

Available colors

+
+ {colorsAvailable.map(c => ( + + ))} +
+
+ +
+ + {/* Capacity */} +
+

Select capacity

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

About

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

{section.title}

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

+ {paragraph} +

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

Tech specs

+
+ +
+
+ Screen + {screen} +
+
+ Resolution + {resolution} +
+
+ Processor + {processor} +
+
+ RAM + {ram} +
+
+ Built in memory + {capacity} +
+ {camera && ( +
+ Camera + {camera} +
+ )} + {zoom && ( +
+ Zoom + {zoom} +
+ )} +
+ Cell + + {cell.join(', ')} + +
+
+
+
+ + {/* ---- You may also like ---- */} +
+ +
+
+ ); +}; 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..aa3bbc29c6a --- /dev/null +++ b/src/modules/ProductsPage/ProductsPage.module.scss @@ -0,0 +1,62 @@ +@use '../../styles/variables' as *; + +.productsPage { + &__filters { + display: flex; + gap: 16px; + margin-bottom: 24px; + } + + &__grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(212px, 272px)); + gap: 16px; + justify-content: center; + + @include on-tablet { + grid-template-columns: repeat(3, 1fr); + justify-items: center; + } + + @include on-desktop { + grid-template-columns: repeat(4, 1fr); + justify-items: center; + } + } + + &__error { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + padding: 40px; + + p { + font-size: 16px; + color: $color-secondary; + } + } + + &__reload { + padding: 8px 24px; + font-family: $font-family; + font-size: 14px; + font-weight: 600; + color: $color-button-text; + background-color: $color-button; + border: none; + cursor: pointer; + transition: box-shadow 0.3s; + + &:hover { + box-shadow: 0 3px 13px $color-button-shadow; + } + } + + &__empty { + padding: 40px; + text-align: center; + font-size: 16px; + color: $color-secondary; + } +} diff --git a/src/modules/ProductsPage/ProductsPage.tsx b/src/modules/ProductsPage/ProductsPage.tsx new file mode 100644 index 00000000000..18c933277b6 --- /dev/null +++ b/src/modules/ProductsPage/ProductsPage.tsx @@ -0,0 +1,161 @@ +import { useEffect, useState, useMemo } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { Breadcrumbs } from '../../components/Breadcrumbs'; +import { Dropdown } from '../../components/Dropdown'; +import { Pagination } from '../../components/Pagination'; +import { ProductCard } from '../../components/ProductCard'; +import { Loader } from '../../components/Loader'; +import { Product } from '../../types/Product'; +import { getProducts } from '../../utils/api'; +import styles from './ProductsPage.module.scss'; + +const SORT_OPTIONS = [ + { value: 'age', label: 'Newest' }, + { value: 'name', label: 'Alphabetically' }, + { value: 'price', label: 'Cheapest' }, +]; + +const PER_PAGE_OPTIONS = [ + { value: '4', label: '4' }, + { value: '8', label: '8' }, + { value: '16', label: '16' }, + { value: 'all', label: 'All' }, +]; + +type Props = { + category: string; + title: string; +}; + +function sortProducts(products: Product[], sortBy: string): Product[] { + const sorted = [...products]; + + switch (sortBy) { + case 'age': + return sorted.sort((a, b) => b.year - a.year); + case 'name': + return sorted.sort((a, b) => a.name.localeCompare(b.name)); + case 'price': + return sorted.sort((a, b) => a.price - b.price); + default: + return sorted; + } +} + +export const ProductsPage: React.FC = ({ category, title }) => { + const [products, setProducts] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isError, setIsError] = useState(false); + const [searchParams, setSearchParams] = useSearchParams(); + + const sort = searchParams.get('sort') || 'age'; + const perPage = searchParams.get('perPage') || '16'; + const page = +(searchParams.get('page') || '1'); + const query = searchParams.get('query') || ''; + + useEffect(() => { + setIsLoading(true); + setIsError(false); + + getProducts() + .then(all => { + setProducts(all.filter(p => p.category === category)); + }) + .catch(() => setIsError(true)) + .finally(() => setIsLoading(false)); + }, [category]); + + const filtered = useMemo(() => { + if (!query) { + return products; + } + + const q = query.toLowerCase().trim(); + + return products.filter(p => p.name.toLowerCase().includes(q)); + }, [products, query]); + + const sorted = useMemo(() => sortProducts(filtered, sort), [filtered, sort]); + + const itemsPerPage = perPage === 'all' ? sorted.length : +perPage; + const start = (page - 1) * itemsPerPage; + const visible = sorted.slice(start, start + itemsPerPage); + + const updateParams = (key: string, value: string) => { + const params = new URLSearchParams(searchParams); + + params.set(key, value); + + if (key !== 'page') { + params.set('page', '1'); + } + + setSearchParams(params); + }; + + return ( +
+ + +

{title}

+

{filtered.length} models

+ + {isLoading && } + + {isError && !isLoading && ( +
+

Something went wrong. Please try again.

+ +
+ )} + + {!isLoading && !isError && filtered.length === 0 && ( +

+ {query + ? `No search results for "${query}"` + : `There are no ${category} yet`} +

+ )} + + {!isLoading && !isError && filtered.length > 0 && ( + <> +
+ updateParams('sort', v)} + /> + updateParams('perPage', v)} + /> +
+ +
+ {visible.map(product => ( + + ))} +
+ + {perPage !== 'all' && ( + updateParams('page', String(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/TabletsPage/TabletsPage.tsx b/src/modules/TabletsPage/TabletsPage.tsx new file mode 100644 index 00000000000..5b949a29507 --- /dev/null +++ b/src/modules/TabletsPage/TabletsPage.tsx @@ -0,0 +1,5 @@ +import { ProductsPage } from '../ProductsPage'; + +export const TabletsPage = () => ( + +); diff --git a/src/modules/TabletsPage/index.ts b/src/modules/TabletsPage/index.ts new file mode 100644 index 00000000000..5f5d7eb9d62 --- /dev/null +++ b/src/modules/TabletsPage/index.ts @@ -0,0 +1 @@ +export { TabletsPage } from './TabletsPage'; diff --git a/src/styles/_base.scss b/src/styles/_base.scss new file mode 100644 index 00000000000..f39a3fc5abf --- /dev/null +++ b/src/styles/_base.scss @@ -0,0 +1,123 @@ +@use 'variables' as *; + +// ======================== +// Theme CSS Custom Properties +// ======================== +:root { + --color-primary: #313237; + --color-secondary: #89939a; + --color-icons: #b4bdc3; + --color-elements: #e2e6e9; + --color-hover-and-bg: #fafbfc; + --color-white: #fff; + --color-green: #27ae60; + --color-red: #eb5757; + --color-accent: #4219d0; + --color-surface: #fff; + --color-button: #313237; + --color-button-text: #fff; + --color-button-shadow: rgba(23, 32, 49, 0.4); + --color-focus-border: #313237; + --fill-0: #313237; + --icon-invert: 0; +} + +[data-theme='dark'] { + --color-primary: #f1f2f9; + --color-secondary: #75767f; + --color-icons: #4a4d58; + --color-elements: #3b3e4a; + --color-hover-and-bg: #323542; + --color-white: #0f1121; + --color-green: #27ae60; + --color-red: #eb5757; + --color-accent: #905bff; + --color-surface: #161827; + --color-button: #905bff; + --color-button-text: #fff; + --color-button-shadow: rgba(144, 91, 255, 0.4); + --color-focus-border: #905bff; + --fill-0: #f1f2f9; + --icon-invert: 1; +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + scroll-behavior: smooth; +} + +body { + font-family: $font-family; + font-size: 14px; + line-height: 21px; + font-weight: 600; + color: var(--color-primary); + background-color: var(--color-white); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + transition: + background-color 0.3s ease, + color 0.3s ease; +} + +a { + text-decoration: none; + color: inherit; +} + +ul { + list-style: none; +} + +button { + border: none; + background: none; + cursor: pointer; + font-family: inherit; +} + +img { + display: block; + max-width: 100%; +} + +// Dark mode SVG icon inversion (after img to satisfy specificity order) +[data-theme='dark'] img[src$='.svg']:not([data-no-invert]) { + filter: invert(1) brightness(2); + transition: filter 0.3s; +} + +[data-theme='dark'] img:not([src$='.svg']) { + filter: none; +} + +input, +select { + font-family: inherit; +} + +// Utility classes +.container { + max-width: $content-max-width; + margin: 0 auto; + + @include content-padding-inline; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss new file mode 100644 index 00000000000..684f05ec727 --- /dev/null +++ b/src/styles/_variables.scss @@ -0,0 +1,81 @@ +@font-face { + font-family: Mont; + src: url('/fonts/Mont-Regular.otf') format('opentype'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: Mont; + src: url('/fonts/Mont-SemiBold.otf') format('opentype'); + font-weight: 600; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: Mont; + src: url('/fonts/Mont-Bold.otf') format('opentype'); + font-weight: 700; + font-style: normal; + font-display: swap; +} + +// ======================== +// Design tokens (Figma Original) +// ======================== +// Original static values (for SCSS functions that can't use var()) +$color-primary-static: #313237; + +// Themed colors — reference CSS custom properties defined in _base.scss +$color-primary: var(--color-primary); +$color-secondary: var(--color-secondary); +$color-icons: var(--color-icons); +$color-elements: var(--color-elements); +$color-hover-and-bg: var(--color-hover-and-bg); +$color-white: var(--color-white); +$color-green: var(--color-green); +$color-red: var(--color-red); +$color-accent: var(--color-accent); +$color-surface: var(--color-surface); +$color-button: var(--color-button); +$color-button-text: var(--color-button-text); +$color-button-shadow: var(--color-button-shadow); + +// Typography +$font-family: Mont, Arial, sans-serif; + +// Breakpoints +$tablet: 640px; +$desktop: 1200px; + +// Grid +$content-max-width: 1136px; + +// ======================== +// Mixins +// ======================== +@mixin on-tablet { + @media (min-width: $tablet) { + @content; + } +} + +@mixin on-desktop { + @media (min-width: $desktop) { + @content; + } +} + +@mixin content-padding-inline { + padding-inline: 16px; + + @include on-tablet { + padding-inline: 24px; + } + + @include on-desktop { + padding-inline: 48px; + } +} diff --git a/src/types/CartItem.ts b/src/types/CartItem.ts new file mode 100644 index 00000000000..d58f0dc05d9 --- /dev/null +++ b/src/types/CartItem.ts @@ -0,0 +1,7 @@ +import { Product } from './Product'; + +export interface CartItem { + id: number; + quantity: number; + product: Product; +} diff --git a/src/types/Product.ts b/src/types/Product.ts new file mode 100644 index 00000000000..052d1dceead --- /dev/null +++ b/src/types/Product.ts @@ -0,0 +1,14 @@ +export interface Product { + id: number; + category: string; + itemId: string; + name: string; + fullPrice: number; + price: number; + screen: string; + capacity: string; + color: string; + ram: string; + year: number; + image: string; +} diff --git a/src/types/ProductDetails.ts b/src/types/ProductDetails.ts new file mode 100644 index 00000000000..17096538c59 --- /dev/null +++ b/src/types/ProductDetails.ts @@ -0,0 +1,26 @@ +export interface Description { + title: string; + text: string[]; +} + +export interface ProductDetails { + id: string; + category: string; + namespaceId: string; + name: string; + capacityAvailable: string[]; + capacity: string; + priceRegular: number; + priceDiscount: number; + colorsAvailable: string[]; + color: string; + images: string[]; + description: Description[]; + screen: string; + resolution: string; + processor: string; + ram: string; + camera?: string; + zoom?: string; + cell: string[]; +} diff --git a/src/utils/api.ts b/src/utils/api.ts new file mode 100644 index 00000000000..90c5dd8ebbc --- /dev/null +++ b/src/utils/api.ts @@ -0,0 +1,55 @@ +// This is a helper to simulate a delay for loading states +const BASE_URL = ''; + +function wait(delay: number): Promise { + return new Promise(resolve => { + setTimeout(resolve, delay); + }); +} + +async function request(url: string): Promise { + await wait(300); + + const response = await fetch(BASE_URL + url); + + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`); + } + + return response.json(); +} + +export const getProducts = () => + request('api/products.json'); +export const getPhones = () => + request( + 'api/phones.json', + ); +export const getTablets = () => + request( + 'api/tablets.json', + ); +export const getAccessories = () => + request( + 'api/accessories.json', + ); + +export const getProductDetails = async ( + productId: string, +): Promise => { + const [phones, tablets, accessories] = await Promise.all([ + getPhones(), + getTablets(), + getAccessories(), + ]); + + const allDetails = [...phones, ...tablets, ...accessories]; + + return allDetails.find(item => item.id === productId) || null; +}; + +export const getSuggestedProducts = async () => { + const products = await getProducts(); + + return [...products].sort(() => Math.random() - 0.5); +};