diff --git a/db.json b/db.json new file mode 100644 index 0000000..9e8ff29 --- /dev/null +++ b/db.json @@ -0,0 +1,70 @@ +{ + "products": [ + { + "id": "1", + "name": "Breed Dry Dog Food", + "price": 100, + "imageUrl": "./src/assets/images/products/dog-food.svg" + }, + { + "id": "2", + "name": "CANON EOS DSLR Camera", + "price": 360, + "imageUrl": "./src/assets/images/products/camera.svg" + }, + { + "id": "3", + "name": "ASUS FHD Gaming Laptop", + "price": 700, + "imageUrl": "./src/assets/images/products/laptop.svg" + }, + { + "id": "4", + "name": "Curology Product Set ", + "price": 500, + "imageUrl": "./src/assets/images/products/curoset.svg" + }, + { + "id": "5", + "name": "Kids Electric Car", + "price": 960, + "imageUrl": "./src/assets/images/products/car.svg" + }, + { + "id": "6", + "name": "Jr. Zoom Soccer Cleats", + "price": 1160, + "imageUrl": "./src/assets/images/products/soccer.svg" + }, + { + "id": "7", + "name": "GP11 Shooter USB Gamepad", + "price": 660, + "imageUrl": "./src/assets/images/products/gamepad.svg" + }, + { + "id": "8", + "name": "Quilted Satin Jacket", + "price": 660, + "imageUrl": "./src/assets/images/products/jacket.svg" + } + ], + "cart": [ + { + "id": "f7d5", + "productId": "1", + "name": "Breed Dry Dog Food", + "price": 100, + "imageUrl": "./src/assets/images/products/dog-food.svg", + "quantity": 3 + }, + { + "id": "bb06", + "productId": "2", + "name": "CANON EOS DSLR Camera", + "price": 360, + "imageUrl": "./src/assets/images/products/camera.svg", + "quantity": 1 + } + ] +} \ No newline at end of file diff --git a/src/assets/icons/cart.svg b/src/assets/icons/cart.svg new file mode 100644 index 0000000..cb051ca --- /dev/null +++ b/src/assets/icons/cart.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/icons/loop.svg b/src/assets/icons/loop.svg new file mode 100644 index 0000000..77862ef --- /dev/null +++ b/src/assets/icons/loop.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/trash.svg b/src/assets/icons/trash.svg new file mode 100644 index 0000000..2c6f001 --- /dev/null +++ b/src/assets/icons/trash.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/user.svg b/src/assets/icons/user.svg new file mode 100644 index 0000000..5b9803a --- /dev/null +++ b/src/assets/icons/user.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/wish.svg b/src/assets/icons/wish.svg new file mode 100644 index 0000000..001ccbf --- /dev/null +++ b/src/assets/icons/wish.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/wishlist.svg b/src/assets/icons/wishlist.svg new file mode 100644 index 0000000..8eab1b8 --- /dev/null +++ b/src/assets/icons/wishlist.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/products/camera.svg b/src/assets/images/products/camera.svg new file mode 100644 index 0000000..729188c --- /dev/null +++ b/src/assets/images/products/camera.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/images/products/car.svg b/src/assets/images/products/car.svg new file mode 100644 index 0000000..a12ad22 --- /dev/null +++ b/src/assets/images/products/car.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/images/products/curoset.svg b/src/assets/images/products/curoset.svg new file mode 100644 index 0000000..bfacd10 --- /dev/null +++ b/src/assets/images/products/curoset.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/images/products/dog-food.svg b/src/assets/images/products/dog-food.svg new file mode 100644 index 0000000..81aaf78 --- /dev/null +++ b/src/assets/images/products/dog-food.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/images/products/gamepad.svg b/src/assets/images/products/gamepad.svg new file mode 100644 index 0000000..54d90ed --- /dev/null +++ b/src/assets/images/products/gamepad.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/images/products/jacket.svg b/src/assets/images/products/jacket.svg new file mode 100644 index 0000000..e1b9d52 --- /dev/null +++ b/src/assets/images/products/jacket.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/images/products/laptop.svg b/src/assets/images/products/laptop.svg new file mode 100644 index 0000000..4401a4d --- /dev/null +++ b/src/assets/images/products/laptop.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/images/products/soccer.svg b/src/assets/images/products/soccer.svg new file mode 100644 index 0000000..a565a9a --- /dev/null +++ b/src/assets/images/products/soccer.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/styles/_variables.scss b/src/assets/styles/_variables.scss new file mode 100644 index 0000000..95ae2a3 --- /dev/null +++ b/src/assets/styles/_variables.scss @@ -0,0 +1,4 @@ +$white-color: white; +$black-color: black; + +$primary-color: #db4444; diff --git a/src/components/OrderDetail.jsx b/src/components/OrderDetail.jsx new file mode 100644 index 0000000..022edcb --- /dev/null +++ b/src/components/OrderDetail.jsx @@ -0,0 +1,41 @@ +import styles from './OrderDetail.module.scss' +import { useCart } from '../hooks/useCart' + +const OrderDetail = () => { + const { cart } = useCart() + + const subtotal = cart.reduce( + (acc, item) => acc + item.price * item.quantity, + 0 + ) + const discount = subtotal * 0.2 + const delivery = 15 + const total = subtotal - discount + delivery + + return ( +
+

