Веб-интерфейс сервиса пользовательской очереди перед чекаутом: пользователь выбирает дефицитный товар, встаёт в очередь, получает временное право на покупку и оформляет заказ, пока право действует.
| Технология | Зачем |
|---|---|
| React 19 + TypeScript | требование кейса, строгая типизация контракта |
| Vite | дев-сервер и сборка |
| Ant Design | UI-kit, готовые компоненты и токены темы |
| TanStack Query | запросы, кэш и поллинг статуса очереди |
| axios | HTTP-клиент, единая точка для заголовков |
| React Router | маршрутизация между экранами |
| Vitest + Testing Library | юнит-тесты |
| ESLint + Prettier | статический анализ и форматирование |
Нужен Node.js 20+.
npm install
cp .env.example .env.local # Windows: copy .env.example .env.local
npm run devПриложение поднимется на http://localhost:5173.
| Переменная | Значение | Описание |
|---|---|---|
VITE_USE_MOCKS |
true / false |
true — фронт работает автономно на локальном мок-движке, бэкенд не нужен. false — все запросы идут на http://localhost:8080 |
По умолчанию в .env.example стоит true, чтобы интерфейс можно было
проверить без поднятого бэкенда.
npm run dev # дев-сервер
npm run build # проверка типов + продакшен-сборка
npm run preview # просмотр собранной версии
npm run lint # ESLint
npm run format # Prettier, форматирование
npm run format:check # Prettier, только проверка
npm test # VitestТипы API не пишутся руками — они генерируются из openapi.yaml в корне
репозитория:
npx openapi-typescript openapi.yaml -o src/shared/api/schema.tsКоманду нужно повторить, если бэкенд изменил контракт.
Структура по мотивам Feature-Sliced Design — слои разделены по зонам ответственности, зависимости направлены сверху вниз:
src/
app/ точка входа, layout, тема Ant Design
pages/ экраны: каталог, карточка товара, очередь, оформление
features/
queue/ хуки очереди: статус с поллингом, вступление, выход, таймер TTL
shared/
api/ axios-клиент, слой запросов, schema.ts (генерируется)
ui/ переиспользуемые компоненты: Header, ProductCard, StateScreen
mocks/ мок-движок очереди и тестовый каталог товаров
Бэкенд в ответе QueueStatus присылает три поля, которые полностью
описывают, что показать пользователю:
state— машинное состояние (для логики и тестов);message— готовый текст для пользователя, фронт выводит его как есть;next_action— какое действие предложить кнопкой.
Поэтому все семь состояний рисует один компонент StateScreen, а не
семь отдельных экранов. Это даёт три вещи:
- Нет рассинхрона: тексты и правила живут в одном месте — на бэкенде. Изменение формулировки не требует релиза фронта.
- Нет «серой зоны»: любое состояние всегда имеет сообщение и следующий шаг, потому что этого требует сам контракт.
- Новое состояние на бэкенде не ломает интерфейс — оно отрисуется по тем же правилам.
| Состояние | Что видит пользователь | next_action |
|---|---|---|
not_in_queue |
товар продаётся через очередь, доступна кнопка покупки | join |
waiting |
позиция в очереди и оценка ожидания | wait |
granted |
право на покупку и обратный отсчёт TTL | checkout |
consumed |
заказ оформлен | browse_similar |
expired |
время на оформление истекло, право ушло дальше | rejoin |
released |
пользователь вышел сам | rejoin |
sold_out |
товар разобрали, пока пользователь ждал | browse_similar |
Статус опрашивается раз в 2 секунды, но только пока состояние активно
(waiting или granted). На терминальных состояниях поллинг сам
останавливается — опрашивать больше нечего, и это снимает лишнюю нагрузку
с бэкенда.
Обратный отсчёт считается от expires_at, полученного с бэкенда, а не
локальным счётчиком. Если вкладка была свёрнута или таймер отстал,
пользователь всё равно увидит корректное время, а не расхождение с
сервером. Хук построен на useSyncExternalStore: время — внешний
источник, а не состояние React.
Авторизация в MVP не реализуется по условию кейса, пользователь
передаётся заголовком X-User-Id. В шапке есть переключатель
«Покупатель 1 / Покупатель 2» — им демонстрируется конкуренция двух
покупателей за один товар.
При смене покупателя кэш запросов полностью сбрасывается, а с личных экранов (очередь, оформление) происходит переход на карточку товара: состояние предыдущего покупателя не должно перетечь к следующему.
Кнопка покупки стоит там же, где на обычной карточке товара, и называется
так же. Очередь не выглядит отдельным аттракционом — она включается
только для товаров с is_queue_enabled и только тогда, когда без неё
покупатели конкурировали бы за одну единицу.
Токен права никогда не хранится в состоянии компонента и не попадает в
URL — он берётся только из свежего ответа /queue/status. Скопированная
ссылка бесполезна: право проверяется по X-User-Id на стороне бэкенда.
Экран оформления /product/:id/checkout перед показом формы проверяет,
что у пользователя есть активное право именно на этот товар. Прямой заход
по ссылке без прохождения очереди упирается в отказ с объяснением и
кнопкой возврата к очереди. Знание URL не даёт обхода.
Кнопки «Выйти из очереди» и «Отказаться от покупки» — не декоративные. Когда покупатель отказывается сам, право уходит следующему сразу, не дожидаясь истечения TTL. Товар меньше простаивает заблокированным, а очередь движется быстрее.
Терминальные состояния sold_out и consumed ведут не в тупик, а к
предложению посмотреть похожие лоты. Даже неудачная попытка покупки
заканчивается следующим шагом, а не пустым экраном.
При VITE_USE_MOCKS=true слой запросов подменяется локальным мок-движком
(src/shared/mocks/queueEngine.ts). Он воспроизводит поведение бэкенда:
- общая очередь на товар, а не отдельная для каждого пользователя;
- позиция вычисляется от времени, а не хранится таймерами;
- ленивая экспирация: истёкшее право схлопывается при чтении статуса;
- идемпотентность: повторное вступление не создаёт второй тикет;
- количество одновременно выданных прав равно количеству единиц товара: при двух экземплярах двое оформляют параллельно, при одном — только один;
- когда сток исчерпан, остальные участники очереди видят
sold_out.
Состояние очередей хранится в localStorage, а не в памяти вкладки.
Это нужно, чтобы прямой заход по URL и перезагрузка страницы не сбрасывали
очередь, и чтобы конкуренцию покупателей можно было показать в двух окнах
браузера одновременно. Выбранный покупатель, наоборот, хранится в
sessionStorage — он свой для каждой вкладки.
В режиме моков в шапке доступна ссылка «Сбросить демо»: она очищает состояние очередей для чистого прогона сценария.
- Откройте товар с одной доступной единицей.
- Покупателем 1 нажмите кнопку покупки — позиция в очереди начнёт уменьшаться.
- Переключитесь на Покупателя 2 и встаньте в очередь на тот же товар — он окажется позади.
- Вернитесь к Покупателю 1: у него активное право и идёт отсчёт. У Покупателя 2 в это же время — ожидание. Право всегда одно.
- Дальше две ветки:
- Покупатель 1 оформляет заказ → Покупатель 2 видит
sold_out; - Покупатель 1 отказывается или ждёт истечения TTL → право немедленно переходит Покупателю 2.
- Покупатель 1 оформляет заказ → Покупатель 2 видит
- Для товара с двумя единицами (например, iPhone 15 Pro, 256 ГБ, Natural Titanium) оба покупателя получают право одновременно — система ограничивает не число покупателей, а число доступных экземпляров.
npm testПокрыты два самых чувствительных участка:
useCountdown— отсчёт TTL, поведение при отсутствии права и при свёрнутой вкладке дольше срока действия (не должен уходить в минус);StateScreen— что для каждого состояния отрисовывается правильное действие и что текст бэкенда выводится без изменений.
ESLint с рекомендованными наборами для TypeScript и React Hooks, плюс
eslint-config-prettier последним в цепочке — чтобы правила
форматирования не конфликтовали с Prettier.
Добавленные правила и их обоснование:
| Правило | Обоснование |
|---|---|
@typescript-eslint/no-explicit-any |
типы приходят из openapi.yaml и являются источником правды по контракту; any молча разрывает эту связь и прячет расхождения с бэкендом |
@typescript-eslint/no-unused-vars |
мёртвый код усложняет чтение; префикс _ оставлен для намеренно неиспользуемых аргументов |
no-console |
отладочные логи не должны попадать в сборку; warn и error разрешены для реальных проблем |
react-hooks/exhaustive-deps |
поллинг статуса и таймер TTL завязаны на зависимости хуков: при неполном списке очередь «замирает» на устаревших данных |
Формат кода единый, задаётся Prettier (.prettierrc). Сгенерированный
schema.ts исключён из форматирования — он перезаписывается при
генерации.
Разработка без бэкенда. Фронтенд и бэкенд писались параллельно, поэтому
интерфейс разрабатывался против контракта openapi.yaml, а не против
работающего API. Чтобы это было возможно, написан мок-движок, который
воспроизводит поведение сервера: общую очередь, TTL, ленивую экспирацию,
идемпотентность. Переключение между моками и реальным API — одна
переменная окружения, код при этом не меняется.
Гонка между рендером и кликом. Право на покупку может истечь между моментом отрисовки экрана и нажатием кнопки. Поэтому токен не сохраняется в состоянии компонента: он читается из свежего статуса непосредственно перед отправкой запроса, а окончательное решение всегда за бэкендом — фронт только отображает результат проверки.
Состояние при смене пользователя. Переключатель покупателей меняет
заголовок X-User-Id, но смонтированные компоненты и кэш запросов при
этом остаются от предыдущего пользователя. Решено сбросом кэша и уходом
с личных экранов очереди и оформления на карточку товара — иначе второй
покупатель видел бы чужое состояние.
Таймер и системное время. Обратный отсчёт изначально хранился в
состоянии React и обновлялся по интервалу — это давало расхождение с
сервером при свёрнутой вкладке. Переписан на useSyncExternalStore с
вычислением от expires_at: время стало внешним источником, а не копией
в состоянии.
Хранение состояния моков. Изначально очередь жила в памяти модуля —
любой прямой заход по URL перезагружал страницу и стирал состояние всех
участников. Перенесено в localStorage: поведение стало устойчивым к
перезагрузке, а состояние — общим для вкладок, что позволяет
демонстрировать конкуренцию покупателей в двух окнах.
- Авторизации нет, пользователь передаётся заголовком (по условию кейса).
- Оплата и доставка не реализуются — считаются существующей частью
продукта. Кнопка «Оплатить» создаёт заказ через
/api/checkoutи на этом сценарий завершается. - Поиск, категории и фильтры в шапке декоративны: они задают контекст маркетплейса, но не являются частью решаемой задачи.
- Данные продавца (рейтинг, отзывы, дата регистрации) статичны — в контракте этих полей нет.
При разработке фронтенда использовался Claude (Anthropic). Ниже — где именно и в каком объёме:
- Тестовые данные. Каталог мок-товаров (
shared/mocks/products.ts) сгенерирован полностью: названия, цены, города, описания. - Мок-движок очереди (
shared/mocks/queueEngine.ts) написан с помощью Claude по правилам, заданным контрактомopenapi.yaml. - Ревью кода. Проверка на мёртвый код, неиспользуемые импорты, рассинхрон состояния между экранами.
- Документация. Этот README составлен с помощью Claude на основе фактического кода проекта.
Архитектурные и продуктовые решения — разделение слоёв, отказ от собственной логики состояний на фронте, поллинг только на активных состояниях, проверка права перед оформлением — принимались командой. Весь сгенерированный код прочитан, проверен и при необходимости переписан вручную.