Created
June 14, 2025 13:58
-
-
Save sunmeat/c487c25c334b7f3c78224ca884a20073 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 {useSelector, useDispatch, Provider} from 'react-redux'; | |
import {configureStore, createSlice} from '@reduxjs/toolkit'; | |
import {useState, useEffect} from 'react'; | |
import './App.css'; | |
// действия для асинхронного поиска товаров с использованием redux-thunk | |
export const fetchProducts = (searchQuery = '') => async (dispatch) => { | |
dispatch(setStatus('loading')); // установка статуса загрузки | |
dispatch(clearError()); // очистка ошибок | |
try { | |
const response = await fetch(`https://fakestoreapi.com/products`); | |
if (!response.ok) throw new Error('Ошибка загрузки товаров'); | |
const products = await response.json(); | |
// фильтрация на стороне клиента для поиска | |
const filteredProducts = products.filter(product => | |
product.title.toLowerCase().includes(searchQuery.toLowerCase()) | |
); | |
dispatch(setProducts(filteredProducts)); // сохранение отфильтрованных товаров | |
dispatch(setStatus('succeeded')); // установка статуса успеха | |
} catch (error) { | |
dispatch(setError(error.message)); // установка ошибки | |
dispatch(setStatus('failed')); // установка статуса ошибки | |
} | |
}; | |
// создание среза (slice) для управления корзиной и товарами | |
const cartSlice = createSlice({ | |
name: 'cart', // имя среза | |
initialState: { // начальное состояние хранилища | |
products: [], // список доступных товаров | |
cartItems: [], // товары в корзине | |
status: 'idle', // статус загрузки (idle, loading, succeeded, failed) | |
error: null, // сообщение об ошибке | |
}, | |
reducers: { // редьюсеры для обработки действий | |
setProducts: (state, action) => { // установка списка товаров | |
state.products = action.payload; | |
}, | |
setStatus: (state, action) => { // установка статуса загрузки | |
state.status = action.payload; | |
}, | |
setError: (state, action) => { // установка ошибки | |
state.error = action.payload; | |
}, | |
clearError: (state) => { // очистка ошибки | |
state.error = null; | |
}, | |
addToCart: (state, action) => { // добавление товара в корзину | |
const {id, title, price} = action.payload; | |
const cartItem = state.cartItems.find(item => item.id === id); | |
const product = state.products.find(p => p.id === id); | |
if (product) { | |
if (cartItem) { | |
cartItem.quantity += 1; | |
} else { | |
state.cartItems.push({id, title, price, quantity: 1}); | |
} | |
} | |
}, | |
removeFromCart: (state, action) => { // удаление товара из корзины | |
state.cartItems = state.cartItems.filter(item => item.id !== action.payload); | |
}, | |
updateQuantity: (state, action) => { // изменение количества товара | |
const {id, quantity} = action.payload; | |
const cartItem = state.cartItems.find(item => item.id === id); | |
if (cartItem && quantity > 0) { | |
cartItem.quantity = quantity; | |
} | |
}, | |
clearCart: (state) => { // очистка корзины | |
state.cartItems = []; | |
}, | |
}, | |
}); | |
// извлечение действий для упрощения их использования | |
const { | |
setProducts, | |
setStatus, | |
setError, | |
clearError, | |
addToCart, | |
removeFromCart, | |
updateQuantity, | |
clearCart | |
} = cartSlice.actions; | |
// настройка хранилища redux с поддержкой redux-thunk | |
const store = configureStore({ | |
reducer: { | |
cart: cartSlice.reducer, // редьюсер для корзины | |
}, | |
middleware: (getDefaultMiddleware) => getDefaultMiddleware(), // включение thunk по умолчанию | |
}); | |
// компонент поиска и списка товаров | |
function ProductList() { | |
const dispatch = useDispatch(); // хук для отправки действий в redux | |
const products = useSelector((state) => state.cart.products); // хук для получения товаров | |
const cartItems = useSelector((state) => state.cart.cartItems); // хук для получения корзины | |
const status = useSelector((state) => state.cart.status); // хук для получения статуса | |
const error = useSelector((state) => state.cart.error); // хук для получения ошибки | |
const [searchQuery, setSearchQuery] = useState(''); // локальное состояние для поиска | |
useEffect(() => { | |
dispatch(fetchProducts(searchQuery)); // загрузка товаров при изменении запроса | |
}, [dispatch, searchQuery]); | |
const handleSearch = (e) => { | |
setSearchQuery(e.target.value); // обновление поискового запроса | |
}; | |
const getAvailableStock = (productId) => { | |
const cartItem = cartItems.find(item => item.id === productId); | |
return cartItem ? 10 - cartItem.quantity : 10; // предполагаем максимум 10 единиц на товар | |
}; | |
return ( | |
<div className="product-list"> | |
<h2 className="section-title">Поиск товаров</h2> | |
<input | |
className="search-input" | |
type="text" | |
value={searchQuery} | |
onChange={handleSearch} | |
placeholder="Введите название товара..." | |
/> | |
{status === 'loading' && <p className="loading">Загрузка...</p>} | |
{error && <p className="error">{error}</p>} | |
{status === 'succeeded' && products.length === 0 && <p className="empty">Товары не найдены</p>} | |
<ul className="product-grid"> | |
{products.map(product => ( | |
<li key={product.id} className="product-item"> | |
<span className="product-name">{product.title}</span> | |
<span className="product-price">{product.price} $</span> | |
<span className="product-stock">Остаток: {getAvailableStock(product.id)}</span> | |
<button | |
className="cart-button" | |
onClick={() => dispatch(addToCart({ | |
id: product.id, | |
title: product.title, | |
price: product.price | |
}))} | |
disabled={getAvailableStock(product.id) === 0} | |
> | |
Добавить в корзину | |
</button> | |
</li> | |
))} | |
</ul> | |
</div> | |
); | |
} | |
// компонент корзины | |
function Cart() { | |
const dispatch = useDispatch(); // хук для отправки действий в redux | |
const cartItems = useSelector((state) => state.cart.cartItems); // хук для получения корзины | |
const total = cartItems.reduce((sum, item) => sum + item.price * item.quantity, 0); | |
const getMaxQuantity = (itemId) => { | |
return 10; // предполагаем максимум 10 единиц на товар | |
}; | |
return ( | |
<div className="cart"> | |
<h2 className="section-title">Корзина</h2> | |
{cartItems.length === 0 ? ( | |
<p className="empty">Корзина пуста</p> | |
) : ( | |
<> | |
<ul className="cart-list"> | |
{cartItems.map(item => ( | |
<li key={item.id} className="cart-item"> | |
<span className="cart-item-name">{item.title}</span> | |
<span className="cart-item-price">{item.price} $ x {item.quantity}</span> | |
<div className="cart-item-controls"> | |
<input | |
type="number" | |
className="cart-item-quantity" | |
value={item.quantity} | |
min="1" | |
max={getMaxQuantity(item.id)} | |
onChange={(e) => dispatch(updateQuantity({ | |
id: item.id, | |
quantity: parseInt(e.target.value) || 1 | |
}))} | |
/> | |
<button | |
className="cart-button remove" | |
onClick={() => dispatch(removeFromCart(item.id))} | |
> | |
Удалить | |
</button> | |
</div> | |
</li> | |
))} | |
</ul> | |
<div className="cart-total"> | |
<span>Итого: {total.toFixed(2)} $</span> | |
<button | |
className="cart-button clear" | |
onClick={() => dispatch(clearCart())} | |
> | |
Очистить корзину | |
</button> | |
</div> | |
</> | |
)} | |
</div> | |
); | |
} | |
// компонент магазина | |
function ShopApp() { | |
return ( | |
<div className="shop-app"> | |
<h1 className="app-title">ReactExpress</h1> | |
<ProductList/> | |
<Cart/> | |
</div> | |
); | |
} | |
// корневой компонент с провайдером redux | |
function App() { | |
return ( | |
<Provider store={store}> | |
<ShopApp/> | |
</Provider> | |
); | |
} | |
export default App; | |
======================================================================================================================== | |
App.css: | |
@import url('https://fonts.googleapis.com/css2?family=Segoe+UI:wght@400;500;600;700&display=swap'); | |
:root { | |
--bg-gradient-start: #1c2526; | |
--bg-gradient-end: #2c3e50; | |
--text-color: #00ffcc; | |
--app-bg-start: rgba(20, 30, 40, 0.85); | |
--app-bg-end: rgba(30, 40, 50, 0.85); | |
--input-bg: rgba(0, 150, 136, 0.3); | |
--input-focus-bg: rgba(0, 170, 150, 0.4); | |
--placeholder-color: #26a69a; | |
--button-bg: linear-gradient(90deg, #00ffcc, #26a69a); | |
--button-hover: linear-gradient(90deg, #00e6b8, #219187); | |
--error-color: #ff4081; | |
--border-glow: rgba(0, 255, 204, 0.5); | |
--shadow-color: rgba(0, 0, 0, 0.4); | |
--item-bg: rgba(0, 150, 136, 0.2); | |
} | |
* { | |
margin: 0; | |
padding: 0; | |
box-sizing: border-box; | |
} | |
body { | |
background: linear-gradient(135deg, var(--bg-gradient-start), var(--bg-gradient-end)); | |
color: var(--text-color); | |
font-family: 'Segoe UI', sans-serif; | |
min-height: 100vh; | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
padding: 20px; | |
} | |
.shop-app { | |
max-width: 800px; | |
width: 100%; | |
background: linear-gradient(145deg, var(--app-bg-start), var(--app-bg-end)); | |
backdrop-filter: blur(15px); | |
padding: 30px; | |
border-radius: 12px; | |
box-shadow: 0 10px 40px var(--shadow-color); | |
border: 1px solid var(--border-glow); | |
animation: fadeIn 0.5s ease-in-out; | |
} | |
@keyframes fadeIn { | |
from { | |
opacity: 0; | |
transform: translateY(-20px); | |
} | |
to { | |
opacity: 1; | |
transform: translateY(0); | |
} | |
} | |
.app-title { | |
text-align: center; | |
color: var(--text-color); | |
font-size: 2.5em; | |
font-weight: 600; | |
margin-bottom: 30px; | |
text-shadow: 0 2px 6px var(--shadow-color); | |
} | |
.product-list, | |
.cart { | |
margin-bottom: 40px; | |
} | |
.section-title { | |
color: var(--text-color); | |
font-size: 1.8em; | |
font-weight: 600; | |
margin-bottom: 20px; | |
text-shadow: 0 2px 4px var(--shadow-color); | |
} | |
.search-input { | |
width: 100%; | |
padding: 12px 16px; | |
border: none; | |
border-radius: 6px; | |
background: var(--input-bg); | |
color: var(--text-color); | |
font-size: 1.1em; | |
margin-bottom: 20px; | |
transition: all 0.3s ease; | |
box-shadow: inset 0 2px 4px var(--shadow-color); | |
} | |
.search-input::placeholder { | |
color: var(--placeholder-color); | |
} | |
.search-input:focus { | |
outline: none; | |
background: var(--input-focus-bg); | |
box-shadow: 0 0 0 3px var(--border-glow); | |
} | |
.product-grid, | |
.cart-list { | |
list-style: none; | |
padding: 0; | |
} | |
.product-item, | |
.cart-item { | |
display: flex; | |
justify-content: space-between; | |
align-items: center; | |
padding: 15px; | |
background: var(--item-bg); | |
border-radius: 8px; | |
margin-bottom: 12px; | |
border: 1px solid var(--border-glow); | |
transition: all 0.3s ease; | |
} | |
.product-item:hover, | |
.cart-item:hover { | |
transform: translateY(-2px); | |
box-shadow: 0 6px 16px var(--shadow-color); | |
} | |
.product-name, | |
.cart-item-name { | |
flex: 1; | |
font-size: 1.1em; | |
} | |
.product-price, | |
.cart-item-price, | |
.product-stock { | |
font-size: 1em; | |
margin-right: 20px; | |
} | |
.cart-item-controls { | |
display: flex; | |
gap: 10px; | |
align-items: center; | |
} | |
.cart-item-quantity { | |
width: 60px; | |
padding: 8px; | |
border: none; | |
border-radius: 6px; | |
background: var(--input-bg); | |
color: var(--text-color); | |
font-size: 1em; | |
text-align: center; | |
transition: all 0.3s ease; | |
} | |
.cart-item-quantity:focus { | |
outline: none; | |
background: var(--input-focus-bg); | |
box-shadow: 0 0 0 3px var(--border-glow); | |
} | |
.cart-button { | |
padding: 10px 20px; | |
background: var(--button-bg); | |
color: #ffffff; | |
border: none; | |
border-radius: 6px; | |
cursor: pointer; | |
font-size: 1em; | |
font-weight: 500; | |
transition: all 0.3s ease; | |
box-shadow: 0 4px 12px var(--shadow-color); | |
} | |
.cart-button:hover { | |
background: var(--button-hover); | |
transform: translateY(-2px); | |
box-shadow: 0 6px 16px var(--shadow-color); | |
} | |
.cart-button:active { | |
transform: scale(0.95); | |
} | |
.cart-button:disabled { | |
background: linear-gradient(90deg, #ff4081, #f06292); | |
cursor: not-allowed; | |
transform: none; | |
box-shadow: none; | |
} | |
.cart-button.remove { | |
background: linear-gradient(90deg, #ff1744, #ff4081); | |
} | |
.cart-button.remove:hover { | |
background: linear-gradient(90deg, #e6153d, #e63874); | |
} | |
.cart-button.clear { | |
background: linear-gradient(90deg, #ff1744, #ff4081); | |
display: block; | |
margin: 20px auto 0; | |
} | |
.cart-button.clear:hover { | |
background: linear-gradient(90deg, #e6153d, #e63874); | |
} | |
.cart-total { | |
text-align: right; | |
font-size: 1.2em; | |
margin-top: 20px; | |
} | |
.loading, | |
.error, | |
.empty { | |
text-align: center; | |
font-size: 1.1em; | |
margin: 20px 0; | |
} | |
.error { | |
color: var(--error-color); | |
} | |
@media (max-width: 600px) { | |
.shop-app { | |
padding: 20px; | |
} | |
.product-item, | |
.cart-item { | |
flex-direction: column; | |
align-items: flex-start; | |
gap: 10px; | |
} | |
.product-price, | |
.cart-item-price, | |
.product-stock { | |
margin-right: 0; | |
} | |
.cart-item-controls { | |
width: 100%; | |
justify-content: space-between; | |
} | |
.cart-button { | |
width: 100%; | |
text-align: center; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment