📋 Что получится
- Live-статус заказа без перезагрузки
- Уведомления на каждом этапе
- Таймлайн доставки
- Геолокация курьера (опционально)
Любой бэкенд: Примеры на PHP, но паттерн работает с любым языком — Node.js, Python, Go и др.
🏗️ Архитектура
1
Менеджер/система меняет статус заказа
↓
2
Сервер отправляет событие в Pushler
↓
3
Клиент получает обновление мгновенно
↓
4
UI обновляется: статус, таймлайн, уведомление
💻 Код сервера
Сервер обновляет статус заказа и отправляет событие клиенту. Примеры на PHP.
Установка PHP SDK
Bash
composer require pushler/php-sdk
Обновление статуса заказа
PHP
<?php
// update_order.php — обновление статуса заказа
require_once 'vendor/autoload.php';
use PushlerRu\PushlerClient;
$pushler = new PushlerClient('key_ваш_ключ', 'secret_ваш_секрет');
// Статусы заказа
$statuses = [
'pending' => ['text' => 'Ожидает подтверждения', 'icon' => '🕐', 'step' => 0],
'confirmed' => ['text' => 'Подтверждён', 'icon' => '✅', 'step' => 1],
'preparing' => ['text' => 'Готовится', 'icon' => '👨🍳', 'step' => 2],
'ready' => ['text' => 'Готов к выдаче', 'icon' => '📦', 'step' => 3],
'shipped' => ['text' => 'В доставке', 'icon' => '🚗', 'step' => 4],
'delivered' => ['text' => 'Доставлен', 'icon' => '🎉', 'step' => 5],
'cancelled' => ['text' => 'Отменён', 'icon' => '❌', 'step' => -1],
];
// Получаем данные
$orderId = (int) $_POST['order_id'];
$newStatus = $_POST['status'];
$comment = $_POST['comment'] ?? null;
// Валидация
if (!isset($statuses[$newStatus])) {
http_response_code(400);
echo json_encode(['error' => 'Invalid status']);
exit;
}
// Обновляем в БД
$pdo = getDbConnection();
$stmt = $pdo->prepare('
UPDATE orders
SET status = ?, updated_at = NOW()
WHERE id = ?
');
$stmt->execute([$newStatus, $orderId]);
// Получаем заказ
$stmt = $pdo->prepare('SELECT * FROM orders WHERE id = ?');
$stmt->execute([$orderId]);
$order = $stmt->fetch(PDO::FETCH_ASSOC);
$statusInfo = $statuses[$newStatus];
// Отправляем событие клиенту
$pushler->trigger(
"private-user-{$order['user_id']}",
'order:status',
[
'order_id' => $orderId,
'status' => $newStatus,
'status_text' => $statusInfo['text'],
'status_icon' => $statusInfo['icon'],
'step' => $statusInfo['step'],
'comment' => $comment,
'updated_at' => date('c')
]
);
// Логируем историю
$stmt = $pdo->prepare('
INSERT INTO order_history (order_id, status, comment, created_at)
VALUES (?, ?, ?, NOW())
');
$stmt->execute([$orderId, $newStatus, $comment]);
echo json_encode(['success' => true]);
Обновление геолокации курьера
PHP
<?php
// update_courier_location.php — обновление позиции курьера
require_once 'vendor/autoload.php';
use PushlerRu\PushlerClient;
$pushler = new PushlerClient('key_ваш_ключ', 'secret_ваш_секрет');
$orderId = (int) $_POST['order_id'];
$lat = (float) $_POST['lat'];
$lng = (float) $_POST['lng'];
$courierId = getCurrentCourierId();
// Получаем заказ
$pdo = getDbConnection();
$stmt = $pdo->prepare('SELECT user_id FROM orders WHERE id = ? AND courier_id = ?');
$stmt->execute([$orderId, $courierId]);
$order = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$order) {
http_response_code(403);
exit;
}
// Отправляем геолокацию клиенту
$pushler->trigger(
"private-user-{$order['user_id']}",
'order:courier-location',
[
'order_id' => $orderId,
'lat' => $lat,
'lng' => $lng,
'updated_at' => date('c')
]
);
echo json_encode(['success' => true]);
Авторизация канала
PHP
<?php
// auth.php
require_once 'vendor/autoload.php';
use PushlerRu\PushlerClient;
$pushler = new PushlerClient('key_ваш_ключ', 'secret_ваш_секрет');
header('Content-Type: application/json');
$channelName = $_POST['channel_name'];
$socketId = $_POST['socket_id'];
$userId = getCurrentUserId();
if (!$userId) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
exit;
}
// Проверяем private-user-{userId}
if (preg_match('/^private-user-(\d+)$/', $channelName, $matches)) {
if ((int) $matches[1] !== $userId) {
http_response_code(403);
echo json_encode(['error' => 'Forbidden']);
exit;
}
}
$auth = $pushler->authorizeChannel($channelName, $socketId);
echo json_encode($auth);
🌐 Код клиента
Vanilla JavaScript
JavaScript
// Отслеживание заказа
class OrderTracker {
constructor(orderId, userId, appKey) {
this.orderId = orderId;
this.userId = userId;
this.appKey = appKey;
this.pushler = null;
this.channel = null;
this.steps = [
{ status: 'pending', label: 'Оформлен', icon: '🕐' },
{ status: 'confirmed', label: 'Подтверждён', icon: '✅' },
{ status: 'preparing', label: 'Готовится', icon: '👨🍳' },
{ status: 'ready', label: 'Готов', icon: '📦' },
{ status: 'shipped', label: 'В пути', icon: '🚗' },
{ status: 'delivered', label: 'Доставлен', icon: '🎉' }
];
this.currentStep = 0;
}
async connect() {
this.pushler = new PushlerClient({
appKey: this.appKey,
authEndpoint: '/auth.php'
});
return new Promise((resolve) => {
this.pushler.on('connected', () => {
this.channel = this.pushler.subscribe(`private-user-${this.userId}`);
this.setupListeners();
resolve();
});
});
}
setupListeners() {
// Обновление статуса
this.channel.on('order:status', (data) => {
if (data.order_id !== this.orderId) return;
this.currentStep = data.step;
this.updateUI(data);
this.showNotification(data);
});
// Геолокация курьера
this.channel.on('order:courier-location', (data) => {
if (data.order_id !== this.orderId) return;
this.updateCourierMarker(data.lat, data.lng);
});
}
updateUI(data) {
// Обновляем текущий статус
document.getElementById('current-status').textContent = data.status_text;
document.getElementById('status-icon').textContent = data.status_icon;
// Обновляем таймлайн
this.steps.forEach((step, index) => {
const el = document.getElementById(`step-${step.status}`);
if (!el) return;
el.classList.remove('active', 'done', 'pending');
if (index < this.currentStep) {
el.classList.add('done');
} else if (index === this.currentStep) {
el.classList.add('active');
} else {
el.classList.add('pending');
}
});
// Комментарий
if (data.comment) {
document.getElementById('status-comment').textContent = data.comment;
}
}
showNotification(data) {
// Браузерное уведомление
if (Notification.permission === 'granted') {
new Notification(`Заказ #${this.orderId}`, {
body: data.status_text,
icon: '/icons/order.png'
});
}
// Toast внутри страницы
const toast = document.createElement('div');
toast.className = 'toast';
toast.innerHTML = `
<span class="icon">${data.status_icon}</span>
<span>${data.status_text}</span>
`;
document.getElementById('toasts').appendChild(toast);
setTimeout(() => toast.remove(), 5000);
}
updateCourierMarker(lat, lng) {
if (this.map && this.courierMarker) {
this.courierMarker.setLatLng([lat, lng]);
}
}
disconnect() {
this.channel?.unsubscribe();
this.pushler?.disconnect();
}
}
// Использование
const tracker = new OrderTracker(12345, 67890, 'key_ваш_ключ');
tracker.connect();
Vue 3 компонент
Vue
<!-- OrderTracker.vue -->
<template>
<div class="order-tracker">
<!-- Текущий статус -->
<div class="current-status" :class="order.status">
<span class="icon">{{ statusInfo.icon }}</span>
<div>
<div class="text">{{ statusInfo.text }}</div>
<div v-if="order.comment" class="comment">{{ order.comment }}</div>
</div>
</div>
<!-- Таймлайн -->
<div class="timeline">
<div
v-for="(step, index) in steps"
:key="step.status"
:class="['step', getStepClass(index)]"
>
<div class="dot">
<span v-if="index < currentStep">✓</span>
<span v-else>{{ index + 1 }}</span>
</div>
<div class="label">{{ step.label }}</div>
</div>
</div>
<!-- Карта (если в доставке) -->
<div v-if="order.status === 'shipped' && courierLocation" class="map-container">
<div id="courier-map" ref="mapEl"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import PushlerClient from '@pushler/js';
const props = defineProps<{
orderId: number;
userId: number;
initialStatus: string;
appKey: string;
}>();
const steps = [
{ status: 'pending', label: 'Оформлен', icon: '🕐' },
{ status: 'confirmed', label: 'Подтверждён', icon: '✅' },
{ status: 'preparing', label: 'Готовится', icon: '👨🍳' },
{ status: 'ready', label: 'Готов', icon: '📦' },
{ status: 'shipped', label: 'В пути', icon: '🚗' },
{ status: 'delivered', label: 'Доставлен', icon: '🎉' }
];
const order = ref({
status: props.initialStatus,
comment: null as string | null
});
const courierLocation = ref<{ lat: number; lng: number } | null>(null);
const currentStep = computed(() =>
steps.findIndex(s => s.status === order.value.status)
);
const statusInfo = computed(() =>
steps.find(s => s.status === order.value.status) || steps[0]
);
const getStepClass = (index: number) => {
if (index < currentStep.value) return 'done';
if (index === currentStep.value) return 'active';
return 'pending';
};
let pushler: PushlerClient | null = null;
let channel: any = null;
onMounted(() => {
pushler = new PushlerClient({
appKey: props.appKey,
authEndpoint: '/auth.php'
});
pushler.on('connected', () => {
channel = pushler!.subscribe(`private-user-${props.userId}`);
channel.on('order:status', (data: any) => {
if (data.order_id !== props.orderId) return;
order.value.status = data.status;
order.value.comment = data.comment;
});
channel.on('order:courier-location', (data: any) => {
if (data.order_id !== props.orderId) return;
courierLocation.value = { lat: data.lat, lng: data.lng };
});
});
});
onUnmounted(() => {
channel?.unsubscribe();
pushler?.disconnect();
});
</script>
<style scoped>
.order-tracker { padding: 24px; }
.current-status {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: var(--dark-surface);
border-radius: 12px;
margin-bottom: 24px;
}
.current-status .icon { font-size: 2rem; }
.current-status .text { font-size: 1.25rem; font-weight: 600; }
.current-status .comment { color: var(--text-secondary); margin-top: 4px; }
.timeline {
display: flex;
justify-content: space-between;
position: relative;
}
.timeline::before {
content: '';
position: absolute;
top: 15px;
left: 30px;
right: 30px;
height: 2px;
background: var(--dark-border);
}
.step {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
z-index: 1;
}
.step .dot {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: var(--dark-surface);
border: 2px solid var(--dark-border);
margin-bottom: 8px;
font-size: 0.875rem;
}
.step.done .dot {
background: #22c55e;
border-color: #22c55e;
color: white;
}
.step.active .dot {
background: var(--primary-orange);
border-color: var(--primary-orange);
color: white;
animation: pulse 2s infinite;
}
.step .label { font-size: 0.75rem; color: var(--text-muted); }
.step.active .label { color: var(--primary-orange); font-weight: 600; }
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(255, 127, 31, 0.4); }
50% { box-shadow: 0 0 0 10px rgba(255, 127, 31, 0); }
}
</style>
⚠️ Типичные ошибки
1. Неправильный канал
PHP
// ❌ Неправильно — отправляем в канал заказа
$pushler->trigger("private-order-{$orderId}", 'order:status', $data);
// Клиенту придётся подписываться на каждый заказ!
// ✅ Правильно — отправляем в канал пользователя
$pushler->trigger("private-user-{$order['user_id']}", 'order:status', $data);
// Один канал для всех уведомлений пользователя
2. Нет проверки order_id на клиенте
JavaScript
// ❌ Неправильно — обновляем любой заказ
channel.on('order:status', (data) => {
updateUI(data); // Если открыто несколько вкладок с разными заказами!
});
// ✅ Правильно — проверяем order_id
channel.on('order:status', (data) => {
if (data.order_id !== currentOrderId) return;
updateUI(data);
});
Готовы попробовать?
Создайте бесплатный аккаунт и начните интеграцию за пару минут