Order Summary

+
+
+

Subtotal

+ ${subtotal} +
+
+

Discount (-20%)

+ -${discount} +
+
+

Delivery Fee

+ ${delivery} +
+
+
+

Total

+ ${total} +
+ +
+ ) +} + +export default OrderDetail diff --git a/src/components/OrderDetail.module.scss b/src/components/OrderDetail.module.scss new file mode 100644 index 0000000..47b0691 --- /dev/null +++ b/src/components/OrderDetail.module.scss @@ -0,0 +1,67 @@ +@use '../assets/styles/variables' as *; + +.detail { + border: 1px solid gainsboro; + border-radius: 20px; + + flex-basis: 50%; + margin-top: 24px; + padding: 20px; + + display: flex; + flex-direction: column; + + .stats { + display: flex; + flex-direction: column; + gap: 20px; + margin-top: 24px; + padding-bottom: 20px; + + border-bottom: 1px solid gainsboro; + div { + display: flex; + align-items: center; + justify-content: space-between; + + p { + color: gray; + font-size: 20px; + } + + span { + font-size: 20px; + font-weight: bold; + } + } + } + + .total { + display: flex; + align-items: center; + justify-content: space-between; + + p { + color: gray; + font-size: 20px; + } + + span { + font-size: 20px; + font-weight: bold; + margin-top: 20px; + } + } + + button { + outline: 0; + border: 0; + font-size: 16px; + padding: 20px; + border-radius: 62px; + background-color: $primary-color; + color: $white-color; + cursor: pointer; + margin-top: 24px; + } +} diff --git a/src/components/card/cart/index.jsx b/src/components/card/cart/index.jsx new file mode 100644 index 0000000..87fb14d --- /dev/null +++ b/src/components/card/cart/index.jsx @@ -0,0 +1,41 @@ +import styles from './index.module.scss' +import TrashIcon from '../../../assets/icons/trash.svg' +import { useCart } from '../../../hooks/useCart' + +const CartItem = ({ item }) => { + const { removeFromCart, updateQuantity } = useCart() + + const handleIncrement = () => { + updateQuantity(item.id, item.quantity + 1) + } + + const handleDecrement = () => { + if (item.quantity > 1) { + updateQuantity(item.id, item.quantity - 1) + } + } + + return ( +
+ +
+ {item.name} +
+
+

{item.name}

+
+ ${item.price} +
+ + {item.quantity} + +
+
+
+
+ ) +} + +export default CartItem diff --git a/src/components/card/cart/index.module.scss b/src/components/card/cart/index.module.scss new file mode 100644 index 0000000..01362b0 --- /dev/null +++ b/src/components/card/cart/index.module.scss @@ -0,0 +1,62 @@ +.item { + border-bottom: 1px solid #dcdcdc; + padding: 24px 0; + position: relative; + + .trash { + outline: 0; + border: 0; + background-color: white; + position: absolute; + + top: 20px; + right: 20px; + + cursor: pointer; + } + + .image { + background-color: #f5f5f5; + display: flex; + align-items: center; + justify-content: center; + padding: 35px; + height: 124px; + width: 124px; + + img { + width: 90px; + object-fit: contain; + } + } + + .wrapper { + display: flex; + align-items: center; + justify-content: space-between; + + .price { + font-size: 24px; + } + + .buttons { + display: flex; + background-color: #f0f0f0; + padding: 12px 20px; + gap: 20px; + border-radius: 62px; + align-items: center; + + span { + font-size: 14px; + } + + button { + outline: 0; + border: 0; + font-size: 16px; + cursor: pointer; + } + } + } +} diff --git a/src/components/card/product/index.jsx b/src/components/card/product/index.jsx new file mode 100644 index 0000000..f7210cd --- /dev/null +++ b/src/components/card/product/index.jsx @@ -0,0 +1,33 @@ +import styles from './index.module.scss' +import WishIcon from '../../../assets/icons/wish.svg' +import { useCart } from '../../../hooks/useCart' + +const ProductCard = ({ id, name, price, imageUrl }) => { + const { addToCart } = useCart() + + const handleAdd = () => { + addToCart({ id, name, price, imageUrl }) + } + + return ( +
+
+ {name} +
+ + +
+
+
+

{name}

+
+ ${price} +
+
+
+ ) +} + +export default ProductCard diff --git a/src/components/card/product/index.module.scss b/src/components/card/product/index.module.scss new file mode 100644 index 0000000..d4013a3 --- /dev/null +++ b/src/components/card/product/index.module.scss @@ -0,0 +1,48 @@ +@use '../../../assets/styles/variables' as *; + +.product { + display: flex; + flex-direction: column; + gap: 16px; + width: 270px; + + .image { + background-color: #f5f5f5; + display: flex; + align-items: center; + justify-content: center; + padding: 35px; + height: 250px; + position: relative; + + .buttons { + position: absolute; + top: 12px; + right: 12px; + + button { + display: flex; + outline: 0; + border: 0; + border-radius: 50%; + padding: 10px; + background-color: white; + + cursor: pointer; + } + } + } + + .content { + h4 { + font-size: 16px; + } + + .stats { + span { + font-size: 16px; + color: $primary-color; + } + } + } +} diff --git a/src/components/header/index.jsx b/src/components/header/index.jsx new file mode 100644 index 0000000..01f4c5a --- /dev/null +++ b/src/components/header/index.jsx @@ -0,0 +1,57 @@ +import styles from './index.module.scss' + +import CartIcon from '../../assets/icons/cart.svg' +import UserIcon from '../../assets/icons/user.svg' +import WishlistIcon from '../../assets/icons/wishlist.svg' +import LoopIcon from '../../assets/icons/loop.svg' +import { Link } from 'react-router-dom' +import { useCart } from '../../hooks/useCart' + +const Header = () => { + const { cart, loading, error } = useCart() + return ( +
+

Exclusive

+ +
+ + +
+
+ + + +
+
+ ) +} + +export default Header diff --git a/src/components/header/index.module.scss b/src/components/header/index.module.scss new file mode 100644 index 0000000..3595d58 --- /dev/null +++ b/src/components/header/index.module.scss @@ -0,0 +1,85 @@ +.header { + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid gainsboro; + padding: 30px 0; + + h2 { + font-size: 24px; + } + + ul { + list-style: none; + display: flex; + gap: 48px; + + a { + color: black; + text-decoration: none; + font-size: 16px; + cursor: pointer; + } + } + + .search { + display: flex; + align-items: center; + background-color: #f5f5f5; + padding: 7px 12px; + gap: 35px; + + input { + outline: 0; + border: 0; + background-color: #f5f5f5; + font-size: 12px; + } + + img { + cursor: pointer; + } + } + + .buttons { + display: flex; + gap: 16px; + + button { + background: none; + outline: 0; + border: 0; + cursor: pointer; + } + } + + .cart { + position: relative; + display: inline-block; + border-radius: 12px; + /*overflow: hidden;*/ + } + + .sticker { + position: absolute; + top: 15px; + /* отступ сверху */ + left: 15px; + /* отступ слева */ + display: flex; + align-items: center; + gap: 6px; + background: linear-gradient(135deg, #ff416c 0%, #ff4b2b 100%); + /* Градиент стикера */ + color: #ffffff; + padding: 6px 10px 6px 10px; + border-radius: 20px; + /* Округление стикера */ + box-shadow: 0 4px 12px rgba(255, 65, 108, 0.4); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.5px; + z-index: 10; + } +} diff --git a/src/components/list/cart/index.jsx b/src/components/list/cart/index.jsx new file mode 100644 index 0000000..026b093 --- /dev/null +++ b/src/components/list/cart/index.jsx @@ -0,0 +1,22 @@ +import CartItem from '../../card/cart' +import styles from './index.module.scss' +import { useCart } from '../../../hooks/useCart' + +const CartList = () => { + const { cart, loading, error } = useCart() + + if (loading) return

Загрузка корзины...

+ if (error) return

Ошибка: {error}

+ + return ( + + ) +} + +export default CartList diff --git a/src/components/list/cart/index.module.scss b/src/components/list/cart/index.module.scss new file mode 100644 index 0000000..1acd00e --- /dev/null +++ b/src/components/list/cart/index.module.scss @@ -0,0 +1,13 @@ +.list { + display: flex; + border: 1px solid gainsboro; + border-radius: 20px; + padding: 20px; + margin-top: 24px; + list-style: none; + + max-width: 715px; + flex-direction: column; + + flex-basis: 80%; +} diff --git a/src/components/list/product/index.jsx b/src/components/list/product/index.jsx new file mode 100644 index 0000000..78c44ae --- /dev/null +++ b/src/components/list/product/index.jsx @@ -0,0 +1,34 @@ +import styles from './index.module.scss' +import ProductCard from '../../card/product' +import { useProducts } from '../../../hooks/useProducts' + +const ProductList = () => { + const { products, loading, error } = useProducts() + + if (loading) { + return
Загрузка продуктов...
+ } + + if (error) { + return ( +
+

Ошибка при загрузке продуктов: {error}

+ +
+ ) + } + + return ( + + ) +} + +export default ProductList diff --git a/src/components/list/product/index.module.scss b/src/components/list/product/index.module.scss new file mode 100644 index 0000000..19d3736 --- /dev/null +++ b/src/components/list/product/index.module.scss @@ -0,0 +1,7 @@ +.list { + display: flex; + flex-wrap: wrap; + list-style: none; + padding-top: 60px; + gap: 30px; +} diff --git a/src/hooks/useCart.js b/src/hooks/useCart.js new file mode 100644 index 0000000..3dd21d9 --- /dev/null +++ b/src/hooks/useCart.js @@ -0,0 +1,48 @@ +import React from 'react' +import { CartService } from '../services/CartService' + +export function useCart() { + const [cart, setCart] = React.useState([]) + const [loading, setLoading] = React.useState(true) + const [error, setError] = React.useState(null) + + const fetchCart = React.useCallback(async () => { + try { + setLoading(true) + const data = await CartService.getCart() + setCart(data) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + }, []) + + const addToCart = async product => { + await CartService.addToCart(product) + await fetchCart() + } + + const removeFromCart = async id => { + await CartService.removeFromCart(id) + await fetchCart() + } + + const updateQuantity = async (id, quantity) => { + await CartService.updateQuantity(id, quantity) + await fetchCart() + } + + React.useEffect(() => { + fetchCart() + }, [fetchCart]) + + return { + cart, + loading, + error, + addToCart, + removeFromCart, + updateQuantity, + } +} diff --git a/src/hooks/useProducts.js b/src/hooks/useProducts.js new file mode 100644 index 0000000..44b460f --- /dev/null +++ b/src/hooks/useProducts.js @@ -0,0 +1,42 @@ +import React from 'react' +import { fetchProducts } from '../services/ProductService' + +export function useProducts() { + const [products, setProducts] = React.useState([]) + const [loading, setLoading] = React.useState(true) + const [error, setError] = React.useState(null) + + React.useEffect(() => { + let cancelled = false + + const loadProducts = async () => { + try { + setLoading(true) + setError(null) + + const data = await fetchProducts() + + if (!cancelled) { + setProducts(data) + } + } catch (err) { + if (!cancelled) { + setError(err.message) + setProducts([]) + } + } finally { + if (!cancelled) { + setLoading(false) + } + } + } + + loadProducts() + + return () => { + cancelled = true + } + }, []) + + return { products, loading, error } +} diff --git a/src/pages/cart/index.jsx b/src/pages/cart/index.jsx new file mode 100644 index 0000000..1c6e816 --- /dev/null +++ b/src/pages/cart/index.jsx @@ -0,0 +1,20 @@ +import styles from './index.module.scss' + +import Header from '../../components/header' +import CartList from '../../components/list/cart' +import OrderDetail from '../../components/OrderDetail' + +const Cart = () => { + return ( +
+
+

Your Cart

+
+ + +
+
+ ) +} + +export default Cart diff --git a/src/pages/cart/index.module.scss b/src/pages/cart/index.module.scss new file mode 100644 index 0000000..3d536a3 --- /dev/null +++ b/src/pages/cart/index.module.scss @@ -0,0 +1,4 @@ +.container { + max-width: 1170px; + margin: 0 auto; +} diff --git a/src/pages/catalog/index.jsx b/src/pages/catalog/index.jsx new file mode 100644 index 0000000..d12a636 --- /dev/null +++ b/src/pages/catalog/index.jsx @@ -0,0 +1,15 @@ +import styles from './index.module.scss' + +import Header from '../../components/header/index' +import ProductList from '../../components/list/product/index' + +const Catalog = () => { + return ( +
+
+ +
+ ) +} + +export default Catalog diff --git a/src/pages/catalog/index.module.scss b/src/pages/catalog/index.module.scss new file mode 100644 index 0000000..3d536a3 --- /dev/null +++ b/src/pages/catalog/index.module.scss @@ -0,0 +1,4 @@ +.container { + max-width: 1170px; + margin: 0 auto; +} diff --git a/src/services/CartService.js b/src/services/CartService.js new file mode 100644 index 0000000..0effe16 --- /dev/null +++ b/src/services/CartService.js @@ -0,0 +1,53 @@ +const BASE_URL = 'http://localhost:8000/cart' + +export const CartService = { + async getCart() { + const response = await fetch(BASE_URL) + if (!response.ok) throw new Error('Ошибка при получении корзины') + return response.json() + }, + async getCartCount() { + const response = await fetch(BASE_URL) + if (!response.ok) throw new Error('Ошибка при получении корзины') + return response.json().length + }, + async addToCart(product) { + const existing = await fetch(`${BASE_URL}?productId=${product.id}`) + const data = await existing.json() + + if (data.length > 0) { + const current = data[0] + return fetch(`${BASE_URL}/${current.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ quantity: current.quantity + 1 }), + }) + } else { + return fetch(BASE_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + productId: product.id, + name: product.name, + price: product.price, + imageUrl: product.imageUrl, + quantity: 1, + }), + }) + } + }, + + async updateQuantity(id, quantity) { + return fetch(`${BASE_URL}/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ quantity }), + }) + }, + + async removeFromCart(id) { + return fetch(`${BASE_URL}/${id}`, { + method: 'DELETE', + }) + }, +} diff --git a/src/services/ProductService.js b/src/services/ProductService.js new file mode 100644 index 0000000..9439e27 --- /dev/null +++ b/src/services/ProductService.js @@ -0,0 +1,15 @@ +const API_URL = 'http://localhost:8000/products' + +export async function fetchProducts() { + try { + const response = await fetch(API_URL) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + return await response.json() + } catch (error) { + throw new Error(error.message || 'Ошибка при загрузке продуктов') + } +}