Возможности Как это работает Тарифы Документация Cookbook Партнёрам Личный кабинет
Бизнес-кейсы
📦

Остатки на складе

8 мин чтения Просто

"Осталось 3 шт!", "Товар закончился", "Снова в наличии!" — всё в реальном времени.

E-commerce PHP Vue

📋 Что получится

  • "Осталось 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', ...);
}

Готовы попробовать?

Создайте бесплатный аккаунт и начните интеграцию за пару минут