Created
June 9, 2025 08:09
-
-
Save sunmeat/69928361e5087fef422234cf55344542 to your computer and use it in GitHub Desktop.
интернет магазин на классовых компонентах
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
App.jsx: | |
import React, {Component} from 'react'; | |
import {QueryClient, QueryClientProvider, useQuery, useMutation, useQueryClient} from '@tanstack/react-query'; // npm install @tanstack/react-query | |
import Dexie from 'dexie'; // npm install dexie | |
import './App.css'; | |
const db = new Dexie('CartDatabase'); | |
db.version(1).stores({ | |
cart: 'id, title, price, image, quantity', | |
}); | |
const queryClient = new QueryClient({ | |
defaultOptions: { | |
queries: { | |
retry: 2, | |
staleTime: 5 * 60 * 1000, | |
}, | |
}, | |
}); | |
const truncateText = (text, maxLength) => { | |
if (!text) return ''; | |
return text.length > maxLength ? text.slice(0, maxLength) + '...' : text; | |
}; | |
const formatPrice = (price) => { | |
return typeof price === 'number' ? price.toFixed(2) : '0.00'; | |
}; | |
const cartService = { | |
async getCart() { | |
try { | |
return await db.cart.toArray(); | |
} catch (error) { | |
console.error('Ошибка загрузки корзины:', error); | |
return []; | |
} | |
}, | |
async addItem(product) { | |
try { | |
const existingItem = await db.cart.get(product.id); | |
if (existingItem) { | |
const updatedItem = {...existingItem, quantity: existingItem.quantity + 1}; | |
await db.cart.put(updatedItem); | |
return updatedItem; | |
} else { | |
const newItem = { | |
id: product.id, | |
title: product.title, | |
price: product.price, | |
image: product.image, | |
quantity: 1, | |
}; | |
await db.cart.put(newItem); | |
return newItem; | |
} | |
} catch (error) { | |
console.error('Ошибка добавления в корзину:', error); | |
throw error; | |
} | |
}, | |
async updateQuantity(productId, quantity) { | |
try { | |
if (quantity <= 0) { | |
return this.removeItem(productId); | |
} | |
const item = await db.cart.get(productId); | |
if (item) { | |
const updatedItem = {...item, quantity}; | |
await db.cart.put(updatedItem); | |
return updatedItem; | |
} | |
} catch (error) { | |
console.error('Ошибка обновления количества:', error); | |
throw error; | |
} | |
}, | |
async removeItem(productId) { | |
try { | |
await db.cart.delete(productId); | |
return productId; | |
} catch (error) { | |
console.error('Ошибка удаления из корзины:', error); | |
throw error; | |
} | |
}, | |
async clearCart() { | |
try { | |
await db.cart.clear(); | |
} catch (error) { | |
console.error('Ошибка очистки корзины:', error); | |
throw error; | |
} | |
} | |
}; | |
// error boundary для перехвата ошибок в дочерних компонентах | |
class ErrorBoundary extends Component { | |
constructor(props) { | |
super(props); | |
this.state = { | |
hasError: false, | |
error: null, | |
errorInfo: null | |
}; | |
} | |
static getDerivedStateFromError(error) { | |
console.log(error); | |
return {hasError: true}; | |
} | |
componentDidCatch(error, errorInfo) { | |
// логируем ошибку для отладки | |
console.error('ErrorBoundary перехватил ошибку:', error, errorInfo); | |
this.setState({ | |
error: error, | |
errorInfo: errorInfo | |
}); | |
} | |
render() { | |
if (this.state.hasError) { | |
return ( | |
<div className="error-boundary"> | |
<h2>Что-то пошло не так!</h2> | |
<details style={{whiteSpace: 'pre-wrap'}}> | |
{this.state.error && this.state.error.toString()} | |
<br/> | |
{this.state.errorInfo && this.state.errorInfo.componentStack} | |
</details> | |
<button onClick={() => window.location.reload()}> | |
Перезагрузить страницу | |
</button> | |
</div> | |
); | |
} | |
return this.props.children; | |
} | |
} | |
class Notification extends Component { | |
constructor(props) { | |
super(props); | |
this.timerRef = null; | |
} | |
componentDidMount() { | |
// устанавливаем таймер для автоматического закрытия уведомления | |
this.timerRef = setTimeout(() => { | |
this.props.onClose(); | |
}, 3000); | |
} | |
componentWillUnmount() { | |
// очищаем таймер при размонтировании компонента | |
if (this.timerRef) { | |
clearTimeout(this.timerRef); | |
} | |
} | |
handleClose = () => { | |
this.props.onClose(); | |
} | |
render() { | |
const {message, type} = this.props; | |
return ( | |
<div className={`notification notification-${type}`}> | |
{message} | |
<button onClick={this.handleClose} className="notification-close">×</button> | |
</div> | |
); | |
} | |
} | |
class LoadingSpinner extends Component { | |
render() { | |
const {size = 'medium'} = this.props; | |
return <div className={`loading-spinner loading-${size}`}>Загрузка...</div>; | |
} | |
} | |
// hoc для работы с react-query в классовых компонентах | |
// HOC (Higher-Order Component, компонент высшего порядка) — это паттерн в React, | |
// представляющий собой функцию, которая принимает компонент и возвращает новый компонент | |
// с дополнительной функциональностью или пропсами | |
// это способ повторного использования логики компонента | |
// HOC часто используется для добавления функциональности, например, работы с внешними | |
// библиотеками (как React Query), управления состоянием, авторизацией и тд | |
function withQuery(WrappedComponent) { | |
return function QueryWrapper(props) { | |
const queryClient = useQueryClient(); | |
return <WrappedComponent {...props} queryClient={queryClient}/>; | |
}; | |
} | |
// hoc для работы с продуктами | |
function withProductsQuery(WrappedComponent) { | |
return function ProductsQueryWrapper(props) { | |
const fetchProducts = async () => { | |
const response = await fetch('https://fakestoreapi.com/products?limit=20'); | |
if (!response.ok) throw new Error('Ошибка загрузки продуктов'); | |
const data = await response.json(); | |
return data.filter(product => | |
product && | |
product.id && | |
product.title && | |
typeof product.price === 'number' | |
); | |
}; | |
const productsQuery = useQuery({ | |
queryKey: ['products'], | |
queryFn: fetchProducts, | |
}); | |
const addToCartMutation = useMutation({ | |
mutationFn: cartService.addItem, | |
onSuccess: (data) => { | |
props.queryClient?.invalidateQueries({queryKey: ['cart']}); | |
// используем ref для вызова метода компонента | |
if (props.componentRef?.current?.handleAddSuccess) { | |
props.componentRef.current.handleAddSuccess(data); | |
} | |
}, | |
onError: (error) => { | |
if (props.componentRef?.current?.handleAddError) { | |
props.componentRef.current.handleAddError(error); | |
} | |
} | |
}); | |
return ( | |
<WrappedComponent | |
{...props} | |
productsQuery={productsQuery} | |
addToCartMutation={addToCartMutation} | |
/> | |
); | |
}; | |
} | |
// hoc для работы с корзиной | |
function withCartQuery(WrappedComponent) { | |
return function CartQueryWrapper(props) { | |
const cartQuery = useQuery({ | |
queryKey: ['cart'], | |
queryFn: cartService.getCart, | |
}); | |
const updateQuantityMutation = useMutation({ | |
mutationFn: ({productId, quantity}) => cartService.updateQuantity(productId, quantity), | |
onSuccess: () => { | |
props.queryClient?.invalidateQueries({queryKey: ['cart']}); | |
}, | |
onError: (error) => { | |
if (props.componentRef?.current?.handleUpdateError) { | |
props.componentRef.current.handleUpdateError(error); | |
} | |
} | |
}); | |
const removeItemMutation = useMutation({ | |
mutationFn: cartService.removeItem, | |
onSuccess: () => { | |
props.queryClient?.invalidateQueries({queryKey: ['cart']}); | |
if (props.componentRef?.current?.handleRemoveSuccess) { | |
props.componentRef.current.handleRemoveSuccess(); | |
} | |
}, | |
onError: (error) => { | |
if (props.componentRef?.current?.handleRemoveError) { | |
props.componentRef.current.handleRemoveError(error); | |
} | |
} | |
}); | |
const clearCartMutation = useMutation({ | |
mutationFn: cartService.clearCart, | |
onSuccess: () => { | |
props.queryClient?.invalidateQueries({queryKey: ['cart']}); | |
if (props.componentRef?.current?.handleClearSuccess) { | |
props.componentRef.current.handleClearSuccess(); | |
} | |
}, | |
onError: (error) => { | |
if (props.componentRef?.current?.handleClearError) { | |
props.componentRef.current.handleClearError(error); | |
} | |
} | |
}); | |
return ( | |
<WrappedComponent | |
{...props} | |
cartQuery={cartQuery} | |
updateQuantityMutation={updateQuantityMutation} | |
removeItemMutation={removeItemMutation} | |
clearCartMutation={clearCartMutation} | |
/> | |
); | |
}; | |
} | |
class ProductList extends Component { | |
constructor(props) { | |
super(props); | |
this.state = { | |
searchTerm: '', | |
notification: null, | |
filteredProducts: [] | |
}; | |
} | |
componentDidMount() { | |
// инициализируем фильтрованные продукты при монтировании | |
this.updateFilteredProducts(); | |
} | |
componentDidUpdate(prevProps, prevState) { | |
// обновляем фильтрованные продукты при изменении поиска или продуктов | |
if (prevState.searchTerm !== this.state.searchTerm || | |
prevProps.productsQuery?.data !== this.props.productsQuery?.data) { | |
this.updateFilteredProducts(); | |
} | |
} | |
// метод для обновления отфильтрованных продуктов | |
updateFilteredProducts = () => { | |
const {productsQuery} = this.props; | |
const {searchTerm} = this.state; | |
const products = productsQuery?.data || []; | |
if (!searchTerm) { | |
this.setState({filteredProducts: products}); | |
return; | |
} | |
const filtered = products.filter(product => | |
product.title.toLowerCase().includes(searchTerm.toLowerCase()) || | |
product.category?.toLowerCase().includes(searchTerm.toLowerCase()) | |
); | |
this.setState({filteredProducts: filtered}); | |
} | |
// обработчик изменения поискового запроса | |
handleSearchChange = (event) => { | |
this.setState({searchTerm: event.target.value}); | |
} | |
// обработчик добавления товара в корзину | |
handleAddToCart = (product) => { | |
this.props.addToCartMutation.mutate(product); | |
} | |
// обработчик успешного добавления | |
handleAddSuccess = (data) => { | |
this.setState({ | |
notification: { | |
message: `${data.title} добавлен в корзину`, | |
type: 'success' | |
} | |
}); | |
} | |
// обработчик ошибки добавления | |
handleAddError = (error) => { | |
this.setState({ | |
notification: { | |
message: 'Ошибка при добавлении товара: ' + error.message, | |
type: 'error' | |
} | |
}); | |
} | |
// обработчик закрытия уведомления | |
handleNotificationClose = () => { | |
this.setState({notification: null}); | |
} | |
// обработчик очистки поиска | |
handleClearSearch = () => { | |
this.setState({searchTerm: ''}); | |
} | |
render() { | |
const {productsQuery, addToCartMutation} = this.props; | |
const {searchTerm, notification, filteredProducts} = this.state; | |
const {data: Products = [], error, isLoading} = productsQuery; | |
if (isLoading) return <LoadingSpinner/>; | |
if (error) return <div className="error">Ошибка: {error.message}</div>; | |
return ( | |
<div className="product-list"> | |
{notification && ( | |
<Notification | |
message={notification.message} | |
type={notification.type} | |
onClose={this.handleNotificationClose} | |
/> | |
)} | |
<div className="product-header"> | |
<h2>Наши товары ({filteredProducts.length})</h2> | |
<div className="search-container"> | |
<input | |
type="text" | |
placeholder="Поиск товаров..." | |
value={searchTerm} | |
onChange={this.handleSearchChange} | |
className="search-input" | |
/> | |
</div> | |
</div> | |
<div className="products"> | |
{filteredProducts.map((product) => ( | |
<div key={product.id} className="product-card"> | |
<div className="product-image-container"> | |
<img | |
src={product.image} | |
alt={product.title} | |
className="product-image" | |
loading="lazy" | |
/> | |
</div> | |
<div className="product-info"> | |
<h3 title={product.title}> | |
{truncateText(product.title, 50)} | |
</h3> | |
<p className="product-category">{product.category}</p> | |
<p className="price">${formatPrice(product.price)}</p> | |
<button | |
onClick={() => this.handleAddToCart(product)} | |
className="add-to-cart" | |
disabled={addToCartMutation.isPending} | |
> | |
{addToCartMutation.isPending ? 'Добавление...' : 'Добавить в корзину'} | |
</button> | |
</div> | |
</div> | |
))} | |
</div> | |
{filteredProducts.length === 0 && searchTerm && ( | |
<div className="no-results"> | |
<p>По запросу "{searchTerm}" ничего не найдено</p> | |
<button onClick={this.handleClearSearch} className="clear-search"> | |
Очистить поиск | |
</button> | |
</div> | |
)} | |
</div> | |
); | |
} | |
} | |
class Cart extends Component { | |
constructor(props) { | |
super(props); | |
this.state = { | |
notification: null, | |
total: 0, | |
totalItems: 0 | |
}; | |
} | |
componentDidMount() { | |
// вычисляем итоги при монтировании | |
this.calculateTotals(); | |
} | |
componentDidUpdate(prevProps) { | |
// пересчитываем итоги при изменении данных корзины | |
if (prevProps.cartQuery?.data !== this.props.cartQuery?.data) { | |
this.calculateTotals(); | |
} | |
} | |
// метод для расчёта общей суммы и количества товаров | |
calculateTotals = () => { | |
const cart = this.props.cartQuery?.data || []; | |
const total = cart.reduce((sum, item) => sum + (item.price * item.quantity), 0); | |
const totalItems = cart.reduce((sum, item) => sum + item.quantity, 0); | |
this.setState({total, totalItems}); | |
} | |
// обработчик изменения количества товара | |
handleQuantityChange = (productId, quantity) => { | |
this.props.updateQuantityMutation.mutate({productId, quantity}); | |
} | |
// обработчик удаления товара | |
handleRemoveItem = (productId) => { | |
this.props.removeItemMutation.mutate(productId); | |
} | |
// обработчик очистки корзины | |
handleClearCart = () => { | |
if (window.confirm('Вы уверены, что хотите очистить корзину?')) { | |
this.props.clearCartMutation.mutate(); | |
} | |
} | |
// обработчик успешного удаления товара | |
handleRemoveSuccess = () => { | |
this.setState({ | |
notification: { | |
message: 'Товар удален из корзины', | |
type: 'success' | |
} | |
}); | |
} | |
// обработчик успешной очистки корзины | |
handleClearSuccess = () => { | |
this.setState({ | |
notification: { | |
message: 'Корзина очищена', | |
type: 'success' | |
} | |
}); | |
} | |
// обработчик ошибок операций с корзиной | |
handleUpdateError = (error) => { | |
this.setState({ | |
notification: { | |
message: 'Ошибка при обновлении количества ' + error, | |
type: 'error' | |
} | |
}); | |
} | |
handleRemoveError = (error) => { | |
this.setState({ | |
notification: { | |
message: 'Ошибка при удалении товара ' + error, | |
type: 'error' | |
} | |
}); | |
} | |
handleClearError = (error) => { | |
this.setState({ | |
notification: { | |
message: 'Ошибка при очистке корзины ' + error, | |
type: 'error' | |
} | |
}); | |
} | |
// обработчик закрытия уведомления | |
handleNotificationClose = () => { | |
this.setState({notification: null}); | |
} | |
render() { | |
const { | |
cartQuery, | |
updateQuantityMutation, | |
removeItemMutation, | |
clearCartMutation | |
} = this.props; | |
const {notification, total, totalItems} = this.state; | |
const {data: cart = [], isLoading, error} = cartQuery; | |
if (isLoading) return <LoadingSpinner size="small"/>; | |
if (error) return <div className="error">Ошибка загрузки корзины</div>; | |
return ( | |
<div className="cart"> | |
{notification && ( | |
<Notification | |
message={notification.message} | |
type={notification.type} | |
onClose={this.handleNotificationClose} | |
/> | |
)} | |
<div className="cart-header"> | |
<h2>Корзина ({totalItems} товаров)</h2> | |
{cart.length > 0 && ( | |
<button | |
onClick={this.handleClearCart} | |
className="clear-cart-btn" | |
disabled={clearCartMutation.isPending} | |
> | |
{clearCartMutation.isPending ? 'Очистка...' : 'Очистить корзину'} | |
</button> | |
)} | |
</div> | |
{cart.length === 0 ? ( | |
<div className="empty-cart"> | |
<p>Корзина пуста</p> | |
<p className="empty-cart-hint">Добавьте товары из каталога</p> | |
</div> | |
) : ( | |
<> | |
<div className="cart-items"> | |
{cart.map(item => ( | |
<div key={item.id} className="cart-item"> | |
<div className="cart-item-image"> | |
<img src={item.image} alt={item.title}/> | |
</div> | |
<div className="cart-item-info"> | |
<h4 title={item.title}> | |
{truncateText(item.title, 40)} | |
</h4> | |
<p className="item-price">${formatPrice(item.price)}</p> | |
</div> | |
<div className="cart-item-controls"> | |
<div className="quantity-controls"> | |
<button | |
onClick={() => this.handleQuantityChange(item.id, item.quantity - 1)} | |
disabled={updateQuantityMutation.isPending} | |
className="quantity-btn" | |
> | |
- | |
</button> | |
<span className="quantity">{item.quantity}</span> | |
<button | |
onClick={() => this.handleQuantityChange(item.id, item.quantity + 1)} | |
disabled={updateQuantityMutation.isPending} | |
className="quantity-btn" | |
> | |
+ | |
</button> | |
</div> | |
<div className="item-total"> | |
${formatPrice(item.price * item.quantity)} | |
</div> | |
<button | |
className="remove-item-btn" | |
onClick={() => this.handleRemoveItem(item.id)} | |
disabled={removeItemMutation.isPending} | |
title="Удалить товар" | |
> | |
🗑️ | |
</button> | |
</div> | |
</div> | |
))} | |
</div> | |
<div className="cart-summary"> | |
<div className="total"> | |
<strong>Итого: ${formatPrice(total)}</strong> | |
</div> | |
<button className="checkout-btn"> | |
Оформить заказ | |
</button> | |
</div> | |
</> | |
)} | |
</div> | |
); | |
} | |
} | |
// оборачиваем компоненты в HOC для работы с react-query | |
const ProductListWithQuery = withQuery(withProductsQuery( | |
React.forwardRef((props, ref) => <ProductList {...props} ref={ref}/>) | |
)); | |
const CartWithQuery = withQuery(withCartQuery( | |
React.forwardRef((props, ref) => <Cart {...props} ref={ref}/>) | |
)); | |
class AppContent extends Component { | |
constructor(props) { | |
super(props); | |
this.state = { | |
hasInitialized: false | |
}; | |
// создаём refs для компонентов | |
this.productListRef = React.createRef(); | |
this.cartRef = React.createRef(); | |
} | |
componentDidMount() { | |
// имитируем инициализацию приложения | |
this.setState({hasInitialized: true}); | |
} | |
render() { | |
if (!this.state.hasInitialized) { | |
return <LoadingSpinner/>; | |
} | |
return ( | |
<ErrorBoundary> | |
<div className="app"> | |
<header className="app-header"> | |
<h1>🛒 Интернет-магазин ReactExpress</h1> | |
<p className="app-subtitle">Качественные товары по доступным ценам</p> | |
</header> | |
<main className="app-main"> | |
<ErrorBoundary> | |
<ProductListWithQuery | |
componentRef={this.productListRef} | |
ref={this.productListRef} | |
/> | |
</ErrorBoundary> | |
<ErrorBoundary> | |
<CartWithQuery | |
componentRef={this.cartRef} | |
ref={this.cartRef} | |
/> | |
</ErrorBoundary> | |
</main> | |
</div> | |
</ErrorBoundary> | |
); | |
} | |
} | |
class App extends Component { | |
render() { | |
return ( | |
<QueryClientProvider client={queryClient}> | |
<AppContent/> | |
</QueryClientProvider> | |
); | |
} | |
} | |
export default App; | |
=================================================================================================================== | |
App.css: | |
.app { | |
max-width: 1400px; | |
margin: 0 auto; | |
padding: 20px; | |
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | |
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
min-height: 100vh; | |
} | |
.app-header { | |
text-align: center; | |
margin-bottom: 40px; | |
padding: 30px 0; | |
background: rgba(255, 255, 255, 0.1); | |
border-radius: 20px; | |
backdrop-filter: blur(10px); | |
border: 1px solid rgba(255, 255, 255, 0.2); | |
} | |
.app-header h1 { | |
color: #ffffff; | |
font-size: 3em; | |
font-weight: 700; | |
margin: 0 0 10px 0; | |
text-shadow: 2px 2px 8px rgba(0, 0, 0, 0.3); | |
letter-spacing: -1px; | |
} | |
.app-subtitle { | |
color: rgba(255, 255, 255, 0.9); | |
font-size: 1.2em; | |
margin: 0; | |
font-weight: 300; | |
} | |
.app-main { | |
display: grid; | |
grid-template-columns: 2fr 1fr; | |
gap: 30px; | |
align-items: start; | |
} | |
@media (max-width: 1024px) { | |
.app-main { | |
grid-template-columns: 1fr; | |
gap: 20px; | |
} | |
} | |
.product-list { | |
background: rgba(255, 255, 255, 0.95); | |
border-radius: 20px; | |
padding: 30px; | |
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1); | |
backdrop-filter: blur(10px); | |
} | |
.product-header { | |
display: flex; | |
justify-content: space-between; | |
align-items: center; | |
margin-bottom: 30px; | |
flex-wrap: wrap; | |
gap: 20px; | |
} | |
.product-header h2 { | |
color: #2c3e50; | |
margin: 0; | |
font-size: 2em; | |
font-weight: 600; | |
} | |
.search-container { | |
flex: 1; | |
max-width: 300px; | |
min-width: 200px; | |
} | |
.search-input { | |
width: 100%; | |
padding: 12px 10px; | |
border: 2px solid #e0e0e0; | |
border-radius: 25px; | |
font-size: 1em; | |
transition: all 0.3s ease; | |
background: #f8f9fa; | |
} | |
.search-input:focus { | |
outline: none; | |
border-color: #667eea; | |
background: #ffffff; | |
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); | |
} | |
.products { | |
display: grid; | |
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); | |
gap: 25px; | |
} | |
.product-card { | |
background: #ffffff; | |
border-radius: 16px; | |
padding: 20px; | |
text-align: center; | |
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.08); | |
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); | |
border: 1px solid rgba(0, 0, 0, 0.05); | |
display: flex; | |
flex-direction: column; | |
height: 420px; | |
} | |
.product-card:hover { | |
transform: translateY(-8px) scale(1.02); | |
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.15); | |
} | |
.product-image-container { | |
height: 180px; | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
margin-bottom: 15px; | |
background: #f8f9fa; | |
border-radius: 12px; | |
overflow: hidden; | |
} | |
.product-image { | |
max-width: 100%; | |
max-height: 100%; | |
object-fit: contain; | |
transition: transform 0.3s ease; | |
} | |
.product-card:hover .product-image { | |
transform: scale(1.05); | |
} | |
.product-info { | |
flex: 1; | |
display: flex; | |
flex-direction: column; | |
justify-content: space-between; | |
} | |
.product-info h3 { | |
color: #2c3e50; | |
margin: 0 0 8px 0; | |
font-size: 1.1em; | |
font-weight: 600; | |
line-height: 1.3; | |
min-height: 2.6em; | |
} | |
.product-category { | |
color: #7f8c8d; | |
font-size: 0.9em; | |
margin: 0 0 10px 0; | |
text-transform: capitalize; | |
font-weight: 500; | |
} | |
.price { | |
color: #e74c3c; | |
font-weight: 700; | |
font-size: 1.4em; | |
margin: 10px 0 15px 0; | |
} | |
.add-to-cart { | |
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
color: white; | |
border: none; | |
padding: 12px 24px; | |
cursor: pointer; | |
border-radius: 25px; | |
font-size: 1em; | |
font-weight: 600; | |
transition: all 0.3s ease; | |
text-transform: uppercase; | |
letter-spacing: 0.5px; | |
} | |
.add-to-cart:hover:not(:disabled) { | |
transform: translateY(-2px); | |
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4); | |
} | |
.add-to-cart:disabled { | |
opacity: 0.7; | |
cursor: not-allowed; | |
} | |
.no-results { | |
text-align: center; | |
padding: 60px 20px; | |
color: #7f8c8d; | |
} | |
.no-results p { | |
font-size: 1.2em; | |
margin-bottom: 20px; | |
} | |
.clear-search { | |
background: #3498db; | |
color: white; | |
border: none; | |
padding: 10px 20px; | |
border-radius: 20px; | |
cursor: pointer; | |
transition: background 0.3s ease; | |
} | |
.clear-search:hover { | |
background: #2980b9; | |
} | |
.cart { | |
background: rgba(255, 255, 255, 0.95); | |
border-radius: 20px; | |
padding: 30px; | |
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1); | |
backdrop-filter: blur(10px); | |
position: sticky; | |
top: 20px; | |
max-height: calc(100vh - 40px); | |
overflow-y: auto; | |
} | |
.cart-header { | |
display: flex; | |
justify-content: space-between; | |
align-items: center; | |
margin-bottom: 25px; | |
flex-wrap: wrap; | |
gap: 15px; | |
} | |
.cart-header h2 { | |
color: #2c3e50; | |
margin: 0; | |
font-size: 1.8em; | |
font-weight: 600; | |
} | |
.clear-cart-btn { | |
background: #e74c3c; | |
color: white; | |
border: none; | |
padding: 8px 16px; | |
border-radius: 20px; | |
cursor: pointer; | |
font-size: 0.9em; | |
transition: all 0.3s ease; | |
} | |
.clear-cart-btn:hover:not(:disabled) { | |
background: #c0392b; | |
transform: translateY(-1px); | |
} | |
.clear-cart-btn:disabled { | |
opacity: 0.7; | |
cursor: not-allowed; | |
} | |
.empty-cart { | |
text-align: center; | |
padding: 40px 20px; | |
color: #7f8c8d; | |
} | |
.empty-cart p { | |
margin: 0 0 10px 0; | |
font-size: 1.1em; | |
} | |
.empty-cart-hint { | |
font-size: 0.9em; | |
opacity: 0.7; | |
} | |
.cart-items { | |
margin-bottom: 25px; | |
} | |
.cart-item { | |
display: grid; | |
grid-template-columns: 60px 1fr auto; | |
gap: 15px; | |
align-items: center; | |
padding: 15px 0; | |
border-bottom: 1px solid #ecf0f1; | |
} | |
.cart-item:last-child { | |
border-bottom: none; | |
} | |
.cart-item-image { | |
width: 60px; | |
height: 60px; | |
border-radius: 8px; | |
overflow: hidden; | |
background: #f8f9fa; | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
} | |
.cart-item-image img { | |
width: 100%; | |
height: 100%; | |
object-fit: contain; | |
} | |
.cart-item-info h4 { | |
margin: 0 0 5px 0; | |
color: #2c3e50; | |
font-size: 0.95em; | |
font-weight: 600; | |
line-height: 1.3; | |
} | |
.item-price { | |
margin: 0; | |
color: #7f8c8d; | |
font-size: 0.9em; | |
} | |
.cart-item-controls { | |
display: flex; | |
flex-direction: column; | |
align-items: center; | |
gap: 8px; | |
} | |
.quantity-controls { | |
display: flex; | |
align-items: center; | |
gap: 8px; | |
background: #f8f9fa; | |
border-radius: 20px; | |
padding: 4px; | |
} | |
.quantity-btn { | |
width: 28px; | |
height: 28px; | |
border: none; | |
background: #ffffff; | |
border-radius: 50%; | |
cursor: pointer; | |
font-weight: bold; | |
color: #667eea; | |
transition: all 0.2s ease; | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
} | |
.quantity-btn:hover:not(:disabled) { | |
background: #667eea; | |
color: white; | |
transform: scale(1.1); | |
} | |
.quantity-btn:disabled { | |
opacity: 0.5; | |
cursor: not-allowed; | |
} | |
.quantity { | |
min-width: 30px; | |
text-align: center; | |
font-weight: 600; | |
color: #2c3e50; | |
} | |
.item-total { | |
font-weight: 700; | |
color: #e74c3c; | |
font-size: 0.9em; | |
} | |
.remove-item-btn { | |
background: none; | |
border: none; | |
cursor: pointer; | |
font-size: 1.2em; | |
padding: 4px; | |
border-radius: 4px; | |
transition: all 0.2s ease; | |
opacity: 0.6; | |
} | |
.remove-item-btn:hover:not(:disabled) { | |
opacity: 1; | |
transform: scale(1.2); | |
background: rgba(231, 76, 60, 0.1); | |
} | |
.remove-item-btn:disabled { | |
opacity: 0.3; | |
cursor: not-allowed; | |
} | |
.cart-summary { | |
border-top: 2px solid #ecf0f1; | |
padding-top: 20px; | |
display: flex; | |
flex-direction: column; | |
gap: 15px; | |
} | |
.total { | |
font-size: 1.4em; | |
font-weight: 700; | |
color: #2c3e50; | |
text-align: right; | |
} | |
.checkout-btn { | |
background: linear-gradient(135deg, #2ecc71 0%, #27ae60 100%); | |
color: white; | |
border: none; | |
padding: 15px 30px; | |
border-radius: 25px; | |
font-size: 1.1em; | |
font-weight: 600; | |
cursor: pointer; | |
transition: all 0.3s ease; | |
text-transform: uppercase; | |
letter-spacing: 1px; | |
} | |
.checkout-btn:hover { | |
transform: translateY(-2px); | |
box-shadow: 0 5px 15px rgba(46, 204, 113, 0.4); | |
} | |
.notification { | |
position: fixed; | |
top: 20px; | |
right: 20px; | |
padding: 15px 20px; | |
border-radius: 10px; | |
color: white; | |
font-weight: 600; | |
z-index: 1000; | |
animation: slideIn 0.3s ease; | |
display: flex; | |
align-items: center; | |
justify-content: space-between; | |
gap: 15px; | |
min-width: 300px; | |
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.2); | |
} | |
.notification-success { | |
background: linear-gradient(135deg, #2ecc71, #27ae60); | |
} | |
.notification-error { | |
background: linear-gradient(135deg, #e74c3c, #c0392b); | |
} | |
.notification-close { | |
background: none; | |
border: none; | |
color: white; | |
font-size: 1.2em; | |
cursor: pointer; | |
padding: 0; | |
width: 20px; | |
height: 20px; | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
border-radius: 50%; | |
transition: background 0.2s ease; | |
} | |
.notification-close:hover { | |
background: rgba(255, 255, 255, 0.2); | |
} | |
@keyframes slideIn { | |
from { | |
transform: translateX(100%); | |
opacity: 0; | |
} | |
to { | |
transform: translateX(0); | |
opacity: 1; | |
} | |
} | |
.loading-spinner { | |
text-align: center; | |
padding: 40px 20px; | |
color: #7f8c8d; | |
font-size: 1.1em; | |
} | |
.loading-medium { | |
padding: 60px 20px; | |
font-size: 1.3em; | |
} | |
.loading-small { | |
padding: 20px; | |
font-size: 1em; | |
} | |
.error { | |
text-align: center; | |
padding: 40px 20px; | |
color: #e74c3c; | |
font-size: 1.1em; | |
background: rgba(231, 76, 60, 0.1); | |
border-radius: 10px; | |
border: 1px solid rgba(231, 76, 60, 0.2); | |
} | |
@media (max-width: 768px) { | |
.app { | |
padding: 15px; | |
} | |
.app-header h1 { | |
font-size: 2.2em; | |
} | |
.app-subtitle { | |
font-size: 1em; | |
} | |
.product-list, | |
.cart { | |
padding: 20px; | |
} | |
.product-header { | |
flex-direction: column; | |
align-items: stretch; | |
} | |
.search-container { | |
max-width: none; | |
} | |
.products { | |
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); | |
gap: 20px; | |
} | |
.product-card { | |
height: auto; | |
min-height: 380px; | |
} | |
.cart { | |
position: static; | |
max-height: none; | |
} | |
.cart-header { | |
flex-direction: column; | |
align-items: stretch; | |
} | |
.cart-item { | |
grid-template-columns: 50px 1fr; | |
gap: 10px; | |
} | |
.cart-item-controls { | |
grid-column: 1 / -1; | |
flex-direction: row; | |
justify-content: space-between; | |
margin-top: 10px; | |
} | |
.notification { | |
left: 15px; | |
right: 15px; | |
min-width: auto; | |
} | |
} | |
@media (max-width: 480px) { | |
.app-header h1 { | |
font-size: 1.8em; | |
} | |
.products { | |
grid-template-columns: 1fr; | |
} | |
.product-header h2 { | |
font-size: 1.5em; | |
} | |
.cart-header h2 { | |
font-size: 1.5em; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment