Разработка cross-sell и up-sell блоков для интернет-магазина
Cross-sell и up-sell — два отдельных механизма увеличения среднего чека, которые часто путают. Cross-sell — предложение сопутствующих товаров («к этому ноутбуку подходит эта мышь»). Up-sell — предложение более дорогого варианта того же товара («за 2000 ₽ больше — версия Pro»). Разработка обоих блоков занимает 4–6 рабочих дней.
Разграничение cross-sell и up-sell
| Параметр | Cross-sell | Up-sell |
|---|---|---|
| Что предлагается | Дополняющие товары | Премиум-версия текущего |
| Где показывается | Страница товара, корзина | Страница товара, до добавления в корзину |
| Метрика | Увеличение количества позиций | Увеличение суммы одной позиции |
| Пример | Чехол к телефону | 128 ГБ вместо 64 ГБ |
Модель данных: ручные связи
Для небольших каталогов — ручное задание связей в admin-панели:
CREATE TABLE product_relations (
id BIGSERIAL PRIMARY KEY,
product_id BIGINT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
related_product_id BIGINT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
type VARCHAR(20) NOT NULL, -- 'cross_sell', 'up_sell', 'accessory', 'spare_part'
sort_order SMALLINT DEFAULT 0,
UNIQUE(product_id, related_product_id, type)
);
CREATE INDEX idx_product_relations_pid_type ON product_relations(product_id, type);
В admin-панели — поиск товаров и добавление связей drag-and-drop с выбором типа.
Автоматические cross-sell через категории
Если связи не заданы вручную — автоматический fallback по совместным покупкам или категориям-аксессуарам:
class CrossSellResolver
{
public function resolve(Product $product, int $limit = 4): Collection
{
// 1. Ручные связи
$manual = $product->relations()
->where('type', 'cross_sell')
->with('relatedProduct')
->orderBy('sort_order')
->limit($limit)
->get()
->map(fn($r) => $r->relatedProduct);
if ($manual->count() >= $limit) return $manual;
// 2. Автоматические из cooccurrences (если есть достаточно данных)
$needed = $limit - $manual->count();
$auto = DB::table('product_cooccurrences')
->where('product_a', $product->id)
->whereNotIn('product_b', $manual->pluck('id'))
->orderByDesc('cooccurrence_count')
->limit($needed)
->pluck('product_b');
$autoProducts = Product::whereIn('id', $auto)->where('is_active', true)->get();
return $manual->merge($autoProducts);
}
}
Up-sell: варианты одного товара
Для вариативных товаров (телефон с разным объёмом памяти) up-sell — это навигация между вариантами с акцентом на премиум:
const UpSellVariants = ({ currentVariant, variants }: UpSellProps) => {
const betterVariants = variants.filter(v => v.price > currentVariant.price);
if (!betterVariants.length) return null;
return (
<div className="border rounded-lg p-4 bg-amber-50">
<p className="text-sm font-medium mb-2">Рассмотрите улучшенную версию:</p>
{betterVariants.slice(0, 2).map(variant => (
<div key={variant.id} className="flex items-center justify-between py-2">
<span className="text-sm">{variant.label}</span>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">
+{formatPrice(variant.price - currentVariant.price)}
</span>
<Button size="sm" variant="outline" onClick={() => selectVariant(variant)}>
Выбрать
</Button>
</div>
</div>
))}
</div>
);
};
Cross-sell в корзине: «Дополните заказ»
Самый конверсионный момент для cross-sell — страница корзины. Блок «Часто берут вместе» агрегирует рекомендации по всем товарам в корзине:
public function getCartCrossSells(Cart $cart): Collection
{
$cartProductIds = $cart->items->pluck('product_id');
// Объединяем cross-sell рекомендации всех товаров в корзине
$recommendations = DB::table('product_relations')
->join('products', 'products.id', '=', 'product_relations.related_product_id')
->whereIn('product_relations.product_id', $cartProductIds)
->whereNotIn('product_relations.related_product_id', $cartProductIds)
->where('product_relations.type', 'cross_sell')
->where('products.is_active', true)
->where('products.stock', '>', 0)
->select('products.*', DB::raw('COUNT(*) as relevance_score'))
->groupBy('products.id')
->orderByDesc('relevance_score')
->limit(4)
->get();
return $recommendations;
}
Товар, рекомендованный сразу несколькими позициями корзины, получает более высокий relevance_score и показывается первым.
Quick-add в блоке рекомендаций
Кнопка «Добавить в корзину» прямо в карточке cross-sell — без перехода на страницу товара:
const CrossSellCard = ({ product }: { product: Product }) => {
const { addToCart, isLoading } = useCart();
return (
<div className="border rounded-lg p-3 flex gap-3">
<img src={product.thumb} alt={product.name} className="w-16 h-16 object-cover rounded" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{product.name}</p>
<p className="text-sm text-gray-700">{formatPrice(product.price)}</p>
</div>
<Button
size="sm"
loading={isLoading(product.id)}
onClick={() => addToCart(product.id, 1)}
>
+ В корзину
</Button>
</div>
);
};
Bundle: фиксированные комплекты
Отдельный тип cross-sell — бандл с скидкой на комплект:
CREATE TABLE product_bundles (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255),
discount_percent NUMERIC(5,2) DEFAULT 0,
is_active BOOLEAN DEFAULT TRUE
);
CREATE TABLE product_bundle_items (
bundle_id BIGINT REFERENCES product_bundles(id) ON DELETE CASCADE,
product_id BIGINT REFERENCES products(id),
is_primary BOOLEAN DEFAULT FALSE,
PRIMARY KEY(bundle_id, product_id)
);
На странице основного товара показывается блок «Купить комплектом»: все товары бандла + итоговая цена со скидкой. Добавление в корзину — одной кнопкой.
Аналитика эффективности блоков
Каждый показ cross-sell/up-sell логируется. Метрики:
- Impressions — сколько раз блок показан
- CTR — клики по рекомендациям / показы
- Add-to-cart rate — добавления / клики
- Uplift — средний чек заказов с cross-sell vs без
Данные позволяют оптимизировать размещение, количество рекомендаций и выбор алгоритма. Обычно 4 рекомендации показывают лучший CTR, чем 2 или 8.







