בניית תכונת היסטוריית צפייה ללקוחות בחנות המסחר האלקטרוני שלך
תארו לעצמכם משתמש שגולש בין מוצרים, אבל כשהם חוזרים לדף הקטגוריה, הם לא מוצאים את נעלי הספורט או מכונת הקפה ההיא. היסטוריית צפייה היא לא רק תכונה—זה כלי לשימור לקוחות. עם זאת, היישום שלה סובל לעיתים קרובות ממזהים כפולים במערכים, שאילתות N+1, ובעיות סדר. אנו משלבים בלוק 'נצפה לאחרונה' עם שמירה במטמון בצד הלקוח ב-localStorage עבור אורחים וסנכרון שרת עבור משתמשים מחוברים, תוך שימוש בבקשה אחת מרוכזת לטעינת נתונים. הפיתוח אורך 1–2 ימים, עם עלות אופיינית של $500–$1,500 ליישום, והחזרות לדפי מוצר עולות בעד 15%. צרו קשר — נבחן את הפרויקט שלכם תוך יום.
תהליך היישום שלב אחר שלב
- הקמת הוק localStorage — צרו הוק React המאחסן מזהי מוצרים ב-localStorage, עם מקסימום 20 רשומות והסרת כפילויות.
- יצירת נקודת קצה API מרוכזת — ב-Laravel, בנו נקודת קצה
useRecentlyViewedהמקבלת מזהים מופרדים בפסיקים ומחזירה את נתוני המוצר המינימליים בשאילתה אחת. - בניית רכיב UI — פתחו רכיב קרוסלה המציג עד 8 מוצרים, תוך שמירה על הסדר מהמערך ההיסטורי.
- סנכרון בעת התחברות — כשהמשתמש מתחבר, מיזגו את נתוני ה-localStorage עם ההיסטוריה בצד השרת באמצעות Sanctum.
- בדיקה ואופטימיזציה — ודאו ביצועים: טעינה מרוכזת מהירה פי 3–5 מבקשות בודדות.
אחסון היסטוריה ב-localStorage
עבור משתמשים לא מחוברים, אנו מאחסנים היסטוריה ב-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 }; }; הדוחף את מזהה המוצר לתחילת המערך בכל צפייה, תוך הסרת כפילויות. מקסימום 20 רשומות. הנה היישום:
// 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)]);
}גישה זו שומרת על היסטוריה בין מכשירים ומונעת אובדן נתונים בעת מעבר בין דפדפנים. שיטת הסנכרון ההיברידית מהירה פי 10 עבור אורחים בהשוואה לסנכרון שרת בלבד, ומשפרת את חוויית המשתמש באמצעות התאמה אישית ואופטימיזציית ביצועים.
טעינה מרוכזת של נתונים
ההיסטוריה מאחסנת רק // 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)]); } . כדי להציג את הבלוק, אנו זקוקים לנתוני מוצר מלאים — בקשת API מרוכזת אחת. אנו משתמשים ב-React Query עם staleTime של 5 דקות כדי למנוע עומס על השרת בביקורים חוזרים:
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>
);
};נקודת הקצה טוענת בדיוק את השדות הדרושים לכרטיס ומחזירה מוצרים בסדר המזהים שהועברו:
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));
} למה טעינה מרוכזת מהירה יותר?
אם תבקשו כל מוצר בנפרד, תקבלו N שאילתות מסד נתונים — עבור 8 מוצרים, זה 8 סבבים. נקודת קצה מרוכזת מחזירה הכל בבת אחת, ומפחיתה את עומס השרת פי 8. בצד הלקוח, אנו שומרים על הסדר על ידי מיון מול מערך המזהים המקורי. גישה זו מאיצה את טעינת הבלוק פי 3–5 ואינה פוגעת ב-Core Web Vitals (LCP, FID).
| גישה | זמן טעינה (8 מוצרים) | עומס שרת |
|---|---|---|
| שאילתות N+1 | 800–1200 אלפיות השנייה | 8 שאילתות |
| בקשה מרוכזת | 150–300 אלפיות השנייה | שאילתה אחת |
איך להוציא את המוצר הנוכחי?
בדף של מוצר X, אנו מוציאים את מוצר X מהבלוק 'נצפה לאחרונה' — אחרת הוא יופיע בהכרח ראשון. אנו מיישמים פילטר פשוט: product_id. זה מונע מהמשתמש לראות את אותו כרטיס מוצר ממש ליד התוכן הראשי.
השוואת שיטות סנכרון
| שיטה | אורחים | מחוברים | מהירות | עומס שרת |
|---|---|---|---|---|
| localStorage בלבד | כן | לא | מיידי | ללא |
| שרת בלבד | לא | כן | ~5 אלפיות השנייה | בינוני |
| היברידי (localStorage + שרת) | כן | כן | מיידי + 5 אלפיות השנייה | נמוך |
הגישה ההיברידית היא אופטימלית: היא משלבת את המהירות של אחסון מקומי עם העמידות של אחסון בצד השרת.
היכן למקם את הבלוק
- דף הבית: עבור משתמשים חוזרים — במקום או לצד מוצרים פופולריים
- דף קטגוריה: בתחתית, אחרי רשת המוצרים הראשית
- דף מוצר: מתחת לבלוק 'מוצרים דומים'
- עגלת קניות: בסרגל צדדי במחשב
- תוצאות חיפוש ריקות: 'אולי אתם מחפשים משהו שצפיתם בו לאחרונה?'
פרטיות ושליטה
כפתור 'נקה היסטוריה' נותן למשתמשים שליטה על הנתונים שלהם. אנו יכולים להוסיף TTL ידני ב-localStorage — למשל, 30 יום. למידע נוסף על localStorage ב-תיעוד MDN.
טעויות יישום נפוצות
- אי הוצאת המוצר הנוכחי — המשתמש רואה אותו ראשון.
- שימוש בשאילתות N+1 במקום בקשה מרוכזת — טעינת הבלוק איטית.
- אי ניקוי היסטוריה בעת התחברות — נתוני אורח מתערבבים עם נתוני משתמש.
- סנכרון היסטוריה בתדירות גבוהה מדי — עומס שרת מיותר.
מה כלול בעבודה
- ניתוח הארכיטקטורה הנוכחית ואפשרויות המיקום
- יישום הוק
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> ); };עם localStorage - נקודות קצה API לסנכרון שרת (Laravel)
- טעינת נתונים מרוכזת עם שמירה במטמון בצד הלקוח
- רכיב UI לבלוק (קרוסלה) עם כפתור ניקוי
- הוראות פריסה ובדיקות
הניסיון שלנו כולל מעל 50 פרויקטים עם היסטוריית צפייה. אנו מבטיחים פעולה תקינה בכל השלבים. צרו קשר כדי לדון בפרויקט שלכם — נבחן אותו תוך יום. הזמינו יישום של בלוק 'נצפה לאחרונה' וקבלו שיפור בהמרות ללא אינטגרציות מורכבות.







