Standby-режим — карусель брендированных слайдов
Роут: /standby (внутри ProtectedRoute + RequireShift)
API: GET /pos/api/v1/marketing/active (POS BFF)
SSE: marketing.invalidate (через useSSE)
Источники
- Бизнес: Маркетинговая информация
- BR: BR 6.1
- API: POS BFF API
- SSE: POS BFF Events
Статус реализации
- Backend: не начат —
GET /pos/api/v1/marketing/activeв pos-bff и/internal/.../marketing-slides/activeв store-service ещё нет- Фронт: не начат — в
erp-pos-desktop/apps/desktop/src/screens/нетStandbyScreen.tsx, нет idle-timer hook’а- SSE:
marketing.invalidateещё не публикуется (useSSEнужно расширить)
Что видит кассир / гость
Когда касса не используется ≥ standby_idle_minutes (default 5) — экран переключается в full-screen карусель брендированных слайдов. Любой клик / тач / клавиатурное событие возвращает кассу в рабочее состояние (предыдущий screen, обычно /main).
Layout
┌────────────────────────────────────────────────────────────┐
│ │
│ │
│ [Полноэкранная картинка слайда] │
│ │
│ │
│ │
│ ● ● ○ ○ (индикаторы) │
└────────────────────────────────────────────────────────────┘
<img>занимает 100% viewport-а сobject-fit: cover(для картинок не 16:9 — обрезка краёв)- Чёрный фон под картинкой (если она прозрачная)
- Внизу — небольшие индикаторы текущего слайда (точки, серые/белые)
- Никаких других UI-элементов (нет логотипа, нет таймера, нет логина — это «дисплей-витрина»)
- Transition между слайдами —
fade(opacity 600ms)
Empty state
Если активных слайдов нет — fallback экран с логотипом Альфа ERP:
┌────────────────────────────────────────────────────────────┐
│ │
│ │
│ А Альфа ERP │
│ │
│ │
└────────────────────────────────────────────────────────────┘
Логотип — захардкожен в POS Desktop (не зависит от данных). Цвета — фирменные Альфы (#EF3124 + #000000).
Idle-timer (триггер запуска standby)
Реализуется как hook useIdleTimer({ minutes }):
- На монтировании
App.tsx(внутриProtectedRoute+RequireShift) подключаются глобальные слушатели:window.addEventListener('mousemove', resetTimer)window.addEventListener('keydown', resetTimer)window.addEventListener('touchstart', resetTimer)window.addEventListener('click', resetTimer)
- Хук держит
setTimeoutнаminutes * 60 * 1000. При любой активности —clearTimeout+setTimeoutзаново - При срабатывании —
navigate('/standby')(через React Router) - На
unmount— снимает слушатели и таймер
Параметр minutes — из конфига ТТ. Получается одним из способов:
- При логине кассира — добавить в response
/auth/loginполеstore.standby_idle_minutes(минимальные изменения) - Или отдельным
GET /pos/api/v1/store/config(новый endpoint) - Решено: расширим существующий login-response (см. Открытые вопросы)
Idle = после логина
Standby запускается только когда кассир залогинен (
RequireShiftоборачивает route). До логина — стандартныйLoginScreen. Pre-login standby — в Deferred бизнес-спеки.
Игнорируется на некоторых экранах
Standby не должен запускаться на:
/login— пока кассир не залогинен/standby— уже в standby/order/success— экран успеха заказа, обычно короткий- (опционально)
/shift/open,/shift/close— там кассир может думать
Реализация: hook принимает параметр enabled, выключается на этих route’ах через useLocation.
Standby Screen — компонент StandbyScreen.tsx
Загрузка данных
При монтировании:
const { slides, transitionSeconds } = await fetch('/pos/api/v1/marketing/active');Response:
{
"data": {
"slides": [
{ "id": "uuid", "image_url": "https://...", "order": 0 }
],
"standby_idle_minutes": 5,
"standby_transition_seconds": 9
}
}Карусель
const [currentIndex, setCurrentIndex] = useState(0);
useEffect(() => {
if (slides.length <= 1) return;
const id = setInterval(
() => setCurrentIndex(i => (i + 1) % slides.length),
transitionSeconds * 1000
);
return () => clearInterval(id);
}, [slides, transitionSeconds]);- Если 1 слайд — без интервала, просто статичная картинка
- Если 0 слайдов — fallback empty state
Transition
Cross-fade через два слоя <img> с opacity transition 600ms — старый блёкнет, новый появляется. Можно реализовать через CSS-класс .active на текущем.
Exit
Глобальный обработчик onClick/onTouchStart/onKeyDown на корневой div Standby Screen:
const handleExit = () => navigate(-1); // вернуться на предыдущий route, обычно /mainПосле navigate(-1) — обычный поток, idle-timer работает заново.
Live-обновление через SSE
При получении SSE marketing.invalidate (через useSSE):
useSSE({
'marketing.invalidate': () => {
// Перезагружаем активные слайды
refetch();
}
});- Если изменения — новый список приходит,
currentIndexкорректируется (если был = 5, а слайдов стало 3 →min(currentIndex, slides.length - 1)) - Cross-fade на новый слайд (если текущий удалён/деактивирован) либо плавная подмена при следующем transition
- В админке маркетолог нажимает «Сохранить» → за ≤ 3 секунды касса показывает новый слайд без F5
Расширение useSSE
Текущий hook (useSSE.ts) подписан на 3 события: menu.invalidate, table.invalidate, aggregator.invalidate. Нужно добавить четвёртое:
// useSSE.ts (псевдокод)
es.addEventListener('marketing.invalidate', (e) => {
// emit в глобальное событие или callback
marketingChannelEmitter.emit('invalidate', JSON.parse(e.data));
});StandbyScreen подписывается на marketingChannelEmitter (или просто использует useSSE с callbacks-параметром, если реализация так позволяет).
Конфигурация standby
Параметры берутся из:
| Параметр | Источник | Default |
|---|---|---|
standby_idle_minutes | stores.standby_idle_minutes (через login-response или GET /pos/api/v1/store/config) | 5 |
standby_transition_seconds | stores.standby_transition_seconds (то же место) | 9 |
После изменения в админке — поля на ТТ обновляются. POS подхватит при следующем логине (или через SSE, если будет добавлен event store.config.changed — Phase 2).
Состояния экрана
| Состояние | Что показываем |
|---|---|
| Loading (первый запрос) | Полупрозрачный экран с фирменным логотипом |
| Loaded, есть слайды | Карусель |
| Loaded, нет слайдов | Empty fallback (логотип Альфа ERP) |
| Ошибка API | Empty fallback (нельзя оставлять кассу без визуала) |
| Получен SSE invalidate | Reload в фоне, без видимого мигания |
Производительность
- Картинки в S3 — webp / прогрессивный jpeg, рекомендуется ≤ 800 КБ каждая (для 1080p хватит)
- POS Desktop кэширует картинки в браузерном кэше (HTTP
Cache-Control: max-age=3600) - При SSE invalidate — request на
/marketing/activeидёт, но если URL’ы не поменялись — браузер берёт из кэша - Slide preload: при mounting сразу
new Image().src = nextSlide.image_urlдля следующего слайда (избежать flash)
Тестирование (для демо 29.05)
Acceptance из BR 6.1:
- На реальной кассе после 5 мин неактивности — запуск standby ✓
- Карусель листает 3 seed-слайда с интервалом 9 сек ✓
- Добавление слайда в админке — появляется на кассе ≤ 3 сек ✓ (через SSE)
- Клик на кассе — возврат на предыдущий экран ✓
Для демо можно временно понизить standby_idle_minutes до 1 (быстрее проверить), затем поднять обратно.
Открытые вопросы
-
Откуда POS Desktop берёт
standby_idle_minutes/standby_transition_seconds? Варианты:- Расширить response
/auth/loginполемstore_config(рекомендуется — минимум изменений) - Новый endpoint
GET /pos/api/v1/store/config - Включить в response
/pos/api/v1/marketing/active(уже там — см. контракт). Тогда idle-timer берёт конфиг из первого ответа на mount /standby и пересохраняет в state/localStorage.
Решено: конфиг идёт внутри ответа
/marketing/active+ первый запрос делается при логине (preload). До этого idle-timer работает с default 5 мин. - Расширить response
-
Что если кассир открыл модалку (например, выбор клиента) и отошёл — должен ли запускаться standby? Скорее да — модалка тоже неактивность.
useIdleTimerподключён глобально, не зависит от модалок. -
PIN-логин при выходе из standby — нужен ли? Нет, standby не разлогинивает. Просто
navigate(-1). Сессия остаётся.