Skip to content

Instantly share code, notes, and snippets.

@sunmeat
Created June 9, 2025 08:09
Show Gist options
  • Save sunmeat/69928361e5087fef422234cf55344542 to your computer and use it in GitHub Desktop.
Save sunmeat/69928361e5087fef422234cf55344542 to your computer and use it in GitHub Desktop.
интернет магазин на классовых компонентах
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