Разработка блока «Недавно просмотренные» для интернет-магазина
Блок недавно просмотренных товаров помогает пользователю вернуться к изученным позициям без поиска по каталогу. Это один из простейших элементов персонализации с минимальной стоимостью разработки и измеримым эффектом на возвраты к карточкам товара. Разработка занимает 1–2 рабочих дня.
Хранение истории просмотров
Для гостей история хранится в localStorage. Для авторизованных — опционально синхронизируется с сервером:
// 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):
// В компоненте страницы товара
const { addProduct } = useRecentlyViewed();
useEffect(() => { addProduct(product.id); }, [product.id]);
Серверная синхронизация для авторизованных
Для авторизованных пользователей история синхронизируется с сервером — чтобы сохранялась между устройствами:
// 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)]);
}
Синхронизация — при логине (мерж localStorage с серверной историей) и при закрытии вкладки (beforeunload + navigator.sendBeacon).
Загрузка данных о товарах
История хранит только product_id. Для отображения блока нужны данные — один запрос к API:
const RecentlyViewedBlock = () => {
const { productIds } = useRecentlyViewed();
const visibleIds = productIds.slice(0, 8); // показываем не более 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 для batch-загрузки
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');
// Возвращаем в порядке переданных id
$ordered = collect($ids)->map(fn($id) => $products->get($id))->filter()->values();
return response()->json(ProductCardResource::collection($ordered));
}
Где размещать блок
- Главная страница: для вернувшихся пользователей — вместо или рядом с популярными товарами
- Страница категории: в нижней части, после основной сетки товаров
- Страница товара: под блоком «Похожие товары»
- Корзина: в боковой панели на десктопе
- Пустые результаты поиска: «Может, вы ищете что-то из просмотренного?»
Исключение текущего товара
На странице товара X из блока «Недавно просмотренные» исключается сам товар X — иначе он неизбежно окажется первым в списке:
const productIds = useRecentlyViewed().productIds.filter(id => id !== currentProductId);
Приватность
Кнопка «Очистить историю» даёт пользователю контроль над данными. Срок хранения в localStorage — не ограничен браузером, но можно добавить TTL вручную:
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const { items, savedAt } = JSON.parse(stored);
const isExpired = Date.now() - savedAt > 30 * 24 * 60 * 60 * 1000; // 30 дней
if (isExpired) localStorage.removeItem(STORAGE_KEY);
}







