Реалізація елементів 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С або WMS, витягаємо через 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 дні.







