Development of the "Previously Viewed" block in 1C-Bitrix

Our company is engaged in the development, support and maintenance of Bitrix and Bitrix24 solutions of any complexity. From simple one-page sites to complex online stores, CRM systems with 1C and telephony integration. The experience of developers is confirmed by certificates from the vendor.
Our competencies:
Development stages

"Recently Viewed" Block Development for 1C-Bitrix

"Recently viewed" is a block that shows products the user opened in the current or a previous session. A classic retention tool: the user leaves a page, then comes back — and doesn't have to search for the product again. Especially valuable on mobile devices, where browser history is inconvenient.

Where to Store View History

Option 1: localStorage (browser). The simplest implementation. JavaScript saves viewed product IDs in localStorage. On page load — reads the list, sends an AJAX request with IDs, receives up-to-date product data.

Pros: works without authentication, no server load. Cons: history doesn't sync across devices, lost when the browser is cleared.

Option 2: Server (database). For authenticated users, history is stored on the server. Anonymous users — in localStorage or via b_sale_fuser (guestID).

Option 3: Hybrid. Anonymous user — localStorage + server-side cache by fuser_id. After login — history migrates to the account. The most complete option.

Implementation via localStorage (Fast Option)

const STORAGE_KEY = 'viewed_products';
const MAX_ITEMS   = 20;

// Called when a product page loads
function trackProductView(productId) {
    let viewed = getViewedProducts();

    // Remove if already present (will move to top)
    viewed = viewed.filter(id => id !== productId);

    // Add to beginning
    viewed.unshift(productId);

    // Limit size
    if (viewed.length > MAX_ITEMS) {
        viewed = viewed.slice(0, MAX_ITEMS);
    }

    localStorage.setItem(STORAGE_KEY, JSON.stringify(viewed));
}

function getViewedProducts() {
    try {
        return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
    } catch {
        return [];
    }
}

// Load data for the block
function loadRecentlyViewed(currentProductId, containerId, limit = 8) {
    const viewed = getViewedProducts()
        .filter(id => id !== currentProductId)
        .slice(0, limit);

    if (viewed.length === 0) {
        document.getElementById(containerId).style.display = 'none';
        return;
    }

    fetch('/ajax/recently-viewed/', {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({ ids: viewed }),
    })
    .then(r => r.json())
    .then(data => renderRecentlyViewed(data.products, containerId));
}

Server-Side AJAX Handler

// local/ajax/recently-viewed/index.php
\Bitrix\Main\Loader::includeModule('iblock');
\Bitrix\Main\Loader::includeModule('catalog');

$ids = array_filter(array_map('intval', json_decode(file_get_contents('php://input'), true)['ids'] ?? []));

if (empty($ids) || count($ids) > 20) {
    echo json_encode(['products' => []]);
    exit;
}

// Preserve the order from localStorage
$result   = [];
$products = [];

$res = \CIBlockElement::GetList(
    [],
    ['ID' => $ids, 'IBLOCK_ID' => CATALOG_IBLOCK_ID, 'ACTIVE' => 'Y'],
    false,
    false,
    ['ID', 'NAME', 'DETAIL_PAGE_URL', 'PREVIEW_PICTURE']
);

while ($product = $res->GetNext()) {
    $productId = $product['ID'];
    $product['PRICE'] = \CPrice::GetBasePrice($productId);
    $products[$productId] = $product;
}

// Restore view order
foreach ($ids as $id) {
    if (isset($products[$id])) {
        $result[] = $products[$id];
    }
}

header('Content-Type: application/json');
echo json_encode(['products' => $result]);

Server-Side Storage for Authenticated Users

CREATE TABLE custom_user_recently_viewed (
    id          SERIAL PRIMARY KEY,
    user_id     INT NOT NULL,
    product_id  INT NOT NULL,
    viewed_at   DATETIME DEFAULT NOW(),
    UNIQUE KEY uk_user_product (user_id, product_id),
    INDEX idx_user (user_id, viewed_at DESC)
);
// When an authenticated user views a product
if ($USER->IsAuthorized()) {
    $userId = $USER->GetID();
    // INSERT or UPDATE viewed_at
    Application::getConnection()->query("
        INSERT INTO custom_user_recently_viewed (user_id, product_id, viewed_at)
        VALUES ({$userId}, {$productId}, NOW())
        ON DUPLICATE KEY UPDATE viewed_at = NOW()
    ");

    // Limit history to 50 records
    Application::getConnection()->query("
        DELETE FROM custom_user_recently_viewed
        WHERE user_id = {$userId}
        AND id NOT IN (
            SELECT id FROM (
                SELECT id FROM custom_user_recently_viewed
                WHERE user_id = {$userId}
                ORDER BY viewed_at DESC
                LIMIT 50
            ) t
        )
    ");
}

History Migration on Login

When an anonymous user logs in, migrate history from localStorage to the database:

// On successful login event
document.addEventListener('userLoggedIn', function(e) {
    const viewed = getViewedProducts();
    if (viewed.length === 0) return;

    fetch('/ajax/sync-viewed-history/', {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({ ids: viewed, user_id: e.detail.userId }),
    });
});

Display in User Account

The "View history" section in the user account — a full list with the ability to clear it. Show up to 100 recent products with pagination. "Clear history" button — DELETE from custom_user_recently_viewed by user_id + clear localStorage.

Timeline

Stage Timeline
localStorage implementation + AJAX handler 2–3 days
Server-side storage for authenticated users 2–3 days
History migration on login 1 day
History page in user account 1–2 days
Testing (cross-browser, mobile) 1–2 days

Total: 1–1.5 weeks for the full version. localStorage-only — 3–4 days.