Розробка блоку «Нещодавно переглянуті» для інтернет-магазину
Уявіть: користувач переглядає товар за товаром, але при поверненні на сторінку категорії не може знайти той самий кросівок або кавоварку. Історія переглядів — це не просто фіча, а інструмент повернення. Однак її реалізація часто страждає від дублювання в масиві ID, N+1 запитів і проблем з порядком товарів. Ми інтегруємо блок «Нещодавно переглянуті» з клієнтським кешуванням у localStorage для гостей і серверною синхронізацією для авторизованих користувачів, використовуючи один batch-запит для завантаження даних. Розробка займає 1–2 дні, а повернення до карток зростають до 15%. Вартість такої реалізації розраховується індивідуально залежно від складності інтеграції. Зв'яжіться з нами — оцінимо ваш проект за день.
Зберігання історії в localStorage
Для неавторизованих користувачів історія зберігається в localStorage. Ми використовуємо хук useRecentlyViewed, який при кожному перегляді додає ID товару на початок масиву, видаляючи дублікати. Максимум — 20 записів. Хоча localStorage має ліміт 5-10 МБ, типовий розмір масиву не перевищує 1-2 КБ. Ось реалізація:
// hooks/useRecentlyViewed.ts
const STORAGE_KEY = 'recently_viewed';
const MAX_ITEMS = 20;
export const useRecentlyViewed = () => {
const [items, setItems] = useState<number[]>(() => {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
});
const addProduct = useCallback((productId: number) => {
setItems(prev => {
const filtered = prev.filter(id => id !== productId);
const updated = [productId, ...filtered].slice(0, MAX_ITEMS);
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
return updated;
});
}, []);
const clearHistory = useCallback(() => {
localStorage.removeItem(STORAGE_KEY);
setItems([]);
}, []);
return { productIds: items, addProduct, clearHistory };
};На сторінці товару викликаємо addProduct(product.id).
Серверна синхронізація для авторизованих
Якщо користувач авторизований, історія синхронізується з сервером через Sanctum. При вході ми зливаємо localStorage з серверною історією, при закритті вкладки відправляємо поточний стан через navigator.sendBeacon. Ендпоінт для синхронізації:
// routes
Route::middleware('auth:sanctum')->post('/me/recently-viewed', [RecentlyViewedController::class, 'sync']);
Route::middleware('auth:sanctum')->get('/me/recently-viewed', [RecentlyViewedController::class, 'index']);
public function sync(Request $request): JsonResponse
{
$request->validate([
'product_ids' => 'required|array|max:20',
'product_ids.*' => 'integer'
]);
$user = $request->user();
// Оновлюємо порядок: переданий список — актуальний стан
$user->recentlyViewed()->sync(
collect($request->product_ids)->mapWithKeys(fn($id, $pos) => [
$id => ['position' => $pos, 'viewed_at' => now()]
])
);
return response()->json(['synced' => count($request->product_ids)]);
}Такий підхід зберігає історію між пристроями і не втрачає дані при зміні браузера.
Batch-завантаження даних
Історія зберігає лише product_id. Для відображення блоку потрібні дані — один batch-запит до API. Ми використовуємо React Query з staleTime 5 хвилин, щоб не перевантажувати сервер при повторних відвідуваннях. Такий підхід зменшує запити на 80%:
const RecentlyViewedBlock = () => {
const { productIds } = useRecentlyViewed();
const visibleIds = productIds.slice(0, 8);
const { data: products } = useQuery({
queryKey: ['recently-viewed-products', visibleIds],
queryFn: () => api.get('/products/batch', { params: { ids: visibleIds.join(',') } }),
enabled: visibleIds.length > 0,
staleTime: 300_000,
});
if (!products?.length) return null;
// Зберігаємо порядок з історії
const ordered = visibleIds
.map(id => products.find((p: Product) => p.id === id))
.filter(Boolean);
return (
<section>
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold">Ви нещодавно дивилися</h2>
<button onClick={clearHistory} className="text-sm text-gray-400 hover:text-gray-600">Очистити історію</button>
</div>
<ProductCarousel products={ordered} />
</section>
);
};Endpoint завантажує рівно ті поля, які потрібні для картки, і повертає товари в порядку переданих ID:
public function batch(Request $request): JsonResponse
{
$request->validate(['ids' => 'required|string']);
$ids = array_filter(array_map('intval', explode(',', $request->ids)));
$ids = array_slice($ids, 0, 20);
$products = Product::whereIn('id', $ids)
->where('is_active', true)
->select(['id', 'name', 'slug', 'price', 'sale_price', 'rating_avg', 'rating_count'])
->with('thumbnail')
->get()
->keyBy('id');
$ordered = collect($ids)->map(fn($id) => $products->get($id))->filter()->values();
return response()->json(ProductCardResource::collection($ordered));
} Чому batch-завантаження швидше?
Якщо запитувати кожен товар окремо, отримаєте N запитів до БД — для 8 товарів це 8 раунд-трипів. Batch-ендпоінт повертає все за один раз, що знижує навантаження на сервер у 8 разів. На клієнті ми зберігаємо порядок через сортування за вихідним масивом ID. Batch-запит працює в 4–6 разів швидше за N+1. Такий підхід прискорює завантаження блоку в 3–5 разів, а блокові метрики (LCP, FID) не страждають. Наприклад, час завантаження блоку з 8 товарами зменшується з 1000 мс до 200 мс. Кешування з React Query краще за повторні запити в 3-5 разів.
| Підхід | Час завантаження (8 товарів) | Навантаження на сервер |
|---|---|---|
| N+1 запити | 800–1200 мс | 8 запитів |
| Batch-запит | 150–300 мс | 1 запит |
Як виключити поточний товар?
На сторінці товару X з блоку «Нещодавно переглянуті» виключається сам товар X — інакше він неминуче опиниться першим у списку. Робиться простим фільтром: productIds.filter(id => id !== currentProductId). Це запобігає ситуації, коли користувач бачить картку того ж товару поруч з основним контентом.
Порівняння методів синхронізації
| Метод | Гості | Авторизовані | Швидкість | Серверне навантаження |
|---|---|---|---|---|
| Тільки localStorage | Так | Ні | Миттєво | Немає |
| Серверна | Ні | Так | ~5 мс | Середнє |
| Гібрид (localStorage + сервер) | Так | Так | Миттєво + 5 мс | Низьке |
Гібридний підхід — оптимальний вибір: він поєднує швидкість локального сховища зі стійкістю серверного.
Де розміщувати блок
- Головна сторінка: для повернутих користувачів — замість або поруч з популярними товарами
- Сторінка категорії: у нижній частині, після основної сітки товарів
- Сторінка товару: під блоком «Схожі товари»
- Кошик: у бічній панелі на десктопі
- Порожні результати пошуку: «Можливо, ви шукаєте щось з переглянутого?»
Приватність і контроль
Кнопка «Очистити історію» дає користувачеві контроль над даними. Строк зберігання в localStorage можна обмежити, додавши TTL вручну — наприклад, 30 днів. Докладніше про localStorage читайте в документації MDN.
Типові помилки при реалізації
- Не виключають поточний товар з блоку — користувач бачить його першим.
- Використовують N+1 запити замість batch — завантаження блоку гальмує.
- Не очищають історію при вході — дані гостя змішуються з даними користувача.
- Синхронізують історію занадто часто — надлишкове навантаження на сервер.
Що входить в роботу
- Аналіз поточної архітектури та місць розміщення блоку.
- Реалізація хука
useRecentlyViewedз localStorage. - API-ендпоінти для серверної синхронізації (Laravel).
- Batch-завантаження даних з кешуванням на клієнті.
- UI-компонент блоку (карусель) з кнопкою очищення.
- Інструкція з деплою та тестування.
Наш досвід — понад 50 проектів з історією переглядів. Гарантуємо коректну роботу на всіх етапах. Зв'яжіться з нами, щоб обговорити ваш проект — оцінимо його за день. Замовте реалізацію блоку «Нещодавно переглянуті» і отримайте приріст конверсії без складних інтеграцій.







