📋 Что получится
- "Осталось 3 шт!" — обновляется в реальном времени
- "Товар закончился" — кнопка блокируется мгновенно
- "Снова в наличии!" — уведомление подписчикам
- Счётчик "N человек смотрят этот товар"
Любой бэкенд: Примеры на PHP. Паттерн работает с любым языком и e-commerce платформой.
📊 Сценарии использования
📦
Мало на складе — создаём срочность
🛒
Товар в корзине — резерв истекает
❌
Закончился — блокируем покупку
🔔
Снова в наличии — push подписчикам
💻 Код сервера
Установка PHP SDK
Bash
composer require pushler/php-sdk
Обновление остатков при покупке
PHP
<?php
// complete_order.php — при успешной покупке
require_once 'vendor/autoload.php';
use PushlerRu\PushlerClient;
$pushler = new PushlerClient('key_ваш_ключ', 'secret_ваш_секрет');
$pdo = getDbConnection();
// После оформления заказа обновляем остатки
foreach ($orderItems as $item) {
$productId = $item['product_id'];
$quantity = $item['quantity'];
// Уменьшаем остаток
$stmt = $pdo->prepare('
UPDATE products
SET stock = stock - ?
WHERE id = ? AND stock >= ?
');
$stmt->execute([$quantity, $productId, $quantity]);
// Получаем новый остаток
$stmt = $pdo->prepare('SELECT stock, title FROM products WHERE id = ?');
$stmt->execute([$productId]);
$product = $stmt->fetch(PDO::FETCH_ASSOC);
$stock = (int) $product['stock'];
// Определяем статус и сообщение
$stockStatus = getStockStatus($stock);
// Broadcast обновления всем, кто смотрит товар
$pushler->trigger(
"product-{$productId}",
'stock:updated',
[
'product_id' => $productId,
'stock' => $stock,
'status' => $stockStatus['status'],
'message' => $stockStatus['message'],
'can_buy' => $stock > 0
]
);
// Если товар закончился — уведомляем подписчиков
if ($stock === 0) {
notifyOutOfStock($pushler, $productId, $product['title']);
}
}
function getStockStatus(int $stock): array
{
if ($stock === 0) {
return ['status' => 'out_of_stock', 'message' => 'Нет в наличии'];
}
if ($stock <= 3) {
return ['status' => 'critical', 'message' => "Осталось {$stock} шт!"];
}
if ($stock <= 10) {
return ['status' => 'low', 'message' => "Осталось мало"];
}
return ['status' => 'available', 'message' => 'В наличии'];
}
function notifyOutOfStock($pushler, int $productId, string $title): void
{
global $pdo;
// Получаем подписчиков на уведомления
$stmt = $pdo->prepare('
SELECT user_id FROM product_watchers WHERE product_id = ?
');
$stmt->execute([$productId]);
while ($row = $stmt->fetch()) {
$pushler->trigger(
"private-user-{$row['user_id']}",
'product:out_of_stock',
[
'product_id' => $productId,
'title' => $title,
'message' => 'Товар закончился. Мы уведомим вас о поступлении.'
]
);
}
}
Уведомление "Снова в наличии"
PHP
<?php
// restock.php — при поступлении товара
require_once 'vendor/autoload.php';
use PushlerRu\PushlerClient;
$pushler = new PushlerClient('key_ваш_ключ', 'secret_ваш_секрет');
$productId = (int) $_POST['product_id'];
$quantity = (int) $_POST['quantity'];
$pdo = getDbConnection();
// Получаем текущий остаток
$stmt = $pdo->prepare('SELECT stock, title FROM products WHERE id = ?');
$stmt->execute([$productId]);
$product = $stmt->fetch(PDO::FETCH_ASSOC);
$wasOutOfStock = $product['stock'] === 0;
// Увеличиваем остаток
$stmt = $pdo->prepare('UPDATE products SET stock = stock + ? WHERE id = ?');
$stmt->execute([$quantity, $productId]);
$newStock = $product['stock'] + $quantity;
$stockStatus = getStockStatus($newStock);
// Обновляем всех на странице товара
$pushler->trigger(
"product-{$productId}",
'stock:updated',
[
'product_id' => $productId,
'stock' => $newStock,
'status' => $stockStatus['status'],
'message' => $stockStatus['message'],
'can_buy' => true
]
);
// Если был out of stock — уведомляем подписчиков
if ($wasOutOfStock) {
$stmt = $pdo->prepare('
SELECT user_id FROM product_watchers WHERE product_id = ?
');
$stmt->execute([$productId]);
while ($row = $stmt->fetch()) {
$pushler->trigger(
"private-user-{$row['user_id']}",
'product:back_in_stock',
[
'product_id' => $productId,
'title' => $product['title'],
'message' => '🎉 Товар снова в наличии! Успейте купить.'
]
);
}
// Очищаем подписки (опционально)
// $pdo->prepare('DELETE FROM product_watchers WHERE product_id = ?')->execute([$productId]);
}
echo json_encode(['success' => true, 'new_stock' => $newStock]);
Подписка "Сообщить о поступлении"
PHP
<?php
// watch_product.php
$productId = (int) $_POST['product_id'];
$userId = getCurrentUserId();
if (!$userId) {
// Для неавторизованных — сохраняем email
$email = filter_var($_POST['email'], FILTER_VALIDATE_EMAIL);
if (!$email) {
http_response_code(400);
exit;
}
$pdo->prepare('
INSERT IGNORE INTO product_watchers_email (product_id, email) VALUES (?, ?)
')->execute([$productId, $email]);
} else {
$pdo->prepare('
INSERT IGNORE INTO product_watchers (product_id, user_id) VALUES (?, ?)
')->execute([$productId, $userId]);
}
echo json_encode(['success' => true]);
🌐 Код клиента
JavaScript
class ProductStock {
constructor(productId, appKey) {
this.productId = productId;
this.appKey = appKey;
this.pushler = null;
this.channel = null;
}
connect() {
this.pushler = new PushlerClient({
appKey: this.appKey
});
this.pushler.on('connected', () => {
// Публичный канал товара (не требует авторизации)
this.channel = this.pushler.subscribe(`product-${this.productId}`);
this.channel.on('stock:updated', (data) => {
this.updateStockUI(data);
});
});
}
updateStockUI(data) {
const stockEl = document.getElementById('stock-status');
const buyBtn = document.getElementById('buy-button');
const stockBadge = document.getElementById('stock-badge');
// Обновляем бейдж
stockBadge.textContent = data.message;
stockBadge.className = `stock-badge ${data.status}`;
// Анимация
stockBadge.classList.add('pulse');
setTimeout(() => stockBadge.classList.remove('pulse'), 500);
// Кнопка покупки
if (!data.can_buy) {
buyBtn.disabled = true;
buyBtn.textContent = 'Нет в наличии';
buyBtn.classList.add('disabled');
// Показываем форму подписки
document.getElementById('notify-form').classList.remove('hidden');
} else {
buyBtn.disabled = false;
buyBtn.textContent = 'Добавить в корзину';
buyBtn.classList.remove('disabled');
document.getElementById('notify-form').classList.add('hidden');
}
// Показываем toast при критичном уровне
if (data.status === 'critical') {
this.showUrgencyToast(data.message);
}
}
showUrgencyToast(message) {
const toast = document.createElement('div');
toast.className = 'urgency-toast';
toast.innerHTML = `⚡ ${message} — торопитесь!`;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 5000);
}
}
// Инициализация на странице товара
const productId = 123;
const stock = new ProductStock(productId, 'key_ваш_ключ');
stock.connect();
// Форма "Сообщить о поступлении"
document.getElementById('notify-form').onsubmit = async (e) => {
e.preventDefault();
const email = document.getElementById('notify-email').value;
await fetch('/watch_product.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `product_id=${productId}&email=${encodeURIComponent(email)}`
});
document.getElementById('notify-form').innerHTML = '✅ Мы уведомим вас о поступлении!';
};
Счётчик "N человек смотрят"
JavaScript
// Используем presence-канал для подсчёта просмотров
const viewersChannel = pushler.subscribe(`presence-product-${productId}`);
viewersChannel.on('pushler:subscription_succeeded', (data) => {
updateViewersCount(Object.keys(data.members || {}).length);
});
viewersChannel.on('pushler:member_added', () => {
const count = viewersChannel.members?.size || 0;
updateViewersCount(count);
});
viewersChannel.on('pushler:member_removed', () => {
const count = viewersChannel.members?.size || 0;
updateViewersCount(count);
});
function updateViewersCount(count) {
const el = document.getElementById('viewers-count');
if (count > 1) {
el.textContent = `👁️ ${count} человек смотрят этот товар`;
el.classList.remove('hidden');
} else {
el.classList.add('hidden');
}
}
💚 Vue компонент
Vue
<!-- StockIndicator.vue -->
<template>
<div class="stock-indicator">
<!-- Бейдж остатка -->
<div :class="['stock-badge', stockStatus]">
{{ stockMessage }}
</div>
<!-- Счётчик просмотров -->
<div v-if="viewersCount > 1" class="viewers">
👁️ {{ viewersCount }} человек смотрят
</div>
<!-- Кнопка покупки -->
<button
:disabled="!canBuy"
:class="['buy-btn', { disabled: !canBuy }]"
@click="$emit('add-to-cart')"
>
{{ canBuy ? 'Добавить в корзину' : 'Нет в наличии' }}
</button>
<!-- Форма подписки -->
<div v-if="!canBuy && !subscribed" class="notify-form">
<input v-model="email" type="email" placeholder="Email для уведомления" />
<button @click="subscribe">Сообщить о поступлении</button>
</div>
<div v-if="subscribed" class="subscribed">
✅ Мы уведомим вас!
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import PushlerClient from '@pushler/js';
const props = defineProps({
productId: { type: Number, required: true },
initialStock: { type: Number, default: 0 },
appKey: { type: String, required: true }
});
const stock = ref(props.initialStock);
const viewersCount = ref(0);
const email = ref('');
const subscribed = ref(false);
let pushler = null;
let productChannel = null;
let presenceChannel = null;
const canBuy = computed(() => stock.value > 0);
const stockStatus = computed(() => {
if (stock.value === 0) return 'out_of_stock';
if (stock.value <= 3) return 'critical';
if (stock.value <= 10) return 'low';
return 'available';
});
const stockMessage = computed(() => {
if (stock.value === 0) return 'Нет в наличии';
if (stock.value <= 3) return `Осталось ${stock.value} шт!`;
if (stock.value <= 10) return 'Осталось мало';
return 'В наличии';
});
onMounted(() => {
pushler = new PushlerClient({
appKey: props.appKey,
authEndpoint: '/auth.php'
});
pushler.on('connected', () => {
// Канал товара
productChannel = pushler.subscribe(`product-${props.productId}`);
productChannel.on('stock:updated', (data) => {
stock.value = data.stock;
});
// Presence для счётчика просмотров
presenceChannel = pushler.subscribe(`presence-product-${props.productId}`);
presenceChannel.on('pushler:subscription_succeeded', (data) => {
viewersCount.value = Object.keys(data.members || {}).length;
});
presenceChannel.on('pushler:member_added', () => {
viewersCount.value++;
});
presenceChannel.on('pushler:member_removed', () => {
viewersCount.value = Math.max(0, viewersCount.value - 1);
});
});
});
onUnmounted(() => {
productChannel?.unsubscribe();
presenceChannel?.unsubscribe();
pushler?.disconnect();
});
async function subscribe() {
await fetch('/watch_product.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `product_id=${props.productId}&email=${encodeURIComponent(email.value)}`
});
subscribed.value = true;
}
</script>
⚠️ Типичные ошибки
1. Остаток уходит в минус
PHP
// ❌ Неправильно — нет проверки в SQL
$pdo->prepare('UPDATE products SET stock = stock - ? WHERE id = ?');
// ✅ Правильно — проверка в WHERE
$stmt = $pdo->prepare('
UPDATE products
SET stock = stock - ?
WHERE id = ? AND stock >= ?
');
$stmt->execute([$quantity, $productId, $quantity]);
if ($stmt->rowCount() === 0) {
throw new Exception('Недостаточно товара на складе');
}
2. Слишком частые обновления
PHP
// ❌ Неправильно — обновление на каждый просмотр товара
$pushler->trigger("product-{$id}", 'stock:updated', ...);
// ✅ Правильно — только при реальном изменении остатка
if ($newStock !== $oldStock) {
$pushler->trigger("product-{$id}", 'stock:updated', ...);
}
Готовы попробовать?
Создайте бесплатный аккаунт и начните интеграцию за пару минут