Разработка компонента избранного на Vue.js для 1С-Битрикс
Функционал избранного (wishlist) в Битрикс нередко реализуется как отдельный список сравнения или как кастомная корзина с особым статусом. Оба подхода — костыли. Vue.js-компонент избранного строится правильно: отдельная сущность, персистентное хранение для авторизованных, guest-режим через localStorage, мгновенная реакция UI без перезагрузки страницы.
Что важно спроектировать до написания кода
Для кого хранится избранное? Если для неавторизованных — только localStorage. Если хотим, чтобы избранное сохранялось при логине — нужна база данных и механизм мержа: при авторизации гостевое избранное из localStorage объединяется с серверным.
Сколько списков? Простой вариант — один список на пользователя. Продвинутый — несколько именованных вишлистов («Подарки на день рождения», «Хочу весной»). Второй вариант значительно сложнее — с отдельным интерфейсом управления списками.
Что делать при добавлении товара, которого нет в наличии? Уведомлять, когда появится — это отдельный функционал «Подписка на наличие», связанный с избранным.
Структура базы данных
CREATE TABLE b_user_wishlist (
ID SERIAL PRIMARY KEY,
USER_ID INT NOT NULL REFERENCES b_user(ID),
LIST_NAME VARCHAR(255) DEFAULT 'default',
DATE_CREATE TIMESTAMP DEFAULT NOW()
);
CREATE TABLE b_wishlist_items (
ID SERIAL PRIMARY KEY,
LIST_ID INT NOT NULL REFERENCES b_user_wishlist(ID),
PRODUCT_ID INT NOT NULL,
DATE_ADD TIMESTAMP DEFAULT NOW(),
UNIQUE (LIST_ID, PRODUCT_ID)
);
Для простого варианта (один список) b_user_wishlist можно не создавать — достаточно таблицы с USER_ID и PRODUCT_ID.
Pinia store для избранного
// stores/wishlistStore.ts
export const useWishlistStore = defineStore('wishlist', () => {
const items = ref<number[]>([])
const loading = ref<Set<number>>(new Set())
// Загрузка при инициализации
async function init() {
if (isLoggedIn()) {
const data = await api.get('/local/api/wishlist/')
items.value = data.map((i: any) => i.product_id)
} else {
const stored = localStorage.getItem('wishlist')
items.value = stored ? JSON.parse(stored) : []
}
}
async function toggle(productId: number) {
if (loading.value.has(productId)) return
loading.value.add(productId)
const wasAdded = items.value.includes(productId)
// Оптимистичное обновление
if (wasAdded) {
items.value = items.value.filter(id => id !== productId)
} else {
items.value.push(productId)
}
// Персист
if (isLoggedIn()) {
try {
await api.post('/local/api/wishlist/' + (wasAdded ? 'remove' : 'add') + '/', { product_id: productId })
} catch (e) {
// Откат при ошибке
if (wasAdded) items.value.push(productId)
else items.value = items.value.filter(id => id !== productId)
}
} else {
localStorage.setItem('wishlist', JSON.stringify(items.value))
}
loading.value.delete(productId)
}
const isInWishlist = (id: number) => items.value.includes(id)
const count = computed(() => items.value.length)
return { items, loading, init, toggle, isInWishlist, count }
})
Оптимистичное обновление (Optimistic UI) — сначала меняем UI, потом отправляем запрос. При ошибке откатываем. Пользователь видит мгновенную реакцию.
Кнопка избранного на карточке товара
<!-- WishlistButton.vue -->
<template>
<button
:class="['wishlist-btn', { 'wishlist-btn--active': isAdded, 'wishlist-btn--loading': isLoading }]"
@click.prevent="handleToggle"
:aria-label="isAdded ? 'Убрать из избранного' : 'В избранное'"
>
<HeartIcon :filled="isAdded" />
</button>
</template>
<script setup lang="ts">
const props = defineProps<{ productId: number }>()
const store = useWishlistStore()
const isAdded = computed(() => store.isInWishlist(props.productId))
const isLoading = computed(() => store.loading.has(props.productId))
async function handleToggle() {
await store.toggle(props.productId)
// Показать toast: "Добавлено в избранное" / "Удалено"
showToast(isAdded.value ? 'Добавлено в избранное' : 'Удалено из избранного')
}
</script>
Toast-уведомления — ненавязчивые сообщения в углу экрана, которые исчезают через 3 секунды. Реализуются через отдельный Toast-компонент или небольшую библиотеку (vue-toastification, vuedraggable).
Страница избранного
<!-- WishlistPage.vue -->
<template>
<div class="wishlist-page">
<h1>Избранное <span class="count">{{ products.length }}</span></h1>
<div v-if="!products.length" class="wishlist-empty">
<p>Список избранного пуст</p>
<RouterLink to="/catalog/">Перейти в каталог</RouterLink>
</div>
<div v-else class="wishlist-grid">
<ProductCard
v-for="product in products"
:key="product.id"
:product="product"
:show-wishlist-btn="true"
/>
</div>
<!-- Добавить все в корзину -->
<button v-if="availableProducts.length" @click="addAllToCart">
Добавить в корзину доступные ({{ availableProducts.length }})
</button>
</div>
</template>
<script setup lang="ts">
const store = useWishlistStore()
const products = ref([])
onMounted(async () => {
if (!store.items.length) return
const res = await fetch(`/local/api/wishlist/products/?ids=${store.items.join(',')}`)
products.value = await res.json()
})
const availableProducts = computed(() => products.value.filter(p => p.available))
async function addAllToCart() {
const cartStore = useCartStore()
for (const product of availableProducts.value) {
await cartStore.add(product.id, 1)
}
showToast(`Добавлено ${availableProducts.value.length} товаров в корзину`)
}
</script>
Мерж guest → user при авторизации
При логине пользователя вызывается серверный метод мержа:
// WishlistController.php
public function mergeGuestAction(array $guestIds): array {
global $USER;
$userId = (int) $USER->GetID();
foreach ($guestIds as $productId) {
// Добавляем только если ещё нет
WishlistItemTable::merge(['USER_ID' => $userId, 'PRODUCT_ID' => (int)$productId]);
}
// Возвращаем полный актуальный список
return WishlistItemTable::getProductIdsByUser($userId);
}
В JS: при событии успешного логина (или при инициализации авторизованного пользователя):
async function onUserLogin() {
const guestIds = JSON.parse(localStorage.getItem('wishlist') ?? '[]')
if (guestIds.length) {
const merged = await api.post('/local/api/wishlist/merge/', { ids: guestIds })
items.value = merged
localStorage.removeItem('wishlist')
} else {
await init() // Загружаем серверный список
}
}
Подписка на наличие из избранного
Расширение: если товар из избранного появился на складе, пользователь получает уведомление. Реализация:
CREATE TABLE b_availability_subscriptions (
USER_ID INT NOT NULL,
PRODUCT_ID INT NOT NULL,
EMAIL VARCHAR(255),
DATE_ADD TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (USER_ID, PRODUCT_ID)
);
Агент Битрикс проверяет таблицу раз в час: если товар снова появился в наличии (QUANTITY > 0), отправляет письмо через CEvent::Send() и удаляет подписку.
Аналитика избранного
Данные избранного — ценный сигнал о спросе. Товары в топе избранного с нулевыми остатками — кандидаты на приоритетное пополнение. Аналитический дашборд: топ-50 товаров в избранном, конверсия «из избранного в покупку» (через JOIN с b_sale_basket).
Сроки
| Вариант | Что входит | Срок |
|---|---|---|
| Guest-only wishlist | localStorage + кнопки + страница | 4–7 дней |
| Авторизованный с персистом | + БД, API, мерж | 1–2 недели |
| + Мульти-списки, подписка | + управление списками, уведомления | +1–2 недели |
Избранное — не просто функция удобства. Это механизм возврата покупателя: человек добавил товар в вишлист, ушёл, вернулся через неделю и купил. Без избранного этот сценарий заканчивается на шаге «ушёл».







