Standby-режим — карусель брендированных слайдов

Роут: /standby (внутри ProtectedRoute + RequireShift) API: GET /pos/api/v1/marketing/active (POS BFF) SSE: marketing.invalidate (через useSSE)

Источники

Статус реализации

  • 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 }):

  1. На монтировании App.tsx (внутри ProtectedRoute + RequireShift) подключаются глобальные слушатели:
    • window.addEventListener('mousemove', resetTimer)
    • window.addEventListener('keydown', resetTimer)
    • window.addEventListener('touchstart', resetTimer)
    • window.addEventListener('click', resetTimer)
  2. Хук держит setTimeout на minutes * 60 * 1000. При любой активности — clearTimeout + setTimeout заново
  3. При срабатывании — navigate('/standby') (через React Router)
  4. На 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_minutesstores.standby_idle_minutes (через login-response или GET /pos/api/v1/store/config)5
standby_transition_secondsstores.standby_transition_seconds (то же место)9

После изменения в админке — поля на ТТ обновляются. POS подхватит при следующем логине (или через SSE, если будет добавлен event store.config.changed — Phase 2).


Состояния экрана

СостояниеЧто показываем
Loading (первый запрос)Полупрозрачный экран с фирменным логотипом
Loaded, есть слайдыКарусель
Loaded, нет слайдовEmpty fallback (логотип Альфа ERP)
Ошибка APIEmpty fallback (нельзя оставлять кассу без визуала)
Получен SSE invalidateReload в фоне, без видимого мигания

Производительность

  • Картинки в 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:

  1. На реальной кассе после 5 мин неактивности — запуск standby ✓
  2. Карусель листает 3 seed-слайда с интервалом 9 сек ✓
  3. Добавление слайда в админке — появляется на кассе ≤ 3 сек ✓ (через SSE)
  4. Клик на кассе — возврат на предыдущий экран ✓

Для демо можно временно понизить standby_idle_minutes до 1 (быстрее проверить), затем поднять обратно.


Открытые вопросы

  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 мин.

  2. Что если кассир открыл модалку (например, выбор клиента) и отошёл — должен ли запускаться standby? Скорее да — модалка тоже неактивность. useIdleTimer подключён глобально, не зависит от модалок.

  3. PIN-логин при выходе из standby — нужен ли? Нет, standby не разлогинивает. Просто navigate(-1). Сессия остаётся.


Ссылки