Skip to content

Repository files navigation

Frontend — «Авито Очередь»

Веб-интерфейс сервиса пользовательской очереди перед чекаутом: пользователь выбирает дефицитный товар, встаёт в очередь, получает временное право на покупку и оформляет заказ, пока право действует.

Стек

Технология Зачем
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, а не семь отдельных экранов. Это даёт три вещи:

  1. Нет рассинхрона: тексты и правила живут в одном месте — на бэкенде. Изменение формулировки не требует релиза фронта.
  2. Нет «серой зоны»: любое состояние всегда имеет сообщение и следующий шаг, потому что этого требует сам контракт.
  3. Новое состояние на бэкенде не ломает интерфейс — оно отрисуется по тем же правилам.

Состояния очереди

Состояние Что видит пользователь 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). На терминальных состояниях поллинг сам останавливается — опрашивать больше нечего, и это снимает лишнюю нагрузку с бэкенда.

Таймер TTL

Обратный отсчёт считается от 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 нажмите кнопку покупки — позиция в очереди начнёт уменьшаться.
  3. Переключитесь на Покупателя 2 и встаньте в очередь на тот же товар — он окажется позади.
  4. Вернитесь к Покупателю 1: у него активное право и идёт отсчёт. У Покупателя 2 в это же время — ожидание. Право всегда одно.
  5. Дальше две ветки:
    • Покупатель 1 оформляет заказ → Покупатель 2 видит sold_out;
    • Покупатель 1 отказывается или ждёт истечения TTL → право немедленно переходит Покупателю 2.
  6. Для товара с двумя единицами (например, 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: поведение стало устойчивым к перезагрузке, а состояние — общим для вкладок, что позволяет демонстрировать конкуренцию покупателей в двух окнах.

Ограничения MVP

  • Авторизации нет, пользователь передаётся заголовком (по условию кейса).
  • Оплата и доставка не реализуются — считаются существующей частью продукта. Кнопка «Оплатить» создаёт заказ через /api/checkout и на этом сценарий завершается.
  • Поиск, категории и фильтры в шапке декоративны: они задают контекст маркетплейса, но не являются частью решаемой задачи.
  • Данные продавца (рейтинг, отзывы, дата регистрации) статичны — в контракте этих полей нет.

Использование ИИ

При разработке фронтенда использовался Claude (Anthropic). Ниже — где именно и в каком объёме:

  • Тестовые данные. Каталог мок-товаров (shared/mocks/products.ts) сгенерирован полностью: названия, цены, города, описания.
  • Мок-движок очереди (shared/mocks/queueEngine.ts) написан с помощью Claude по правилам, заданным контрактом openapi.yaml.
  • Ревью кода. Проверка на мёртвый код, неиспользуемые импорты, рассинхрон состояния между экранами.
  • Документация. Этот README составлен с помощью Claude на основе фактического кода проекта.

Архитектурные и продуктовые решения — разделение слоёв, отказ от собственной логики состояний на фронте, поллинг только на активных состояниях, проверка права перед оформлением — принимались командой. Весь сгенерированный код прочитан, проверен и при необходимости переписан вручную.

About

Клиентская часть сервиса пользовательской очереди перед чекаутом: React 19, TypeScript, TanStack Query

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages