Реализация Urgency/Scarcity элементов (таймер, ограниченное количество) на сайте
Urgency (срочность) и scarcity (дефицит) — классические принципы Чалдини, работающие в e-commerce уже 20 лет. Проблема: большинство реализаций либо технически ненадёжны (таймер сбрасывается при обновлении страницы), либо выглядят как очевидная манипуляция (счётчик «осталось 3 штуки», который никогда не меняется). Ниже — реализация, которая работает честно и технически правильно.
Таймер обратного отсчёта
Требование к надёжности: таймер не должен сбрасываться при обновлении страницы. Нельзя делать это через new Date() + N минут при каждом монтировании компонента.
Правильная схема:
- При первом посещении страницы акции создаём запись в Redis с TTL
- При последующих посещениях берём оставшееся время из Redis
- Для неаутентифицированных — ключ по
sessionIdиз cookie
// Получение или создание таймера сессии
public function getCountdown(Request $request, string $promoCode): array
{
$sessionId = $request->cookie('session_id') ?? Str::uuid()->toString();
$key = "countdown:{$promoCode}:{$sessionId}";
$ttl = Redis::ttl($key);
if ($ttl <= 0) {
$duration = 1800; // 30 минут
Redis::setex($key, $duration, now()->addSeconds($duration)->timestamp);
$ttl = $duration;
}
return [
'ends_at' => now()->addSeconds($ttl)->toIso8601String(),
'session_id' => $sessionId,
];
}
Компонент таймера (React):
const CountdownTimer: React.FC<{ endsAt: string }> = ({ endsAt }) => {
const [timeLeft, setTimeLeft] = useState(0);
useEffect(() => {
const target = new Date(endsAt).getTime();
const tick = () => {
const diff = Math.max(0, target - Date.now());
setTimeLeft(diff);
};
tick();
const interval = setInterval(tick, 1000);
return () => clearInterval(interval);
}, [endsAt]);
const hours = Math.floor(timeLeft / 3_600_000);
const minutes = Math.floor((timeLeft % 3_600_000) / 60_000);
const seconds = Math.floor((timeLeft % 60_000) / 1000);
if (timeLeft === 0) return <ExpiredBanner />;
return (
<div className="countdown" role="timer" aria-live="polite">
<Digit value={hours} label="ч" />
<Digit value={minutes} label="м" />
<Digit value={seconds} label="с" />
</div>
);
};
Компонент Digit добавляет flip-анимацию при смене значения через CSS @keyframes.
Индикатор остатков товара
Показывать реальные остатки из складской системы — лучшая практика. Если остаток ≤ N единиц, показываем предупреждение:
const StockIndicator: React.FC<{ stock: number }> = ({ stock }) => {
if (stock > 10) return null;
return (
<div className={`stock-indicator stock-indicator--${stock <= 3 ? 'critical' : 'low'}`}>
{stock <= 3
? `Последние ${stock} шт.`
: `Осталось ${stock} шт. — заканчивается`}
</div>
);
};
Синхронизация с реальным складом — если используется 1С или Warehouse Management System, остатки подтягиваются через API и кэшируются в Redis на 5 минут:
public function getStockLevel(int $productId): int
{
return Cache::remember("stock:{$productId}", 300, function () use ($productId) {
return $this->warehouseApi->getAvailableQuantity($productId);
});
}
Важно: не показывать точный остаток для очень популярных товаров — это создаёт эффект «стадного поведения» (другие тоже хотят этот товар) и немного усиливает конверсию.
«X человек смотрят прямо сейчас»
Счётчик активных пользователей на странице товара — реальный или приближённый.
Реальная реализация через Redis:
// При загрузке страницы товара
public function trackView(int $productId, string $sessionId): int
{
$key = "viewers:{$productId}";
Redis::zadd($key, time(), $sessionId);
Redis::zremrangebyscore($key, 0, time() - 300); // убираем старше 5 минут
Redis::expire($key, 600);
return Redis::zcard($key);
}
Для обновления в реальном времени — либо polling каждые 30 секунд, либо Server-Sent Events:
// SSE endpoint
public function viewersStream(int $productId): StreamedResponse
{
return response()->stream(function () use ($productId) {
while (true) {
$count = $this->viewerService->getCount($productId);
echo "data: {\"viewers\": {$count}}\n\n";
ob_flush();
flush();
sleep(30);
}
}, 200, [
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
]);
}
// На клиенте
useEffect(() => {
const es = new EventSource(`/api/products/${productId}/viewers`);
es.onmessage = (e) => setViewers(JSON.parse(e.data).viewers);
return () => es.close();
}, [productId]);
Flash sale (акция на ограниченное время)
Flash sale требует атомарной работы с остатками — без конкурентных записей:
// Lua-скрипт в Redis для атомарного декремента
$script = <<<'LUA'
local key = KEYS[1]
local current = tonumber(redis.call('GET', key) or '0')
if current <= 0 then
return -1
end
return redis.call('DECR', key)
LUA;
$remaining = Redis::eval($script, 1, "flash_sale:{$saleId}:stock");
if ($remaining < 0) {
return response()->json(['error' => 'Товар закончился'], 422);
}
«Последний в корзине»
Если товар добавлен в корзину другими пользователями и реальный остаток совпадает или ниже количества зарезервированных единиц:
{isLastInCart && (
<Alert variant="warning">
Этот товар есть в корзинах других покупателей. Оформите заказ, чтобы зарезервировать его.
</Alert>
)}
Техническая реализация: таблица cart_reservations(product_id, quantity, session_id, expires_at). Резервирование снимается через 30 минут или при оформлении заказа.
Этика и антипаттерны
Urgency-элементы работают, когда они честны. Паттерны, которые вредят репутации:
- Таймер, который сбрасывается при каждом заходе на страницу
- «Осталось 2 штуки» на товаре с постоянным наличием
- Счётчик просматривающих, который рандомно генерируется на клиенте
Эти приёмы краткосрочно повышают конверсию, но долгосрочно разрушают доверие — пользователи замечают несоответствия.
Сроки
| Задача | Время |
|---|---|
| Countdown timer (Redis + компонент) | 1 день |
| Индикатор остатков (реальные данные) | 0.5 дня |
| Счётчик просматривающих (SSE) | 1 день |
| Flash sale с Redis-резервированием | 1–2 дня |
Базовый набор (таймер + остатки): 1.5–2 дня.







