Розробка блоку «Нещодавно переглянуті» для інтернет-магазину
Уявіть: користувач переглядає товар за товаром, але при поверненні на сторінку категорії не може знайти той самий кросівок або кавоварку. Історія переглядів — це не просто фіча, а інструмент повернення. Однак її реалізація часто страждає від дублювання в масиві 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 проектів з історією переглядів. Гарантуємо коректну роботу на всіх етапах. Зв'яжіться з нами, щоб обговорити ваш проект — оцінимо його за день. Замовте реалізацію блоку «Нещодавно переглянуті» і отримайте приріст конверсії без складних інтеграцій.







