Skip to content

Instantly share code, notes, and snippets.

@sunmeat
Created June 14, 2025 13:58
Show Gist options
  • Save sunmeat/c487c25c334b7f3c78224ca884a20073 to your computer and use it in GitHub Desktop.
Save sunmeat/c487c25c334b7f3c78224ca884a20073 to your computer and use it in GitHub Desktop.
асинхронный поиск на редаксе
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