Compare commits

...
Author SHA1 Message Date
tremble7681andClaude Fable 5 a2557897a0 fix(login): обратный отсчёт кода + возврат на оплату после входа
Жалоба: по ссылке на оплату жмёшь «получить код» — «слишком часто».
- на шаге кода кнопка «отправить ещё раз» с отсчётом (60с, синхронно с
  бэкендом); на 429 ведём отсчёт по Retry-After и не пугаем
- кнопка «Получить код» блокируется на время кулдауна
- RequireAuth запоминает путь (ссылку на оплату); после входа по коду и
  через Telegram возвращаем туда, а не на баланс

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 15:22:05 +10:00
tremble7681andClaude Fable 5 a7d5f27b53 tests(pwa): часы в тестах баланса заморожены — мок-штраф не выпадает из окна недели
Экран режет постановления по периоду «последние 7 дней от сегодня», а даты
в моках фиксированные: с 21.08 штраф от 13.08 вышел из окна, и два теста
падали на чистом HEAD. Морозим только Date (таймеры настоящие, иначе
userEvent/waitFor зависнут) на 18.08 — день, когда появился фильтр.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 20:49:54 +10:00
tremble7681andClaude Fable 5 0c433280fe balance: экран опрашивает себя после оплаты — итог обновляется на глазах
Первая обратная связь: «очень долго обновляется баланс». Экран показывал
старый итог, пока приложение не перезапустят: перезапроса по расписанию не
было вовсе.

- баланс опрашивается каждые 4 сек, пока висит «Зачисляется» ЛИБО в последние
  5 минут была платёжная активность (lib/paymentWatch, отметка в
  sessionStorage — переживает перезагрузку страницы при возврате из банка)
- отметка ставится при создании платежа (TopupPage) и при факте оплаты
  (PaymentPage): опрос успевает застать появление «Зачисляется» при
  запаздывающем вебхуке и не гаснет раньше, чем снимок баланса догонит платёж
  при сбое точечного обновления на бэке

Парная правка бэкенда — crm2 b64ea35 (запись в 1С каждые 15 сек + точечный
снимок баланса).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 20:38:49 +10:00
tremble7681andClaude Opus 5 80ac25ee0f Продление сессии: приложение подхватывает свежий токен из ответа
Бэкенд с пятого дня жизни токена кладёт продлённый в заголовок X-Driver-Token
(crm2, modules/payments/driver_auth). Здесь — подмена: тридцать дней начинают
считаться от последнего захода, а не от входа, и водитель перестаёт раз в месяц
упираться в экран входа.

Подменяем только тот токен, с которым сами ушли. Сеанс мог смениться, пока
запрос был в пути — водитель вышел, сотрудник начал просмотр от чужого лица, —
и продление чужого ответа вернуло бы закрытый сеанс к жизни. Просмотр от лица
водителя не продлеваем: его восемь часов сознательные.

README: приложение запущено для всех 22.08.2026, гейт по номерам снят. Раздел
оставлен как инструкция на случай, если вход снова понадобится сузить.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 22:19:42 +10:00
tremble7681andClaude Opus 5 00006ed51b Лента уведомлений: колокольчик, экран и честная отметка «прочитано»
Пуш живёт до первого смахивания, сообщение в боте тонет в переписке — и
разговор «мне ничего не приходило» упирался в слово против слова. Теперь у
водителя есть место, куда посмотреть, а у парка — отметка о прочтении, которая
ставится ровно тогда, когда человек открыл ленту или нажал на уведомление.

Отмечаем ПОСЛЕ показа списка, а не при загрузке: иначе счётчик гаснет у того,
кто мазнул по экрану и ушёл. Время первого прочтения бэкенд не переписывает.

Нажатие по пушу: воркер сам отметить не может (токен лежит в хранилище
страницы, ему недоступном), поэтому номер уведомления приезжает в пуше, воркер
добавляет его к адресу параметром, а страница отмечает и параметр убирает.

Колокольчик по умолчанию выключен в общей шапке и включён только на главной:
шапка стоит на каждом экране, и незачем ходить за счётчиком с оплаты.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 23:24:55 +10:00
tremble7681andClaude Opus 5 9aa71d6ca7 feat(pwa): тумблер боевой оплаты в отладке
Выключен по умолчанию: пополнение под просмотром открывает учебный экран. Включён
— платёж уходит в банк, и над кнопкой «Оплатить» появляется предупреждение, что
деньги спишутся по-настоящему и зачислятся водителю.

Режим гаснет вместе с выходом из учётки сотрудника: следующий раз он включается
осознанно, а не достаётся по наследству.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:30:55 +10:00
tremble7681andClaude Opus 5 7342a6c9d5 feat(pwa): на экране только включение уведомлений, выключения нет
Строка предлагает включить, пока уведомления выключены, и исчезает, когда они
включены. Кнопка «выключить» рядом с балансом только подталкивала бы отказаться
от двух событий, ради которых приложение и открывают.

Отключение при этом никуда не делось: оно осталось системным — в настройках
уведомлений телефона или в настройках сайта в браузере, там же, где человек
управляет всеми остальными приложениями.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 13:30:19 +10:00
tremble7681andClaude Opus 5 4b30b4bbbe feat(pwa): предложение включить уведомления после установки
Карточка появляется в установленном приложении (и сразу по событию appinstalled),
объясняет пользу — новый штраф, дошедшее пополнение — и только по кнопке вызывает
системное окно.

Порядок именно такой, потому что системное окно показывается ОДИН раз за
установку: отказ браузер запоминает навсегда, и переспросить нечем. Своё
предложение отказа не стоит — «не сейчас» откладывает разговор на неделю.

Во вкладке браузера карточку не показываем: на iPhone уведомления существуют
только в установленном приложении, а человеку, ещё не решившему, нужен ли ему
сайт, вопрос про уведомления мешает.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 10:50:23 +10:00
tremble7681andClaude Opus 5 562a6d35fe feat(pwa): свой сервис-воркер и включение уведомлений
Воркер теперь собирается через injectManifest: generateSW умеет только кэш, а
уведомление показывает именно воркер — страница при этом может быть закрыта.
Нажатие на уведомление переиспользует уже открытое окно, а не плодит второе.

Включение — строкой на главном экране, по нажатию. Не всплывающим окном на
входе: разрешение спрашивается один раз за установку, отказ браузер запоминает
навсегда, и потраченная впустую попытка закрывает канал. Если сервер уведомления
не настроил — разрешение не запрашиваем вовсе.

Отдельные тексты вместо молчания: на iPhone без установки на домашний экран
уведомления невозможны в принципе, и об этом сказано прямо.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 10:29:43 +10:00
tremble7681andClaude Opus 5 0a09570d98 refactor(pwa): штрафы живут только на главном экране
Отдельный список штрафов удалён: два списка одного и того же в разных местах
сбивали с толку. Фильтр «Штрафы» внизу главного экрана теперь показывает сами
постановления, а не проводки по ним, и ведёт в карточку с фотографией.

Разница между постановлением и проводкой здесь принципиальная: штраф, который
водитель оплатил сам, парк не выставлял — проводки нет, и в выписке он бы не
появился вовсе (Морозов, штраф от 16.08). Строка «Долг по штрафам» вверху теперь
включает этот фильтр, а не уводит на другой экран.

Период действует и на штрафы: календарь над списком не должен стоять, ничего не
меняя.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:40:06 +10:00
tremble7681andClaude Opus 5 62f8baa80f fix(pwa): под иконкой одно слово — «Водитель»
«Премиум Водитель» под иконкой не помещается: Android обрезает подпись примерно
на двенадцати символах, и на экране оставалось «Премиум» — неотличимо от других
приложений парка. Короткое имя теперь «Водитель», полное («Премиум Водитель»)
осталось для установки и списка приложений.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:24:16 +10:00
tremble7681andClaude Opus 5 7204290e45 fix(pwa): штрафы не прячутся за вкладкой «Не погашены»
Водитель, оплативший штраф сам, видел пустой экран: список открывался на
«Не погашены», а там у него ноль (Морозов, штраф от 16.08 — оплачен им, парк его
не выставлял). Выглядело как «приложение потеряло мои штрафы».

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 19:22:16 +10:00
tremble7681andClaude Opus 5 b0a6b7ec12 fix(pwa): под иконкой подписано «Премиум Водитель»
short_name был «Премиум» — под иконкой на телефоне стояло именно оно, и от
приложения механика оно ничем не отличалось. Плюс apple-mobile-web-app-title:
iOS подпись из манифеста не читает и берёт её только из этого мета-тега.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 18:29:42 +10:00
tremble7681andClaude Opus 5 53394b9478 fix(pwa): иконка приложения — значок сайта, а не отвергнутый знак
Предыдущая иконка была собрана из assets/logos/premium_park_favicon_pack — это
неудачный вариант знака, владелец его забраковал. Ставим то, что стоит на самом
сайте: красная плитка --s-accent с белой «П», как в pptaxi.ru/favicon.svg. Значок
в браузере, на экране телефона и на сайте теперь один.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 18:28:59 +10:00
tremble7681andClaude Opus 5 388c8e7343 feat(pwa): иконка приложения — фирменный знак парка
Была нарисованная «карта» на кремовом фоне из первых набросков. Теперь знак
«Премиум Парк» из фирменного набора, на белой подложке сайта.

Собирается из вектора скриптом scripts/gen-icons.py (три пути, команды M/L/H/V/C,
сглаживание сверхдискретизацией ×4) — прежний gen-icons.mjs рисовал плитку
вручную и к логотипу отношения не имел. Готовый PNG из набора не подошёл: знак
внутри него занимает 264×195 из 512, и на иконке он бы размылился.

Отдельная maskable-версия с полями: Android режет иконку по кругу, и знак без
запаса теряет края. Ссылка apple-touch-icon в index.html — iOS манифест не
читает и берёт иконку «на экран Домой» только оттуда.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 18:08:00 +10:00
tremble7681andClaude Opus 5 3bdd5ce818 feat(pwa): переход в штраф из выписки, заметные даты, минус раздел пополнений
Дата в выписке и в списке штрафов встала первой строкой чернильным цветом: в
этих списках ищут «что было 12-го», а бледная подпись под заголовком для этого
не годилась.

Строка штрафа в выписке стала кнопкой в карточку — там фотография и подробности.
Остальные строки не притворяются ссылками.

Раздел «Мои пополнения» удалён вместе с кнопкой: он показывал только наши
платежи и у большинства водителей был пуст (1С проводит пополнения сама), а те
же операции видны в выписке фильтром «Пополнения».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 17:58:28 +10:00
tremble7681andClaude Opus 5 0c7735ef85 style(pwa): палитра приложения переведена на палитру сайта pptaxi.ru
Светлый сине-серый фон #EEF1F5, белые карточки, чернильный текст #0F172A,
фирменный красный акцент #DC2626 у кнопки действия, зелёный #059669 у прихода
денег, янтарный #D97706 у предупреждений — токены сняты с живого сайта
(переменные --s-* в его инлайн-CSS).

Имена токенов теперь роли, а не краски: canvas / surface / line / ink / muted /
accent / pos / neg / warn. Прежние cream и ink описывали конкретный цвет, и при
смене палитры «cream» стал бы синим.

Тема браузера и манифест PWA переведены на новый фон — иначе шторка адресной
строки и заставка запуска остаются от прежней темы.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 17:53:08 +10:00
tremble7681andClaude Opus 5 3bf26d3650 fix(pwa): в штрафах только факт и сумма, без кухни расчётов
С карточки убраны строки про номинал постановления, скидку и «в ГИБДД
постановление закрыто», из списка — абзац о том, кто и как платит ГИБДД.
Остаётся то, ради чего экран делался: фотография нарушения, статья, место,
скорость, начисленная сумма и погашен ли долг.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 17:42:59 +10:00
tremble7681andClaude Opus 5 eac59b823c fix(pwa): в штрафах «погашен», а не «оплачен»
Слово «оплачен» водитель читает как «я ничего не должен», а постановление к тому
моменту оплачено парком — не им. Поэтому в списке и карточке состояние теперь
«не погашен / погашен / не выставлялся / уточняется», долг в шапке берётся из
баланса 1С, а карточка объясняет расхождение сумм: «постановление — 750 ₽, парк
оплатил его со скидкой и выставил вам фактическую сумму».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 17:36:01 +10:00
tremble7681andClaude Opus 5 f4a9da25cf feat(pwa): экран штрафов с фотографиями нарушений
Список с вкладками «Неоплаченные / Все» и суммой долга в шапке; в карточке —
фотография с камеры первой, до реквизитов: по ней водитель узнаёт свою машину и
свою поездку. Дальше сумма, скидка с датой (то, ради чего заходят вовремя),
скорость и разрешённый предел, постановление, камера, ссылка на PDF.

Строка «Долг по штрафам» на главном экране теперь ведёт сюда, а не сразу к
оплате: платить, не поняв за что, человек не станет. Аренда по-прежнему ведёт к
оплате — за тем её и открывают.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 17:18:58 +10:00
tremble7681andClaude Opus 5 9187b5766e feat(pwa): значки операций в выписке
За неделю у активного водителя два десятка строк, и половина одинаковая по виду
(«аренда автомобиля» каждый день) — список сливается в стену текста. Значок даёт
зацепку для глаза: нужное находится, не читая каждую строку.

Подбор по тегу 1С, а не по знаку суммы: аренда, пени, штраф, ДТП, депозит,
пополнение, компенсация, коррекция — у каждого свой. Общий компонент, как в
складе CRM: разложенные по местам иконки разъезжаются размерами при первой правке.

Монохромные намеренно — цвет в этом списке уже занят и означает направление
денег, цветной значок с ним бы спорил.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 17:06:02 +10:00
tremble7681andClaude Opus 5 6becdd036c feat(pwa): аренда и штрафы видны прямо на главном экране
Две статьи, которые водитель проверяет каждый день: за них он платит и из-за них
ему звонят. Стоят под итогом, нажатие ведёт сразу к оплате этой статьи — чаще
всего человек за тем и открыл приложение, а заход в шторку это лишний шаг.

Остальные статьи (депозит, повреждения, прочее) на экран не выносим: они редкие,
им хватает шторки, иначе экран снова превращается в столбик из шести строк и
выписка уезжает за нижний край телефона.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 17:02:13 +10:00
tremble7681andClaude Opus 5 2d4b66d51f feat(pwa): главный экран из двух половин — деньги сверху, выписка снизу
По образцу приложения вендора: баланс в парке и «зачисляется», под ними крупная
кнопка «Пополнить», ниже — период с календарями, фильтры и список операций
(аренда, пени, штрафы, пополнения).

Разбивка по статьям переехала в шторку «Пополнить». Она нужна ровно в момент
выбора, куда платить; всё остальное время это шесть строк, отодвигающих выписку
за нижний край телефона. В шторке только те статьи, в которые платёж проводится.

Экран суммы, придя из шторки, показывает выбранную категорию строкой, а не рядом
чипов: второй выбор того же самого сбивает с толку.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:35:01 +10:00
tremble7681andClaude Opus 5 8b0ce4cf45 fix(pwa): экран баланса повторяет карточку CRM
Итог берётся с сервера, а не складывается из строк (1С округляет статьи до
рублей, итог держит с копейками: −8 976 против −8 976,25). Статья, в которую
платёж провести нечем, показывается, но не притворяется кнопкой — «Страховка».
Пустой снимок 1С даёт «Уточняется» вместо нулей.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:46:35 +10:00
tremble7681andClaude Opus 5 39ee2b7a49 fix(pwa): код двухфакторки уходит сам, а не ждёт кнопки
Второй шаг входа сотрудника не отправлялся вовсе: у поля с кодом не было ни
обработки Enter, ни автоотправки, а «Готово» на клавиатуре телефона — это как
раз Enter. Со стороны выглядело как «код не принят», хотя запрос на /auth/2fa
не уходил ни разу (в логах бэкенда — только login с ответом «нужен второй шаг»).

Теперь шесть цифр отправляются сразу, Enter тоже работает, поле получает фокус и
autocomplete=one-time-code (подсказка кода из СМС/менеджера паролей).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:19:11 +10:00
tremble7681andClaude Opus 5 a0ada7de67 feat(pwa): просмотр чужого экрана больше не выбрасывает из своего аккаунта
Сотрудник, вошедший водителем, при переключении на чужую карточку терял
собственный вход: токен один на приложение, и «посмотреть, как выглядит у
другого» означало заново входить через Telegram.

Теперь свой сеанс откладывается и возвращается кнопкой «Вернуться к своему
аккаунту». Откладывается ровно один раз — на переходе из обычного сеанса:
смена водителя внутри просмотра не должна подменять отложенное чужим токеном.

Заодно вход для сотрудника достижим изнутри приложения: длинный тап по названию
в шапке ведёт на /dev. Ссылкой на экране это не делаем — водителю она ни к чему,
а сотруднику иначе приходится набирать адрес руками.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 15:00:22 +10:00
56 changed files with 2177 additions and 309 deletions
+5 -4
View File
@@ -92,12 +92,13 @@ npm run build # tsc -b && vite build → dist/
`../../docs/superpowers/plans/2026-06-18-premium-driver-pwa.md`, раздел `../../docs/superpowers/plans/2026-06-18-premium-driver-pwa.md`, раздел
«Deployment notes»: статика на `app.pptaxi.ru`, Caddy с фолбэком на `index.html`. «Deployment notes»: статика на `app.pptaxi.ru`, Caddy с фолбэком на `index.html`.
## Обкатка: кто может войти и как смотреть чужой экран ## Кто может войти и как смотреть чужой экран
Приложение выложено на боевой домен, а обкатывается интерфейс. Поэтому на время > **Запущено 22.08.2026: вход открыт всем водителям.** Список номеров ниже пуст,
обкатки включены две вещи. > то есть гейт выключен. Раздел оставлен как инструкция на случай, если вход
> снова понадобится сузить — например под обкатку следующей крупной правки.
### 1. Вход только по списку номеров ### 1. Вход только по списку номеров (сейчас выключено)
Переменная бэкенда (`crm2/backend/.env`, читается в `app/config.py`): Переменная бэкенда (`crm2/backend/.env`, читается в `app/config.py`):
+6 -1
View File
@@ -3,7 +3,12 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#F3EEE3" /> <meta name="theme-color" content="#EEF1F5" />
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png" />
<!-- iOS манифест не читает: иконку «на экран Домой» он берёт только отсюда. -->
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<!-- iOS берёт подпись под иконкой отсюда, а не из манифеста. -->
<meta name="apple-mobile-web-app-title" content="Водитель" />
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet" />
+3 -1
View File
@@ -33,7 +33,9 @@
"typescript": "^5.5.4", "typescript": "^5.5.4",
"vite": "^5.4.2", "vite": "^5.4.2",
"vite-plugin-pwa": "^0.20.5", "vite-plugin-pwa": "^0.20.5",
"vitest": "^2.0.5" "vitest": "^2.0.5",
"workbox-core": "^7.4.1",
"workbox-precaching": "^7.4.1"
} }
}, },
"node_modules/@adobe/css-tools": { "node_modules/@adobe/css-tools": {
+3 -1
View File
@@ -37,6 +37,8 @@
"typescript": "^5.5.4", "typescript": "^5.5.4",
"vite": "^5.4.2", "vite": "^5.4.2",
"vite-plugin-pwa": "^0.20.5", "vite-plugin-pwa": "^0.20.5",
"vitest": "^2.0.5" "vitest": "^2.0.5",
"workbox-core": "^7.4.1",
"workbox-precaching": "^7.4.1"
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 909 B

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

@@ -1,13 +1,14 @@
{ {
"name": "Премиум Водитель", "name": "Премиум Водитель",
"short_name": "Премиум", "short_name": "Водитель",
"start_url": "/", "start_url": "/",
"display": "standalone", "display": "standalone",
"orientation": "portrait", "orientation": "portrait",
"background_color": "#F3EEE3", "background_color": "#EEF1F5",
"theme_color": "#F3EEE3", "theme_color": "#EEF1F5",
"icons": [ "icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
] ]
} }
-76
View File
@@ -1,76 +0,0 @@
// Генерация PWA-иконок «Премиум Водитель» без сторонних либ (zlib PNG-энкодер).
// Дизайн: кремовый фон #F3EEE3, тёмная скруглённая плитка #1C1B19,
// внутри белая «карта» #F6F2E9 с тёмной полосой (финансовый мотив). Без жёлтого/текста.
import zlib from "node:zlib";
import fs from "node:fs";
import path from "node:path";
const CREAM = [243, 238, 227];
const INK = [28, 27, 25];
const CARD = [246, 242, 233];
function crc32(buf) {
let c = ~0;
for (let i = 0; i < buf.length; i++) {
c ^= buf[i];
for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
}
return (~c) >>> 0;
}
function chunk(type, data) {
const t = Buffer.from(type, "ascii");
const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0);
const body = Buffer.concat([t, data]);
const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(body), 0);
return Buffer.concat([len, body, crc]);
}
function encodePng(N, rgba) {
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(N, 0); ihdr.writeUInt32BE(N, 4); ihdr[8] = 8; ihdr[9] = 6;
const stride = N * 4;
const raw = Buffer.alloc(N * (stride + 1));
for (let y = 0; y < N; y++) {
raw[y * (stride + 1)] = 0;
rgba.copy(raw, y * (stride + 1) + 1, y * stride, y * stride + stride);
}
return Buffer.concat([sig, chunk("IHDR", ihdr), chunk("IDAT", zlib.deflateSync(raw)), chunk("IEND", Buffer.alloc(0))]);
}
function inRoundRect(px, py, x0, y0, x1, y1, r) {
if (px < x0 || px >= x1 || py < y0 || py >= y1) return false;
const cx = px < x0 + r ? x0 + r : px >= x1 - r ? x1 - r : px;
const cy = py < y0 + r ? y0 + r : py >= y1 - r ? y1 - r : py;
const dx = px - cx, dy = py - cy;
return dx * dx + dy * dy <= r * r;
}
function draw(N) {
const buf = Buffer.alloc(N * N * 4);
// tile (centered ~64%)
const tile = Math.round(N * 0.64), tx0 = Math.round((N - tile) / 2), ty0 = tx0;
const tx1 = tx0 + tile, ty1 = ty0 + tile, tr = Math.round(tile * 0.24);
// card inside tile
const cw = Math.round(tile * 0.62), ch = Math.round(tile * 0.42);
const cx0 = Math.round((N - cw) / 2), cy0 = Math.round((N - ch) / 2);
const cx1 = cx0 + cw, cy1 = cy0 + ch, cr = Math.round(ch * 0.18);
// stripe near top of card
const sy0 = cy0 + Math.round(ch * 0.22), sy1 = sy0 + Math.round(ch * 0.14);
for (let y = 0; y < N; y++) {
for (let x = 0; x < N; x++) {
let col = CREAM;
if (inRoundRect(x, y, tx0, ty0, tx1, ty1, tr)) col = INK;
if (inRoundRect(x, y, cx0, cy0, cx1, cy1, cr)) col = CARD;
if (x >= cx0 && x < cx1 && y >= sy0 && y < sy1) col = INK;
const o = (y * N + x) * 4;
buf[o] = col[0]; buf[o + 1] = col[1]; buf[o + 2] = col[2]; buf[o + 3] = 255;
}
}
return encodePng(N, buf);
}
const dir = path.resolve("public/icons");
fs.mkdirSync(dir, { recursive: true });
for (const N of [192, 512]) {
fs.writeFileSync(path.join(dir, `icon-${N}.png`), draw(N));
console.log(`wrote icon-${N}.png`);
}
+63
View File
@@ -0,0 +1,63 @@
"""Иконки приложения — тот же значок, что у сайта pptaxi.ru.
Сайт отдаёт `favicon.svg`: красная скруглённая плитка --s-accent и белая «П».
Повторяем её здесь, чтобы значок в браузере, на экране телефона и на сайте был
один и тот же.
Рисуем сами (Pillow), потому что тянуть растеризатор SVG ради одной плитки с
буквой незачем. Сглаживание — сверхдискретизацией ×4.
Запуск (нужен Pillow):
python scripts/gen-icons.py
"""
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
OUT = Path(__file__).resolve().parent.parent / "public" / "icons"
ACCENT = (220, 38, 38, 255) # --s-accent сайта
PAPER = (255, 255, 255, 255) # --s-paper
SS = 4
# Шрифт ищем среди системных: у сайта заголовки набраны Manrope, но ради одной
# буквы тащить файл шрифта в репозиторий не стоит — берём ближайший гротеск.
FONT_CANDIDATES = [
r"C:\Windows\Fonts\segoeuib.ttf",
r"C:\Windows\Fonts\arialbd.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
]
def _font(size: int) -> ImageFont.FreeTypeFont:
for path in FONT_CANDIDATES:
if Path(path).exists():
return ImageFont.truetype(path, size)
raise SystemExit("не нашёл жирный шрифт для буквы")
def compose(size: int, inset: float, radius_ratio: float = 0.22) -> Image.Image:
"""inset — доля стороны под плитку. У maskable она меньше: Android режет
иконку по кругу, и плитка без запаса теряет углы."""
big = size * SS
img = Image.new("RGBA", (big, big), PAPER)
draw = ImageDraw.Draw(img)
pad = big * (1 - inset) / 2
box = (pad, pad, big - pad, big - pad)
draw.rounded_rectangle(box, radius=(box[2] - box[0]) * radius_ratio, fill=ACCENT)
letter = "П"
font = _font(int((box[3] - box[1]) * 0.72))
l, t, r, b = draw.textbbox((0, 0), letter, font=font)
draw.text(((box[0] + box[2] - (r - l)) / 2 - l,
(box[1] + box[3] - (b - t)) / 2 - t), letter, font=font, fill=PAPER)
return img.resize((size, size), Image.LANCZOS)
if __name__ == "__main__":
OUT.mkdir(parents=True, exist_ok=True)
for name, size, inset in (("icon-192.png", 192, 1.0),
("icon-512.png", 512, 1.0),
("icon-maskable-512.png", 512, 0.72),
("apple-touch-icon.png", 180, 1.0)):
compose(size, inset).save(OUT / name)
print("собрано", name)
+39 -4
View File
@@ -1,7 +1,9 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Navigate, Route, Routes } from "react-router-dom"; import { useEffect } from "react";
import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "@/store/auth"; import { useAuth } from "@/store/auth";
import { useStaff } from "@/store/staff"; import { useStaff } from "@/store/staff";
import { setReturnTo } from "@/lib/returnTo";
import { ImpersonationBar } from "@/components/ImpersonationBar"; import { ImpersonationBar } from "@/components/ImpersonationBar";
import { LoginPage } from "@/pages/LoginPage"; import { LoginPage } from "@/pages/LoginPage";
import { TgWaitPage } from "@/pages/TgWaitPage"; import { TgWaitPage } from "@/pages/TgWaitPage";
@@ -9,14 +11,21 @@ import { OnboardingPage } from "@/pages/OnboardingPage";
import { BalancePage } from "@/pages/BalancePage"; import { BalancePage } from "@/pages/BalancePage";
import { TopupPage } from "@/pages/TopupPage"; import { TopupPage } from "@/pages/TopupPage";
import { PaymentPage } from "@/pages/PaymentPage"; import { PaymentPage } from "@/pages/PaymentPage";
import { HistoryPage } from "@/pages/HistoryPage";
import { DevLoginPage } from "@/pages/DevLoginPage"; import { DevLoginPage } from "@/pages/DevLoginPage";
import { DebugPage } from "@/pages/DebugPage"; import { DebugPage } from "@/pages/DebugPage";
import { MockPayPage } from "@/pages/MockPayPage"; import { MockPayPage } from "@/pages/MockPayPage";
import { FineDetailPage } from "@/pages/FineDetailPage";
import { NotificationsPage } from "@/pages/NotificationsPage";
import { markNotificationsRead } from "@/api/driver";
function RequireAuth({ children }: { children: ReactNode }) { function RequireAuth({ children }: { children: ReactNode }) {
const token = useAuth((s) => s.token); const token = useAuth((s) => s.token);
return token ? <>{children}</> : <Navigate to="/login" replace />; const loc = useLocation();
if (token) return <>{children}</>;
// Запоминаем, куда шёл водитель (напр. ссылку на оплату), чтобы вернуть его
// туда после входа, а не на баланс.
setReturnTo(loc.pathname + loc.search);
return <Navigate to="/login" replace />;
} }
/** Отладка существует только для сотрудника: либо он вошёл учёткой CRM, либо /** Отладка существует только для сотрудника: либо он вошёл учёткой CRM, либо
@@ -28,12 +37,37 @@ function RequireStaff({ children }: { children: ReactNode }) {
return impersonated || staffToken ? <>{children}</> : <Navigate to="/" replace />; return impersonated || staffToken ? <>{children}</> : <Navigate to="/" replace />;
} }
/**
* Нажатие по пушу — единственный честный признак, что уведомление увидели.
* Сам воркер отметить его не может: токен водителя лежит в хранилище страницы,
* ему недоступном. Поэтому воркер добавляет к адресу `?n=<номер>`, а отмечает
* страница — и тут же убирает параметр, чтобы он не остался в истории.
*/
function MarkPushRead() {
const loc = useLocation();
const nav = useNavigate();
useEffect(() => {
const params = new URLSearchParams(loc.search);
const raw = params.get("n");
if (!raw) return;
const id = Number(raw);
if (Number.isFinite(id) && id > 0) void markNotificationsRead([id], "push");
params.delete("n");
const rest = params.toString();
nav(loc.pathname + (rest ? `?${rest}` : ""), { replace: true });
}, [loc.search, loc.pathname, nav]);
return null;
}
export default function App() { export default function App() {
return ( return (
<div className="mx-auto max-w-md min-h-full px-4 pt-3 pb-8"> <div className="mx-auto max-w-md min-h-full px-4 pt-3 pb-8">
{/* Плашка живёт над маршрутами: чей экран открыт, видно на каждом из них, {/* Плашка живёт над маршрутами: чей экран открыт, видно на каждом из них,
а не только там, где про неё вспомнили. Сама решает, показываться ли. */} а не только там, где про неё вспомнили. Сама решает, показываться ли. */}
<ImpersonationBar /> <ImpersonationBar />
<MarkPushRead />
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/dev" element={<DevLoginPage />} /> <Route path="/dev" element={<DevLoginPage />} />
@@ -43,7 +77,8 @@ export default function App() {
<Route path="/topup" element={<RequireAuth><TopupPage /></RequireAuth>} /> <Route path="/topup" element={<RequireAuth><TopupPage /></RequireAuth>} />
<Route path="/pay/:orderId" element={<RequireAuth><PaymentPage /></RequireAuth>} /> <Route path="/pay/:orderId" element={<RequireAuth><PaymentPage /></RequireAuth>} />
<Route path="/mock-pay/:orderId" element={<RequireAuth><MockPayPage /></RequireAuth>} /> <Route path="/mock-pay/:orderId" element={<RequireAuth><MockPayPage /></RequireAuth>} />
<Route path="/history" element={<RequireAuth><HistoryPage /></RequireAuth>} /> <Route path="/fines/:fineId" element={<RequireAuth><FineDetailPage /></RequireAuth>} />
<Route path="/notifications" element={<RequireAuth><NotificationsPage /></RequireAuth>} />
<Route path="/debug" element={<RequireAuth><RequireStaff><DebugPage /></RequireStaff></RequireAuth>} /> <Route path="/debug" element={<RequireAuth><RequireStaff><DebugPage /></RequireStaff></RequireAuth>} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
@@ -0,0 +1,56 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { api } from "./client";
import { useAuth } from "@/store/auth";
/**
* Продление сеанса: бэкенд кладёт свежий токен в заголовок `X-Driver-Token`,
* приложение обязано молча его подменить. Без этого 30 дней жизни токена
* считались бы от входа, и раз в месяц водителя выкидывало бы на экран входа —
* при том, что пароля у него нет и «войти» означает сходить в бота за кодом.
*/
function answer(headers: Record<string, string> = {}) {
return vi.fn(async () =>
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "Content-Type": "application/json", ...headers },
}));
}
describe("продление токена водителя", () => {
beforeEach(() => {
useAuth.setState({ token: "OLD", driverId: 7, impersonated: false, driverName: null, own: null });
});
afterEach(() => vi.unstubAllGlobals());
it("подменяет токен, когда ответ принёс свежий", async () => {
vi.stubGlobal("fetch", answer({ "X-Driver-Token": "NEW" }));
await api.get("driver/balance").json();
expect(useAuth.getState().token).toBe("NEW");
expect(useAuth.getState().driverId).toBe(7);
});
it("без заголовка ничего не трогает", async () => {
vi.stubGlobal("fetch", answer());
await api.get("driver/balance").json();
expect(useAuth.getState().token).toBe("OLD");
});
it("не продлевает просмотр от лица водителя", async () => {
// Восемь часов просмотра — сознательный предел для чужого баланса на чужом
// ноутбуке. Бэкенд заголовок и не пришлёт, но полагаться на это нельзя:
// ответ на запрос, ушедший ДО начала просмотра, может прийти уже после.
useAuth.setState({ token: "IMP", driverId: 9, impersonated: true, driverName: "Петров" });
vi.stubGlobal("fetch", answer({ "X-Driver-Token": "NEW" }));
await api.get("driver/balance").json();
expect(useAuth.getState().token).toBe("IMP");
});
it("не воскрешает сеанс, закрытый пока запрос был в пути", async () => {
vi.stubGlobal("fetch", vi.fn(async () => {
useAuth.getState().clear(); // водитель нажал «выйти»
return new Response("{}", { status: 200, headers: { "X-Driver-Token": "NEW" } });
}));
await api.get("driver/balance").json();
expect(useAuth.getState().token).toBeNull();
});
});
+24
View File
@@ -7,6 +7,29 @@ export const API_BASE = import.meta.env.VITE_API_BASE ?? "https://crm.pptaxi.ru/
/** Засечка времени на конкретный запрос. WeakMap — чтобы не держать Request'ы. */ /** Засечка времени на конкретный запрос. WeakMap — чтобы не держать Request'ы. */
const started = new WeakMap<Request, number>(); const started = new WeakMap<Request, number>();
/**
* Заголовок, которым бэкенд отдаёт ПРОДЛЁННЫЙ токен водителя (см. driver_auth.py).
*
* Токен живёт 30 дней, и без продления они считались бы от входа: раз в месяц
* водителя выкидывало бы на экран входа, а «войти» для него означает сходить в
* бота за кодом. Начиная с пятого дня жизни токена бэкенд кладёт в ответ свежий,
* мы его молча подменяем — и 30 дней начинают считаться от последнего захода.
*/
const RENEWED_TOKEN_HEADER = "X-Driver-Token";
/** Подменить токен на продлённый, если ответ его принёс. */
function adoptRenewedToken(request: Request, response: Response): void {
const fresh = response.headers.get(RENEWED_TOKEN_HEADER);
if (!fresh) return;
const auth = useAuth.getState();
// Сеанс мог смениться, пока запрос был в пути: водитель вышел, сотрудник начал
// просмотр от чужого лица. Подменяем только тот токен, с которым сами ушли, —
// иначе продление чужого ответа вернёт закрытый сеанс к жизни.
if (auth.impersonated || auth.driverId == null) return;
if (request.headers.get("Authorization") !== `Bearer ${auth.token}`) return;
auth.setSession(fresh, auth.driverId);
}
export const api: KyInstance = ky.create({ export const api: KyInstance = ky.create({
prefixUrl: API_BASE, prefixUrl: API_BASE,
timeout: 30_000, timeout: 30_000,
@@ -30,6 +53,7 @@ export const api: KyInstance = ky.create({
status: response.status, status: response.status,
ms: Math.round(performance.now() - (started.get(request) ?? performance.now())), ms: Math.round(performance.now() - (started.get(request) ?? performance.now())),
}); });
adoptRenewedToken(request, response);
}, },
], ],
beforeError: [ beforeError: [
+130 -3
View File
@@ -25,7 +25,17 @@ export type AuthRequest = z.infer<typeof AuthRequestSchema>;
export const VerifySchema = z.object({ token: z.string(), driver_id: z.number() }); export const VerifySchema = z.object({ token: z.string(), driver_id: z.number() });
export const BalanceSchema = z.object({ export const BalanceSchema = z.object({
accounts: z.array(z.object({ bucket: z.string(), balance: z.number() })), accounts: z.array(z.object({
bucket: z.string(),
balance: z.number(),
// Можно ли платить в эту статью. «Страховка» приходит из 1С, но платежом не
// закрывается — строку показываем, нажатие не предлагаем.
payable: z.boolean().default(true),
})),
// Итог берём с сервера, а не суммой строк: 1С округляет статьи до рублей, а итог
// держит с копейками, и сумма строк с ним не сходится (−8 976 против 8 976,25).
total: z.number().nullable().default(null),
synced_at: z.string().nullable().default(null),
// Принято нами, но ещё не проведено в 1С. В сумму баланса не входит. // Принято нами, но ещё не проведено в 1С. В сумму баланса не входит.
pending_topup: z.number().default(0), pending_topup: z.number().default(0),
}); });
@@ -76,6 +86,19 @@ export async function apiErrorText(err: unknown, fallback: string): Promise<stri
return fallback; return fallback;
} }
/**
* Сколько секунд ждать перед повторным запросом кода. Берём из заголовка
* Retry-After ответа 429; если его нет — null (вызывающий решит сам).
*/
export function retryAfterSeconds(err: unknown): number | null {
if (err instanceof HTTPError) {
const raw = err.response.headers.get("Retry-After");
const n = raw ? parseInt(raw, 10) : NaN;
if (Number.isFinite(n) && n > 0) return n;
}
return null;
}
export const tgSession = async () => export const tgSession = async () =>
TgSessionSchema.parse(await api.post("driver/auth/tg/session").json()); TgSessionSchema.parse(await api.post("driver/auth/tg/session").json());
/** POST, а не GET: в query-параметре pair_session оседал в access-логах. */ /** POST, а не GET: в query-параметре pair_session оседал в access-логах. */
@@ -94,8 +117,11 @@ export const getBalance = async () =>
BalanceSchema.parse(await api.get("driver/balance").json()); BalanceSchema.parse(await api.get("driver/balance").json());
export const getBuckets = async () => export const getBuckets = async () =>
BucketsSchema.parse(await api.get("driver/buckets").json()); BucketsSchema.parse(await api.get("driver/buckets").json());
export const topup = async (bucket: string, amount: number, method: "qr" | "card") => export const topup = async (bucket: string, amount: number, method: "qr" | "card",
TopupSchema.parse(await api.post("driver/topup", { json: { bucket, amount, method } }).json()); live = false) =>
TopupSchema.parse(await api.post("driver/topup", {
json: { bucket, amount, method, live },
}).json());
export const getPaymentState = async (orderId: string) => export const getPaymentState = async (orderId: string) =>
PaymentStateSchema.parse(await api.get(`driver/payment/${orderId}`).json()); PaymentStateSchema.parse(await api.get(`driver/payment/${orderId}`).json());
export const getPayments = async () => export const getPayments = async () =>
@@ -109,3 +135,104 @@ export const mockPayOutcome = async (
) => PaymentStateSchema.parse( ) => PaymentStateSchema.parse(
await api.post(`driver/mock-pay/${orderId}`, { json: { outcome } }).json(), await api.post(`driver/mock-pay/${orderId}`, { json: { outcome } }).json(),
); );
export const StatementSchema = z.object({
items: z.array(z.object({
date: z.string().nullable(),
title: z.string(),
description: z.string().default(""),
amount: z.number(),
kind: z.enum(["charges", "payments", "fines"]),
// Карточка штрафа, если постановление у нас есть. null — строка не ссылка.
fine_id: z.number().nullable().default(null),
})),
from: z.string(),
to: z.string(),
kind: z.string(),
});
export type StatementKind = "all" | "charges" | "payments" | "fines";
/** Выписка по счёту за период. Даты — ISO (ГГГГ-ММ-ДД), как их понимает бэкенд. */
export const getStatement = async (from: string, to: string, kind: StatementKind) =>
StatementSchema.parse(await api.get("driver/statement", {
searchParams: { from, to, kind },
}).json());
const FineBriefSchema = z.object({
id: z.number(),
number: z.string(),
date: z.string().nullable(),
article: z.string(),
address: z.string().default(""),
car_number: z.string().default(""),
// Сумма, начисленная ВОДИТЕЛЮ. Номинала постановления, скидки и расчётов парка
// с ГИБДД в приложении нет намеренно — водителю нужен факт штрафа и сумма.
amount: z.number(),
// Погашен ли штраф водителем перед парком. Постановление парк гасит авансом,
// и на этот признак его расчёты не влияют.
settled: z.enum(["owed", "paid", "not_charged", "cancelled", "unknown"]),
cancelled: z.boolean().default(false),
has_photo: z.boolean().default(false),
});
export type FineBrief = z.infer<typeof FineBriefSchema>;
export const FinesSchema = z.object({
items: z.array(FineBriefSchema),
open_count: z.number(),
// Долг по штрафам из 1С — тот же, что на главном экране (отрицательный).
debt: z.number(),
});
export const FineDetailSchema = FineBriefSchema.extend({
post_date: z.string().nullable().default(null),
speed: z.number().nullable().default(null),
speed_limit: z.number().nullable().default(null),
speed_excess: z.number().nullable().default(null),
camera: z.string().default(""),
// Ссылки подписанные и живут час — держать их в кэше дольше нет смысла.
photos: z.array(z.string()).default([]),
document: z.string().nullable().default(null),
});
export const getFines = async (status: "all" | "open") =>
FinesSchema.parse(await api.get("driver/fines", { searchParams: { status } }).json());
export const getFine = async (id: number) =>
FineDetailSchema.parse(await api.get(`driver/fines/${id}`).json());
export const NotificationSchema = z.object({
id: z.number(),
kind: z.string(),
title: z.string(),
body: z.string(),
url: z.string().nullable().default(null),
created_at: z.string().nullable().default(null),
read_at: z.string().nullable().default(null),
});
export type DriverNotification = z.infer<typeof NotificationSchema>;
export const NotificationsSchema = z.object({
items: z.array(NotificationSchema),
unread: z.number(),
});
export const getNotifications = async () =>
NotificationsSchema.parse(await api.get("driver/notifications").json());
/** Отметить прочитанным. Без списка — «всё»: так отмечает открытие ленты. */
export const markNotificationsRead = async (ids?: number[], source: "app" | "push" = "app") =>
api.post("driver/notifications/read", { json: { ids: ids ?? null, source } }).json();
export const PushKeySchema = z.object({ key: z.string(), enabled: z.boolean() });
export const getPushKey = async () =>
PushKeySchema.parse(await api.get("driver/push/key").json());
export const subscribePush = async (sub: { endpoint: string; p256dh: string; auth: string }) =>
api.post("driver/push/subscribe", { json: sub }).json();
export const unsubscribePush = async (endpoint: string) =>
api.post("driver/push/unsubscribe", { json: { endpoint } }).json();
/** Пробное уведомление себе. Доступно только при просмотре сотрудником. */
export const sendTestPush = async () => api.post("driver/push/test").json();
@@ -1,7 +1,41 @@
export function AppHeader() { import { useNavigate } from "react-router-dom";
import { useRef } from "react";
import { NotificationsBell } from "@/pages/NotificationsPage";
/** Долгое нажатие, после которого открывается вход для сотрудника. */
const LONG_PRESS_MS = 1500;
/** Колокольчик по умолчанию выключен: он тянет счётчик уведомлений, а шапка
* стоит на каждом экране — незачем ходить за ним с оплаты или из штрафа.
* Включается там, откуда водитель и правда пойдёт читать: на главной. */
export function AppHeader({ bell = false }: { bell?: boolean } = {}) {
const nav = useNavigate();
const timer = useRef<number | null>(null);
// Ссылки «для сотрудника» на экранах водителя нет намеренно: водителю она
// ничего не даёт, а место занимает. Но сотруднику, уже вошедшему водителем,
// адрес /dev иначе приходится набирать руками — отсюда долгий тап по названию.
const start = () => {
timer.current = window.setTimeout(() => nav("/dev"), LONG_PRESS_MS);
};
const cancel = () => {
if (timer.current) window.clearTimeout(timer.current);
timer.current = null;
};
return ( return (
<header className="mb-4"> <header className="mb-4 flex items-center justify-between">
<span className="text-sm font-bold">Премиум Водитель</span> <span
className="text-sm font-bold select-none"
onPointerDown={start}
onPointerUp={cancel}
onPointerLeave={cancel}
onContextMenu={(e) => e.preventDefault()}
>
Премиум Водитель
</span>
{bell && <NotificationsBell />}
</header> </header>
); );
} }
@@ -11,7 +11,7 @@ import { Spinner } from "@/components/Spinner";
* первый вход сотрудника (/dev) и смена водителя на вкладке «Отладка». * первый вход сотрудника (/dev) и смена водителя на вкладке «Отладка».
*/ */
export function DriverPicker({ onPicked }: { onPicked: (d: DriverBrief) => void }) { export function DriverPicker({ onPicked }: { onPicked: (d: DriverBrief) => void }) {
const setSession = useAuth((s) => s.setSession); const startImpersonation = useAuth((s) => s.startImpersonation);
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [list, setList] = useState<DriverBrief[]>([]); const [list, setList] = useState<DriverBrief[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -35,7 +35,7 @@ export function DriverPicker({ onPicked }: { onPicked: (d: DriverBrief) => void
setBusyId(d.id); setBusyId(d.id);
try { try {
const r = await impersonate(d.id); const r = await impersonate(d.id);
setSession(r.token, r.driver.id, { impersonated: true, driverName: r.driver.fio }); startImpersonation(r.token, r.driver.id, r.driver.fio);
onPicked(r.driver); onPicked(r.driver);
} catch (e: unknown) { } catch (e: unknown) {
toast.error(await apiErrorText(e, "Не удалось открыть приложение за водителя")); toast.error(await apiErrorText(e, "Не удалось открыть приложение за водителя"));
@@ -46,7 +46,7 @@ export function DriverPicker({ onPicked }: { onPicked: (d: DriverBrief) => void
return ( return (
<div> <div>
<div className="card flex items-center gap-2 px-3 py-2.5 mb-3 bg-white"> <div className="card flex items-center gap-2 px-3 py-2.5 mb-3 bg-surface">
<Search size={16} className="text-muted shrink-0" /> <Search size={16} className="text-muted shrink-0" />
<input <input
autoFocus autoFocus
@@ -18,7 +18,7 @@ export function ImpersonationBar() {
const onDebug = loc.pathname === "/debug"; const onDebug = loc.pathname === "/debug";
return ( return (
<div className="flex items-center gap-2 mb-3 px-3 py-2 rounded-xl2 bg-ink text-cream text-xs"> <div className="flex items-center gap-2 mb-3 px-3 py-2 rounded-xl2 border border-warn/40 bg-warn/10 text-warn text-xs">
<Eye size={14} className="shrink-0" /> <Eye size={14} className="shrink-0" />
<span className="min-w-0 truncate"> <span className="min-w-0 truncate">
Смотрю как <b>{driverName ?? `водитель #${driverId}`}</b> Смотрю как <b>{driverName ?? `водитель #${driverId}`}</b>
@@ -0,0 +1,57 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
vi.mock("@/lib/push", () => ({
currentState: vi.fn(async () => "off"),
enable: vi.fn(async () => "on"),
installed: vi.fn(() => true),
}));
import { NotificationsPrompt } from "./NotificationsPrompt";
import * as push from "@/lib/push";
describe("предложение включить уведомления", () => {
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
vi.mocked(push.installed).mockReturnValue(true);
vi.mocked(push.currentState).mockResolvedValue("off");
});
it("установленному приложению предлагаем — своей карточкой, а не системным окном", async () => {
render(<NotificationsPrompt />);
expect(await screen.findByText("Сообщать о новом?")).toBeInTheDocument();
// Системное окно вызывается только по кнопке: оно показывается один раз за
// установку, и отказ браузер запоминает навсегда.
expect(push.enable).not.toHaveBeenCalled();
await userEvent.click(screen.getByRole("button", { name: "Включить" }));
expect(push.enable).toHaveBeenCalled();
});
it("во вкладке браузера не предлагаем: на iPhone это всё равно не работает", async () => {
vi.mocked(push.installed).mockReturnValue(false);
render(<NotificationsPrompt />);
expect(screen.queryByText("Сообщать о новом?")).not.toBeInTheDocument();
});
it("«не сейчас» откладывает на неделю, а не отказывает навсегда", async () => {
render(<NotificationsPrompt />);
await userEvent.click(await screen.findByRole("button", { name: "Не сейчас" }));
expect(screen.queryByText("Сообщать о новом?")).not.toBeInTheDocument();
const until = Number(localStorage.getItem("pp-push-prompt-later"));
const days = (until - Date.now()) / 86400_000;
expect(days).toBeGreaterThan(6.5);
expect(days).toBeLessThan(7.5);
// Пока срок не вышел — не показываем повторно.
render(<NotificationsPrompt />);
expect(screen.queryByText("Сообщать о новом?")).not.toBeInTheDocument();
});
it("уже включено — карточки нет", async () => {
vi.mocked(push.currentState).mockResolvedValue("on");
render(<NotificationsPrompt />);
expect(screen.queryByText("Сообщать о новом?")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,84 @@
import { useEffect, useState } from "react";
import { Bell } from "lucide-react";
import { toast } from "sonner";
import { currentState, enable, installed, type PushState } from "@/lib/push";
/**
* Предложение включить уведомления — после установки приложения.
*
* Почему своя карточка, а не сразу системное окно. Системное показывается ОДИН
* раз за установку: отказ браузер запоминает навсегда, и переспросить нельзя.
* Своё предложение отказа не стоит — «не сейчас» просто откладывает его на
* неделю, и вернуться к разговору можно.
*
* Почему после установки, а не на входе. На iPhone уведомления работают только в
* установленном приложении, а человеку, который ещё не решил, нужен ли ему этот
* сайт вообще, вопрос про уведомления мешает.
*/
const DELAY_KEY = "pp-push-prompt-later";
const REMIND_AFTER_DAYS = 7;
function postponed(): boolean {
const raw = localStorage.getItem(DELAY_KEY);
if (!raw) return false;
const until = Number(raw);
return Number.isFinite(until) && Date.now() < until;
}
export function NotificationsPrompt() {
const [state, setState] = useState<PushState | null>(null);
const [hidden, setHidden] = useState(postponed());
const [busy, setBusy] = useState(false);
useEffect(() => {
let alive = true;
const refresh = () => currentState().then((s) => { if (alive) setState(s); });
refresh();
// Приложение только что поставили на домашний экран — самое время спросить,
// человек как раз занят настройкой.
const onInstalled = () => { setHidden(false); refresh(); };
window.addEventListener("appinstalled", onInstalled);
return () => { alive = false; window.removeEventListener("appinstalled", onInstalled); };
}, []);
// Показываем только установленному приложению и только пока разрешение не
// спрашивали: «включить» для остальных живёт строкой внизу экрана.
if (hidden || state !== "off" || !installed()) return null;
async function turnOn() {
setBusy(true);
try {
const next = await enable();
setState(next);
if (next !== "on") toast.error("Уведомления не включились");
} catch {
toast.error("Не удалось включить уведомления");
} finally {
setBusy(false);
}
}
function later() {
localStorage.setItem(DELAY_KEY, String(Date.now() + REMIND_AFTER_DAYS * 86400_000));
setHidden(true);
}
return (
<div className="card p-4 mb-3">
<div className="flex items-start gap-3">
<Bell size={18} strokeWidth={1.5} className="text-muted shrink-0 mt-0.5" />
<div className="min-w-0">
<p className="text-sm font-semibold mb-1">Сообщать о новом?</p>
<p className="text-muted text-xs">
Пришлём уведомление, когда появится новый штраф или когда пополнение
дойдёт до баланса.
</p>
</div>
</div>
<div className="flex gap-2 mt-3">
<button className="btn-primary" disabled={busy} onClick={turnOn}>Включить</button>
<button className="btn-ghost" disabled={busy} onClick={later}>Не сейчас</button>
</div>
</div>
);
}
@@ -0,0 +1,40 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
vi.mock("@/lib/push", () => ({
currentState: vi.fn(async () => "off"),
enable: vi.fn(async () => "on"),
}));
import { NotificationsRow } from "./NotificationsRow";
import * as push from "@/lib/push";
describe("строка уведомлений", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(push.currentState).mockResolvedValue("off");
});
it("выключенные уведомления предлагает включить", async () => {
render(<NotificationsRow />);
await userEvent.click(await screen.findByRole("button", { name: /Включить уведомления/ }));
expect(push.enable).toHaveBeenCalled();
});
it("включённые — строки нет: отказаться от них мы не предлагаем", async () => {
// Отключение остаётся системным (настройки телефона или сайта в браузере) —
// рядом с балансом кнопка «выключить» только подталкивала бы к отказу.
vi.mocked(push.currentState).mockResolvedValue("on");
const { container } = render(<NotificationsRow />);
await vi.waitFor(() => expect(container.textContent).toBe(""));
expect(screen.queryByText(/Выключить/)).not.toBeInTheDocument();
});
it("запрещённые в браузере — объясняем, а не предлагаем кнопку", async () => {
vi.mocked(push.currentState).mockResolvedValue("denied");
render(<NotificationsRow />);
expect(await screen.findByText(/запрещены в настройках браузера/)).toBeInTheDocument();
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,69 @@
import { useEffect, useState } from "react";
import { Bell } from "lucide-react";
import { toast } from "sonner";
import { currentState, enable, type PushState } from "@/lib/push";
/**
* Включение уведомлений — одной строкой на главном экране.
*
* Не всплывающим окном на входе: разрешение спрашивается один раз за установку,
* и потраченная впустую попытка закрывает канал навсегда. Здесь водитель жмёт
* сам, увидев, ради чего.
*
* Выключения здесь НЕТ намеренно (решение владельца 19.08.2026). Включённые
* уведомления — это ровно те два события, ради которых приложение и открывают;
* кнопка «выключить» рядом с балансом только предлагает от них отказаться.
* Способ отключить остаётся системный: настройки уведомлений телефона или
* настройки сайта в браузере — там же, где человек управляет всеми остальными
* приложениями.
*/
export function NotificationsRow() {
const [state, setState] = useState<PushState | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
let alive = true;
currentState().then((s) => { if (alive) setState(s); });
return () => { alive = false; };
}, []);
// Включено — строки нет: предлагать нечего.
if (state === null || state === "unsupported" || state === "on") return null;
async function turnOn() {
setBusy(true);
try {
setState(await enable());
} catch {
toast.error("Не удалось включить уведомления");
} finally {
setBusy(false);
}
}
if (state === "needs-install") {
return (
<p className="text-muted text-xs text-center mt-6">
Чтобы получать уведомления о штрафах и платежах, добавьте приложение на домашний экран.
</p>
);
}
if (state === "denied") {
return (
<p className="text-muted text-xs text-center mt-6">
Уведомления запрещены в настройках браузера включить их можно только там.
</p>
);
}
return (
<button
className="flex items-center justify-center gap-1.5 text-muted text-xs mt-6 w-full"
disabled={busy}
onClick={turnOn}
>
<Bell size={15} /> Включить уведомления
</button>
);
}
@@ -8,7 +8,7 @@ export function Segment({ value, options, onChange }: Props) {
type="button" type="button"
onClick={() => onChange(o.value)} onClick={() => onChange(o.value)}
className={`flex-1 py-2.5 rounded-xl2 text-xs border ${ className={`flex-1 py-2.5 rounded-xl2 text-xs border ${
value === o.value ? "bg-ink text-cream border-ink font-bold" : "bg-surface border-line text-muted" value === o.value ? "bg-ink text-white border-ink font-bold" : "bg-surface border-line text-muted"
}`} }`}
> >
{o.label} {o.label}
@@ -0,0 +1,52 @@
import { useEffect, type ReactNode } from "react";
/**
* Шторка снизу — выбор, который не уводит с экрана.
*
* Отдельным экраном такой выбор стоит дороже, чем кажется: человек теряет из
* виду то, ради чего пришёл (баланс), и возвращается кнопкой «назад», которой на
* телефоне ещё надо попасть. Здесь выбор происходит поверх экрана, а закрывается
* тапом мимо, кнопкой и клавишей Escape.
*/
export function Sheet({ open, onClose, title, children }: {
open: boolean;
onClose: () => void;
title: string;
children: ReactNode;
}) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
document.addEventListener("keydown", onKey);
// Фон не должен уезжать под шторкой: иначе список категорий листает страницу.
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
};
}, [open, onClose]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-end justify-center">
<button
aria-label="Закрыть"
className="absolute inset-0 bg-ink/40"
onClick={onClose}
/>
<div
role="dialog"
aria-modal="true"
aria-label={title}
className="relative w-full max-w-md bg-surface rounded-t-2xl pb-6 pt-2 max-h-[80vh] overflow-y-auto"
>
{/* Полоска-ручка: на телефоне она объясняет, что панель тянется, лучше любой подписи. */}
<div className="mx-auto mb-3 h-1 w-10 rounded-full bg-line" />
<p className="px-5 pb-2 text-sm font-bold">{title}</p>
{children}
</div>
</div>
);
}
@@ -0,0 +1,153 @@
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { ChevronRight, Image as ImageIcon } from "lucide-react";
import { getFines, getStatement, type StatementKind } from "@/api/driver";
import { fineStatusText, isOwed } from "@/lib/fineStatus";
import { formatDate, formatMoney } from "@/lib/format";
import { OperationIcon } from "@/components/operationIcon";
import { Spinner } from "@/components/Spinner";
const FILTERS: { key: StatementKind; label: string }[] = [
{ key: "all", label: "Все операции" },
{ key: "charges", label: "Начисления" },
{ key: "payments", label: "Пополнения" },
{ key: "fines", label: "Штрафы" },
];
/**
* Нижняя половина главного экрана: за что списаны деньги.
*
* Три фильтра показывают проводки 1С, а «Штрафы» — сами постановления, а не
* проводки по ним. Разница существенная: штраф, который водитель оплатил сам,
* парк не выставлял, проводки по нему нет, и в выписке он бы не появился вовсе.
* Отдельного экрана штрафов нет намеренно — два списка одного и того же в разных
* местах сбивают с толку.
*/
export function Statement({ from, to, kind, onFrom, onTo, onKind }: {
from: string;
to: string;
kind: StatementKind;
onFrom: (v: string) => void;
onTo: (v: string) => void;
onKind: (k: StatementKind) => void;
}) {
const nav = useNavigate();
const showFines = kind === "fines";
const ops = useQuery({
queryKey: ["statement", from, to, kind],
queryFn: () => getStatement(from, to, kind),
enabled: !showFines,
});
const fines = useQuery({
queryKey: ["fines"],
queryFn: () => getFines("all"),
enabled: showFines,
});
const active = showFines ? fines : ops;
// Период распространяется и на штрафы: иначе календарь над списком стоял бы,
// ничего не меняя, — это хуже, чем его отсутствие.
const fineRows = (fines.data?.items ?? []).filter(
(f) => !f.date || (f.date >= from && f.date <= to),
);
const opRows = ops.data?.items ?? [];
const isEmpty = showFines ? fineRows.length === 0 : opRows.length === 0;
const rowCls = "w-full flex items-center gap-3 py-2.5 border-b border-line last:border-0 text-left";
return (
<div>
<div className="flex items-center gap-2 mb-3">
{/* Нативный date-input: календарь телефона знакомее любого нашего. */}
<input type="date" value={from} max={to} onChange={(e) => onFrom(e.target.value)}
aria-label="Начало периода"
className="card flex-1 px-3 py-2 text-xs bg-surface" />
<span className="text-muted text-xs"></span>
<input type="date" value={to} min={from} onChange={(e) => onTo(e.target.value)}
aria-label="Конец периода"
className="card flex-1 px-3 py-2 text-xs bg-surface" />
</div>
<div className="flex gap-1.5 mb-3 overflow-x-auto">
{FILTERS.map((f) => (
<button
key={f.key}
onClick={() => onKind(f.key)}
className={`shrink-0 px-3 py-2 rounded-xl2 text-xs border ${
f.key === kind ? "border-ink bg-surface font-semibold" : "border-line text-muted"
}`}
>
{f.label}
</button>
))}
</div>
{active.isLoading ? (
<div className="flex justify-center py-8"><Spinner /></div>
) : active.isError ? (
<div className="text-center py-6">
<p className="text-muted text-sm mb-3">Не удалось загрузить операции</p>
<button className="btn-ghost" onClick={() => active.refetch()}>Повторить</button>
</div>
) : isEmpty ? (
<p className="text-muted text-sm text-center py-8">
{showFines ? "Штрафов за период нет" : "Нет операций за выбранный период"}
</p>
) : showFines ? (
<div>
{fineRows.map((f) => (
// Штраф ведёт в карточку: там фотография с камеры, место и скорость.
<button key={f.id} className={rowCls} onClick={() => nav(`/fines/${f.id}`)}>
<OperationIcon title="Штраф" />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5 text-xs font-semibold">
{formatDate(f.date)}
{f.has_photo && <ImageIcon size={12} strokeWidth={1.5} className="text-muted" />}
</span>
<span className="block text-sm truncate">{f.article}</span>
<span className="block text-muted text-xs">
{fineStatusText(f)}{f.car_number ? ` · ${f.car_number}` : ""}
</span>
</span>
<b className={`shrink-0 ${isOwed(f) ? "text-neg" : "text-muted"}`}>
{formatMoney(f.amount)}
</b>
<ChevronRight size={16} className="text-muted shrink-0" />
</button>
))}
</div>
) : (
<div>
{opRows.map((op, idx) => {
const row = (
<>
<OperationIcon title={op.title} />
<span className="min-w-0 flex-1">
{/* Дата — первой строкой: в выписке ищут «что было 12-го», а не
название операции. */}
<span className="block text-xs font-semibold">{formatDate(op.date)}</span>
<span className="block text-sm">{op.title}</span>
{op.description && (
<span className="block text-muted text-xs truncate">{op.description}</span>
)}
</span>
<b className={`shrink-0 ${op.amount < 0 ? "text-neg" : "text-pos"}`}>
{formatMoney(op.amount)}
</b>
</>
);
return op.fine_id != null ? (
<button key={`${op.date}-${idx}`} className={rowCls}
onClick={() => nav(`/fines/${op.fine_id}`)}>
{row}
<ChevronRight size={16} className="text-muted shrink-0" />
</button>
) : (
<div key={`${op.date}-${idx}`} className={rowCls}>{row}</div>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,25 @@
import { describe, it, expect } from "vitest";
import { operationIcon } from "./operationIcon";
/** Названия — настоящие теги 1С из выписки (finance_ledger_entries.tag). */
describe("значок операции", () => {
it("разные виды операций различаются", () => {
const names = [
"Аренда автомобиля",
"Пени",
"Штраф ГИБДД",
"Пополнение баланса через мобильное приложение",
"ДТП",
"Перевод с депозита на баланс",
].map((t) => operationIcon(t));
expect(new Set(names).size).toBe(names.length);
});
it("штраф ГИБДД и штраф компании — один значок: для водителя это одно и то же", () => {
expect(operationIcon("Штраф ГИБДД")).toBe(operationIcon("Штраф компании"));
});
it("незнакомый тег получает нейтральный значок, а не пустоту", () => {
expect(operationIcon("Списание за что-то новое")).toBeTruthy();
});
});
@@ -0,0 +1,53 @@
import {
Banknote, Car, Flag, Gavel, Gift, HandCoins, Percent, PiggyBank, Receipt,
RefreshCcw, ShieldAlert, TriangleAlert, Wrench, type LucideIcon,
} from "lucide-react";
/**
* Значок операции в выписке — по тегу 1С, а не по знаку суммы.
*
* Список сливается в стену текста: за неделю у активного водителя два десятка
* строк, и половина из них — одинаковая по виду «аренда автомобиля». Значок
* даёт зацепку для глаза, чтобы взгляд находил нужное, не читая каждую строку.
*
* Общий компонент, а не иконка на месте применения: тот же приём, что в складе
* CRM (modules/warehouse/categoryIcons.tsx). Разложены по местам — и размеры с
* толщиной линии разъезжаются при первой же правке.
*
* Монохромные намеренно: цвет в этом списке уже занят и означает направление
* денег (красное — списали, зелёное — пришло). Цветной значок спорил бы с ним.
*/
const BY_TAG: [RegExp, LucideIcon][] = [
[/аренда/i, Car],
[/пени/i, Percent],
[/штраф/i, Gavel],
[/дтп|повреждени/i, TriangleAlert],
[/депозит/i, PiggyBank],
[/пополнение|оплата/i, Banknote],
[/ремонт|запчаст/i, Wrench],
[/компенсац/i, HandCoins],
[/акци|бонус|подар/i, Gift],
[/коррекц|сторно|перерасч/i, RefreshCcw],
[/страхов/i, ShieldAlert],
[/остаток|opening/i, Flag],
];
export function operationIcon(title: string): LucideIcon {
for (const [re, icon] of BY_TAG) {
if (re.test(title)) return icon;
}
return Receipt;
}
/** Значок в рамке — как в списке склада: одинаковый размер у всех строк. */
export function OperationIcon({ title }: { title: string }) {
const Icon = operationIcon(title);
return (
<span
aria-hidden
className="shrink-0 w-8 h-8 rounded-xl2 border border-line bg-canvas flex items-center justify-center text-muted"
>
<Icon size={16} strokeWidth={1.5} />
</span>
);
}
+6 -3
View File
@@ -3,11 +3,14 @@
@tailwind utilities; @tailwind utilities;
html, body, #root { height: 100%; } html, body, #root { height: 100%; }
body { @apply bg-cream text-ink font-sans antialiased; margin: 0; } body { @apply bg-canvas text-ink font-sans antialiased; margin: 0; }
button { font-family: inherit; } button { font-family: inherit; }
@layer components { @layer components {
.btn-primary { @apply bg-ink text-cream rounded-xl2 py-3.5 px-4 text-sm font-bold text-center w-full active:opacity-90 disabled:opacity-50; } /* Кнопки повторяют сайт: действие — фирменным красным с белым текстом
.btn-ghost { @apply border border-ink text-ink rounded-xl2 py-3 px-4 text-sm font-semibold text-center w-full; } (.s-btn--accent), второстепенное — белым с чернильным текстом и рамкой
(.s-btn--ghost). */
.btn-primary { @apply bg-accent text-white rounded-xl2 py-3.5 px-4 text-sm font-bold text-center w-full active:bg-accent-press disabled:opacity-50; }
.btn-ghost { @apply bg-surface border border-line text-ink rounded-xl2 py-3 px-4 text-sm font-semibold text-center w-full active:bg-canvas-soft; }
.card { @apply bg-surface border border-line rounded-2xl; } .card { @apply bg-surface border border-line rounded-2xl; }
} }
+30
View File
@@ -0,0 +1,30 @@
import type { FineBrief } from "@/api/driver";
/**
* Что написать водителю о штрафе.
*
* Ключевое различие, которое интерфейс обязан удерживать: постановление в ГИБДД
* гасит ПАРК, обычно авансом и со скидкой, — и с этого момента штраф закрыт для
* государства, но не для водителя. Водитель возвращает деньги парку, и это видно
* только по балансу «Долг по штрафам». Поэтому слово «оплачен» здесь не
* используется вовсе: оно означало бы для человека «я ничего не должен».
*/
export function fineStatusText(f: Pick<FineBrief, "settled">): string {
switch (f.settled) {
case "owed":
return "не погашен";
case "paid":
return "погашен";
case "not_charged":
return "не выставлялся";
case "cancelled":
return "отменён";
default:
return "уточняется";
}
}
/** Долг именно этого штрафа перед парком — по нему красим сумму. */
export function isOwed(f: Pick<FineBrief, "settled">): boolean {
return f.settled === "owed";
}
+18
View File
@@ -30,3 +30,21 @@ export function formatPhone(national10: string): string {
if (d.length > 8) p.push("-" + d.slice(8, 10)); if (d.length > 8) p.push("-" + d.slice(8, 10));
return p.join(""); return p.join("");
} }
/** Дата человеку — ДД.ММ.ГГГГ. На проводе и в <input type="date"> остаётся ISO. */
export function formatDate(iso: string | null): string {
if (!iso) return "";
const [y, m, d] = iso.split("-");
return y && m && d ? `${d}.${m}.${y}` : iso;
}
/** ISO-дата на N дней назад от сегодня — для периода выписки по умолчанию. */
export function isoDaysAgo(days: number): string {
const d = new Date();
d.setDate(d.getDate() - days);
return d.toISOString().slice(0, 10);
}
export function isoToday(): string {
return new Date().toISOString().slice(0, 10);
}
@@ -0,0 +1,51 @@
/**
* Отметка «водитель только что платил» — общая для экранов пополнения и баланса.
*
* Зачем она нужна, если экран баланса и так опрашивает себя, пока висит
* «Зачисляется» (pending_topup > 0). У опроса по одному этому признаку два
* слепых пятна, и оба приходятся ровно на сбойные сценарии:
*
* 1. Водитель ушёл с экрана оплаты жестом «назад» ДО прихода вебхука банка
* (СБП подтверждается и через 10–60 секунд). Баланс на тот момент отвечает
* pending_topup=0 — опрос не взводится, и когда вебхук наконец ставит PAID,
* перезапросить баланс уже некому: экран оплаты размонтирован вместе со
* своим опросом. Старый итог висит без строки «Зачисляется» бессрочно.
*
* 2. «Зачисляется» гаснет в момент записи платежа в 1С, и в норме итог в том же
* ответе уже новый. Но точечное обновление снимка — не гарантия (1С может
* не ответить, тогда снимок догонит общий синк за ≤2 минуты): опрос,
* выключившийся по pending_topup=0, этот догон уже не увидит — деньги
* «пропадают» с экрана до смены фокуса.
*
* Поэтому платёжная активность (создание платежа, факт оплаты) оставляет
* отметку, и баланс продолжает опрашиваться ещё WATCH_MS после неё — окно с
* запасом накрывает и лаг вебхука, и полный цикл общего синка.
*
* sessionStorage, а не переменная модуля: PWA на телефоне перезагружает
* страницу охотно (нехватка памяти, возврат из банковского приложения), и
* отметка в памяти не пережила бы ровно тот переход, ради которого заведена.
*/
const KEY = "pp-payment-activity-at";
/** Окно опроса после платёжной активности: лаг вебхука (до минуты) + запись в
* 1С (≤15 с) + общий синк снимка (≤2 мин) — с запасом. */
export const WATCH_MS = 5 * 60_000;
export function markPaymentActivity(): void {
try {
sessionStorage.setItem(KEY, String(Date.now()));
} catch {
// Приватный режим или отключённое хранилище: без отметки остаётся опрос по
// «Зачисляется» — деградация до прежнего поведения, а не поломка.
}
}
export function paymentActivityWithin(ms: number = WATCH_MS): boolean {
try {
const t = Number(sessionStorage.getItem(KEY) || 0);
return t > 0 && Date.now() - t < ms;
} catch {
return false;
}
}
+91
View File
@@ -0,0 +1,91 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("@/api/driver", () => ({
getPushKey: vi.fn(async () => ({ key: "BIIh_RELLY", enabled: true })),
subscribePush: vi.fn(async () => ({ ok: true })),
unsubscribePush: vi.fn(async () => ({ ok: true })),
}));
import { currentState, enable, supported } from "./push";
import * as api from "@/api/driver";
const SUB = {
endpoint: "https://fcm.googleapis.com/fcm/send/abc",
toJSON: () => ({
endpoint: "https://fcm.googleapis.com/fcm/send/abc",
keys: { p256dh: "p256", auth: "auth" },
}),
unsubscribe: vi.fn(async () => true),
};
function stubBrowser(opts: {
permission?: NotificationPermission;
existing?: unknown;
ios?: boolean;
standalone?: boolean;
} = {}) {
const perm = opts.permission ?? "default";
const registration = {
pushManager: {
getSubscription: vi.fn(async () => opts.existing ?? null),
subscribe: vi.fn(async () => SUB),
},
};
vi.stubGlobal("navigator", {
serviceWorker: {
getRegistration: vi.fn(async () => registration),
ready: Promise.resolve(registration),
},
userAgent: opts.ios ? "iPhone" : "Android Chrome",
standalone: opts.standalone,
});
const notification = {
permission: perm,
requestPermission: vi.fn(async () => (opts.permission === "denied" ? "denied" : "granted")),
};
// Notification обязан лежать И в window: supported() проверяет именно его —
// так же, как это делает браузер.
vi.stubGlobal("window", {
PushManager: function () {},
Notification: notification,
matchMedia: () => ({ matches: !!opts.standalone }),
});
vi.stubGlobal("Notification", notification);
return registration;
}
describe("уведомления в приложении", () => {
beforeEach(() => vi.clearAllMocks());
afterEach(() => vi.unstubAllGlobals());
it("iPhone без установки на экран: объясняем, а не молчим", async () => {
vi.stubGlobal("navigator", { userAgent: "iPhone", standalone: false });
vi.stubGlobal("window", { matchMedia: () => ({ matches: false }) });
expect(supported()).toBe(false);
expect(await currentState()).toBe("needs-install");
});
it("разрешение отклонено — переспросить нечем", async () => {
stubBrowser({ permission: "denied" });
expect(await currentState()).toBe("denied");
});
it("подписка уходит на сервер с ключами из браузера", async () => {
stubBrowser();
expect(await enable()).toBe("on");
expect(api.subscribePush).toHaveBeenCalledWith({
endpoint: "https://fcm.googleapis.com/fcm/send/abc",
p256dh: "p256",
auth: "auth",
});
});
it("сервер не настроен — разрешение НЕ спрашиваем", async () => {
// Единственная попытка на установку: потратить её впустую нельзя.
vi.mocked(api.getPushKey).mockResolvedValueOnce({ key: "", enabled: false });
stubBrowser();
expect(await enable()).toBe("off");
expect(Notification.requestPermission).not.toHaveBeenCalled();
expect(api.subscribePush).not.toHaveBeenCalled();
});
});
+92
View File
@@ -0,0 +1,92 @@
import { getPushKey, subscribePush, unsubscribePush } from "@/api/driver";
/**
* Включение уведомлений в приложении.
*
* Главное правило экрана вокруг этого кода: разрешение спрашивается ОДИН раз за
* всю жизнь установки. Отказ браузер запоминает, переспросить нельзя — только
* руками в настройках сайта, куда водитель не пойдёт. Поэтому запрос идёт
* строго по нажатию и только когда сервер подтвердил, что уведомления настроены.
*/
export type PushState =
| "unsupported" // браузер не умеет (или iOS без установки на домашний экран)
| "needs-install" // iOS: умеет, но только когда приложение добавлено на экран
| "denied" // разрешение отклонено — переспросить нечем
| "off" // можно включить
| "on"; // подписка есть
/** Приложение открыто как установленное (с домашнего экрана), а не вкладкой. */
export function installed(): boolean {
return isStandalone();
}
function isStandalone(): boolean {
return window.matchMedia("(display-mode: standalone)").matches ||
// iOS до 17 не поддерживает display-mode: standalone в matchMedia
(navigator as { standalone?: boolean }).standalone === true;
}
function isIos(): boolean {
return /iPad|iPhone|iPod/.test(navigator.userAgent);
}
export function supported(): boolean {
return "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
}
export async function currentState(): Promise<PushState> {
if (!supported()) return isIos() && !isStandalone() ? "needs-install" : "unsupported";
if (Notification.permission === "denied") return "denied";
const reg = await navigator.serviceWorker.getRegistration();
const sub = await reg?.pushManager.getSubscription();
return sub ? "on" : "off";
}
/** base64url → байты: ключ VAPID браузер принимает только так.
* Возвращаем ArrayBuffer, а не Uint8Array: типы DOM ждут BufferSource. */
function keyToBytes(base64: string): ArrayBuffer {
const padded = base64.replace(/-/g, "+").replace(/_/g, "/") +
"=".repeat((4 - (base64.length % 4)) % 4);
const raw = atob(padded);
const bytes = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
return bytes.buffer;
}
/** Включить уведомления. Возвращает новое состояние. */
export async function enable(): Promise<PushState> {
if (!supported()) return currentState();
const { key, enabled } = await getPushKey();
// Ключа нет — сервер уведомления не отправляет. Разрешение не спрашиваем:
// потратить единственную попытку впустую нельзя.
if (!enabled || !key) return "off";
const permission = await Notification.requestPermission();
if (permission !== "granted") return permission === "denied" ? "denied" : "off";
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: keyToBytes(key),
});
const json = sub.toJSON() as { endpoint?: string; keys?: { p256dh?: string; auth?: string } };
await subscribePush({
endpoint: json.endpoint ?? sub.endpoint,
p256dh: json.keys?.p256dh ?? "",
auth: json.keys?.auth ?? "",
});
return "on";
}
/** Выключить: снимаем подписку и в браузере, и на сервере. */
export async function disable(): Promise<PushState> {
const reg = await navigator.serviceWorker.getRegistration();
const sub = await reg?.pushManager.getSubscription();
if (sub) {
// Сначала сервер: не сумеем достучаться — подписка останется живой, и
// уведомления продолжат приходить, а это лучше «выключил, а они идут».
await unsubscribePush(sub.endpoint);
await sub.unsubscribe();
}
return "off";
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Куда вернуть водителя после входа.
*
* Ссылки на оплату (`/pay/:id`) открываются под RequireAuth: незалогиненного
* бросает на /login. Раньше после входа он попадал на баланс, а не на оплату,
* ради которой пришёл. Запоминаем нужный путь тут (переживает переход через
* /tg-wait и онбординг), после любого успешного входа — забираем и очищаем.
*/
const KEY = "pp-return-to";
/** Путь для сохранения — только внутренние маршруты приложения. */
export function setReturnTo(path: string | null | undefined): void {
try {
if (path && path.startsWith("/") && !path.startsWith("//") && path !== "/login") {
sessionStorage.setItem(KEY, path);
}
} catch {
/* приватный режим/недоступный storage — не критично */
}
}
/** Забрать сохранённый путь (и очистить). По умолчанию — на главную. */
export function takeReturnTo(): string {
try {
const v = sessionStorage.getItem(KEY);
sessionStorage.removeItem(KEY);
if (v && v.startsWith("/") && !v.startsWith("//")) return v;
} catch {
/* см. выше */
}
return "/";
}
@@ -1,47 +1,175 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi, beforeAll, afterAll } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen, within } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom"; import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// Ответ как у настоящего водителя: статьи 1С, итог отдельным числом.
vi.mock("@/api/driver", () => ({ vi.mock("@/api/driver", () => ({
// Колокольчик в шапке главного экрана спрашивает счётчик непрочитанного.
getNotifications: vi.fn(async () => ({ items: [], unread: 0 })),
markNotificationsRead: vi.fn(async () => ({ marked: 0 })),
getBalance: vi.fn(async () => ({ getBalance: vi.fn(async () => ({
accounts: [ accounts: [
{ bucket: "Долг аренда", balance: -3200 }, { bucket: "Долг аренда", balance: -3050, payable: true },
{ bucket: епозит", balance: 5000 }, { bucket: олг по штрафам", balance: -5156, payable: true },
{ bucket: "Страховка", balance: 200, payable: false },
], ],
total: -8976.25,
synced_at: null,
pending_topup: 1500,
})),
getFines: vi.fn(async () => ({
items: [{
id: 12, number: "18810525260813035846", date: "2026-08-13",
article: "12.09.2 - Превышение скорости", address: "", car_number: "А947УТ125",
amount: 618.75, settled: "owed" as const, cancelled: false, has_photo: true,
}],
open_count: 1, debt: -5156,
})),
getStatement: vi.fn(async () => ({
items: [
{ date: "2026-08-17", title: "Аренда автомобиля", description: "ТС А947УТ125",
amount: -2600, kind: "charges" },
{ date: "2026-08-17", title: "Пополнение баланса", description: "", amount: 1000,
kind: "payments" },
],
from: "2026-08-11", to: "2026-08-18", kind: "all",
})), })),
})); }));
import { BalancePage } from "./BalancePage"; // Экран режет постановления по периоду «последние 7 дней от сегодня», а даты в
// моках фиксированные: без замороженных часов штраф от 13.08 выпадает из окна,
// как только настоящее «сегодня» уходит за 20.08. Морозим только Date — таймеры
// остаются настоящими, иначе userEvent и waitFor зависнут.
beforeAll(() => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-08-18T12:00:00Z"));
});
afterAll(() => vi.useRealTimers());
import { BalancePage, balancePollMs } from "./BalancePage";
import { markPaymentActivity } from "@/lib/paymentWatch";
import * as driver from "@/api/driver";
function wrap() { function wrap() {
const qc = new QueryClient(); const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render( return render(
<QueryClientProvider client={qc}> <QueryClientProvider client={qc}>
<MemoryRouter><BalancePage /></MemoryRouter> <MemoryRouter>
<Routes>
<Route path="/" element={<BalancePage />} />
<Route path="/topup" element={<p>экран пополнения</p>} />
</Routes>
</MemoryRouter>
</QueryClientProvider> </QueryClientProvider>
); );
} }
describe("BalancePage", () => { describe("главный экран", () => {
it("shows hero total (sum of negatives) and account rows", async () => { it("опрос баланса: пока висит «зачисляется» или водитель недавно платил", () => {
wrap(); // Водитель после оплаты смотрит на экран и ждёт новый итог — экран обязан
expect(await screen.findByText("Долг аренда")).toBeInTheDocument(); // обновиться сам. Вне платёжных окон опроса быть не должно.
expect(screen.getByText("Депозит")).toBeInTheDocument(); sessionStorage.clear();
// Appears in both the hero total and the single debt account row. expect(balancePollMs({ pending_topup: 1500 })).toBe(4000);
expect(screen.getAllByText("3 200 ₽").length).toBeGreaterThan(0); expect(balancePollMs({ pending_topup: 0 })).toBe(false);
expect(screen.getByRole("button", { name: /Пополнить/i })).toBeInTheDocument(); expect(balancePollMs(undefined)).toBe(false);
// Свежая платёжная активность держит опрос и без «зачисляется»: вебхук
// банка может запаздывать (опрос обязан застать появление строки), а снимок
// баланса — догонять платёж общим синком уже после её погашения.
markPaymentActivity();
expect(balancePollMs({ pending_topup: 0 })).toBe(4000);
sessionStorage.clear();
}); });
it("shows an error state when balance fails to load", async () => { it("сверху деньги: итог с сервера и строка «зачисляется»", async () => {
const driver = await import("@/api/driver"); wrap();
(driver.getBalance as any).mockRejectedValueOnce(new Error("boom")); // Сумма статей дала бы −8 006, а в CRM у водителя −8 976,25. Показываем её.
// re-render fresh expect(await screen.findByText("8 976,25 ₽")).toBeInTheDocument();
const { QueryClient, QueryClientProvider } = await import("@tanstack/react-query"); expect(screen.getByText("Зачисляется")).toBeInTheDocument();
const { MemoryRouter } = await import("react-router-dom"); expect(screen.getByText("1 500 ₽")).toBeInTheDocument();
const { render, screen } = await import("@testing-library/react"); });
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(<QueryClientProvider client={qc}><MemoryRouter><BalancePage /></MemoryRouter></QueryClientProvider>); it("снизу выписка за период", async () => {
expect(await screen.findByText(/Не удалось загрузить/i)).toBeInTheDocument(); wrap();
expect(await screen.findByText("Аренда автомобиля")).toBeInTheDocument();
expect(screen.getAllByText(/17\.08\.2026/).length).toBe(2); // обе операции того дня
expect(screen.getByRole("button", { name: "Все операции" })).toBeInTheDocument();
expect(driver.getStatement).toHaveBeenCalled();
});
it("фильтр перезапрашивает операции выбранного вида", async () => {
wrap();
await userEvent.click(await screen.findByRole("button", { name: "Начисления" }));
const calls = vi.mocked(driver.getStatement).mock.calls;
expect(calls[calls.length - 1][2]).toBe("charges");
});
it("фильтр «Штрафы» показывает постановления, а не проводки", async () => {
// Штраф, оплаченный водителем самостоятельно, парк не выставлял — проводки по
// нему нет, и в выписке он бы не появился вовсе (Морозов, 18.08.2026).
wrap();
await userEvent.click(await screen.findByRole("button", { name: "Штрафы" }));
expect(await screen.findByText(/Превышение скорости/)).toBeInTheDocument();
expect(driver.getFines).toHaveBeenCalled();
});
it("аренда и штрафы видны сразу, остальные статьи — нет", async () => {
wrap();
await screen.findByText("8 976,25 ₽");
// Эти две водитель проверяет каждый день — они на экране.
expect(screen.getByText("Долг аренда")).toBeInTheDocument();
expect(screen.getByText("Долг по штрафам")).toBeInTheDocument();
expect(screen.getByText("3 050 ₽")).toBeInTheDocument();
// Редкие статьи на экран не лезут: для них есть шторка.
expect(screen.queryByText("Страховка")).not.toBeInTheDocument();
});
it("аренда ведёт к оплате, а штрафы — в свой список", async () => {
wrap();
await userEvent.click(await screen.findByRole("button", { name: /Долг аренда/ }));
expect(await screen.findByText("экран пополнения")).toBeInTheDocument();
});
it("строка штрафов включает фильтр внизу, а не уводит на другой экран", async () => {
wrap();
await userEvent.click(await screen.findByRole("button", { name: /Долг по штрафам/ }));
expect(await screen.findByText(/Превышение скорости/)).toBeInTheDocument();
expect(driver.getFines).toHaveBeenCalled();
});
it("категории для выбора живут в шторке «Пополнить»", async () => {
wrap();
await screen.findByText("8 976,25 ₽");
await userEvent.click(screen.getByRole("button", { name: /^Пополнить$/ }));
const sheet = await screen.findByRole("dialog", { name: "Куда пополнить" });
const inSheet = within(sheet);
expect(inSheet.getByText("Долг аренда")).toBeInTheDocument();
expect(inSheet.getByText("Долг по штрафам")).toBeInTheDocument();
// «Страховка» платежом не закрывается — в списке выбора ей делать нечего.
expect(inSheet.queryByText("Страховка")).not.toBeInTheDocument();
});
it("шторка закрывается тапом мимо", async () => {
wrap();
await screen.findByText("8 976,25 ₽");
await userEvent.click(screen.getByRole("button", { name: /^Пополнить$/ }));
await userEvent.click(await screen.findByRole("button", { name: "Закрыть" }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("отдельного раздела пополнений нет — они в выписке", async () => {
// Раздел «Мои пополнения» показывал наши платежи и у большинства водителей был
// пуст: 1С проводит пополнения сама, и они уже видны фильтром в выписке.
wrap();
await screen.findByText("8 976,25 ₽");
expect(screen.queryByText(/Мои пополнения/)).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Пополнения" })).toBeInTheDocument();
});
it("баланс не загрузился — экран говорит об этом, а не рисует ноль", async () => {
vi.mocked(driver.getBalance).mockRejectedValueOnce(new Error("boom"));
wrap();
expect(await screen.findByRole("button", { name: /Обновить баланс/ })).toBeInTheDocument();
}); });
}); });
+134 -36
View File
@@ -1,69 +1,167 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { History, LogOut } from "lucide-react"; import { ArrowUp, ChevronRight, LogOut } from "lucide-react";
import { getBalance } from "@/api/driver"; import { getBalance, type StatementKind } from "@/api/driver";
import { formatMoney } from "@/lib/format"; import { formatMoney, isoDaysAgo, isoToday } from "@/lib/format";
import { paymentActivityWithin } from "@/lib/paymentWatch";
import { useAuth } from "@/store/auth"; import { useAuth } from "@/store/auth";
import { AppHeader } from "@/components/AppHeader"; import { AppHeader } from "@/components/AppHeader";
import { NotificationsPrompt } from "@/components/NotificationsPrompt";
import { NotificationsRow } from "@/components/NotificationsRow";
import { Sheet } from "@/components/Sheet";
import { Spinner } from "@/components/Spinner"; import { Spinner } from "@/components/Spinner";
import { Statement } from "@/components/Statement";
// Две статьи, которые водитель проверяет каждый день: за них он платит и из-за
// них ему звонят. Остальные (депозит, повреждения, прочее) — редкие, им хватает
// шторки «Пополнить», иначе экран снова превращается в столбик из шести строк.
const MAIN_BUCKETS = ["Долг аренда", "Долг по штрафам"];
// Пока висит «Зачисляется» ЛИБО водитель недавно платил, экран опрашивает
// баланс сам: платёж доезжает до 1С за секунды, и новый итог должен появиться
// на глазах — водитель после оплаты смотрит именно сюда, а перезапускать
// приложение он не обязан. Одного «зачисляется» мало: опрос по нему не
// взводится, пока вебхук банка запаздывает, и гаснет раньше, чем снимок
// баланса догонит платёж при сбое точечного обновления (см. lib/paymentWatch).
// Вне этих окон опрос не нужен: остальные движения баланса водитель не
// караулит у экрана.
export const balancePollMs = (d?: { pending_topup?: number }): number | false =>
(d?.pending_topup ?? 0) > 0 || paymentActivityWithin() ? 4000 : false;
/**
* Главный экран из двух половин: сверху деньги, снизу — за что они списаны.
*
* Разбивка по статьям живёт в шторке «Пополнить», а не на экране: водителю она
* нужна ровно в момент, когда он решает, куда платить, а всё остальное время
* это шесть строк, отодвигающих выписку за нижний край телефона.
*/
export function BalancePage() { export function BalancePage() {
const nav = useNavigate(); const nav = useNavigate();
const clear = useAuth((s) => s.clear); const clear = useAuth((s) => s.clear);
const { data, isLoading, isError, refetch } = useQuery({ queryKey: ["balance"], queryFn: getBalance }); const { data, isLoading, isError, refetch } = useQuery({
queryKey: ["balance"],
queryFn: getBalance,
refetchInterval: (q) => balancePollMs(q.state.data),
});
const [sheet, setSheet] = useState(false);
// Неделя — тот же период, что у вендора: столько человек помнит свои поездки.
const [from, setFrom] = useState(isoDaysAgo(7));
const [to, setTo] = useState(isoToday());
const [kind, setKind] = useState<StatementKind>("all");
const accounts = data?.accounts ?? []; const accounts = data?.accounts ?? [];
const total = accounts.reduce((s, a) => s + a.balance, 0); const total = data?.total ?? accounts.reduce((s, a) => s + a.balance, 0);
const pending = data?.pending_topup ?? 0; const pending = data?.pending_topup ?? 0;
const noData = !isLoading && !isError && accounts.length === 0;
const payable = accounts.filter((a) => a.payable);
const main = MAIN_BUCKETS.map((b) => accounts.find((a) => a.bucket === b)).filter(
(a): a is NonNullable<typeof a> => a != null,
);
function pick(bucket?: string) {
setSheet(false);
nav("/topup", { state: bucket ? { bucket } : undefined });
}
return ( return (
<div> <div>
<AppHeader /> <AppHeader bell />
<div className="card p-4 mb-4 text-center">
<p className="text-muted text-xs mb-1">Баланс</p> <NotificationsPrompt />
<p className={`text-2xl font-extrabold ${total < 0 ? "text-neg" : "text-pos"}`}>{formatMoney(total)}</p>
{pending > 0 && ( {/* ── Деньги ─────────────────────────────────────────────── */}
// Платёж принят нами, но 1С его ещё не провела. В сумму баланса он не <div className="card p-4 mb-3">
// входит намеренно: сложить оба источника значило бы показать <div className="flex justify-between items-baseline">
// пополнение дважды, как только расчётный лист принесёт тот же платёж. <span className="text-muted text-xs">Баланс в парке</span>
<p className="text-muted text-xs mt-2"> {isLoading ? (
{formatMoney(pending)} зачисляется учтётся после проведения <Spinner />
</p> ) : noData ? (
<span className="text-sm font-semibold">уточняется</span>
) : (
<b className={`text-2xl ${total < 0 ? "text-neg" : "text-pos"}`}>{formatMoney(total)}</b>
)} )}
</div> </div>
{main.length > 0 && (
{isLoading ? ( <div className="mt-3 pt-3 border-t border-line">
<div className="flex justify-center py-10"><Spinner /></div> {main.map((a) => (
) : isError ? ( // Аренда ведёт к оплате — за тем её и открывают. Штрафы включают
<div className="text-center py-8"> // фильтр в списке ниже: «−5 156 ₽» не отвечает на вопрос «за что»,
<p className="text-muted text-sm mb-3">Не удалось загрузить баланс</p> // а отдельного экрана штрафов нет — два списка одного и того же в
<button className="btn-ghost" onClick={() => refetch()}>Повторить</button> // разных местах сбивали с толку.
</div>
) : (
<div className="mb-4">
{accounts.map((a) => (
<button <button
key={a.bucket} key={a.bucket}
onClick={() => nav("/topup", { state: { bucket: a.bucket } })} disabled={!a.payable}
className="w-full flex justify-between items-center py-3 border-b border-line last:border-0 text-left" onClick={() =>
a.bucket === "Долг по штрафам"
? setKind("fines")
: nav("/topup", { state: { bucket: a.bucket } })
}
className="w-full flex justify-between items-baseline py-1.5 text-left disabled:opacity-100"
> >
<span className="text-sm">{a.bucket}</span> <span className="text-muted text-xs">{a.bucket}</span>
<b className={a.balance < 0 ? "text-neg" : "text-pos"}>{formatMoney(a.balance)}</b> <span className={`text-sm ${a.balance < 0 ? "text-neg" : a.balance > 0 ? "text-pos" : ""}`}>
{formatMoney(a.balance)}
</span>
</button> </button>
))} ))}
</div> </div>
)} )}
{pending > 0 && (
// Платёж принят нами, но 1С его ещё не провела. В сумму не входит: сложить
// оба источника значило бы показать пополнение дважды после проведения.
<div className="flex justify-between items-baseline mt-3 pt-3 border-t border-line">
<span className="text-muted text-xs">Зачисляется</span>
<span className="text-sm">{formatMoney(pending)}</span>
</div>
)}
{isError && (
<button className="btn-ghost mt-3" onClick={() => refetch()}>Обновить баланс</button>
)}
</div>
<button className="btn-primary" onClick={() => nav("/topup")}>Пополнить</button> <button className="btn-primary flex items-center justify-center gap-2 mb-5"
onClick={() => setSheet(true)}>
<div className="flex justify-center gap-6 mt-6 text-muted text-xs"> <ArrowUp size={18} /> Пополнить
<button className="flex items-center gap-1.5" onClick={() => nav("/history")}>
<History size={15} /> История
</button> </button>
{/* ── За что списано ─────────────────────────────────────── */}
<Statement from={from} to={to} kind={kind}
onFrom={setFrom} onTo={setTo} onKind={setKind} />
<NotificationsRow />
<div className="flex justify-center mt-6 text-muted text-xs">
{/* Отдельного раздела «Мои пополнения» нет намеренно: пополнения видны в
выписке выше по фильтру, а дублирующий пустой экран сбивал с толку. */}
<button className="flex items-center gap-1.5" onClick={() => { clear(); nav("/login"); }}> <button className="flex items-center gap-1.5" onClick={() => { clear(); nav("/login"); }}>
<LogOut size={15} /> Выйти <LogOut size={15} /> Выйти
</button> </button>
</div> </div>
<Sheet open={sheet} onClose={() => setSheet(false)} title="Куда пополнить">
{payable.length === 0 ? (
<p className="text-muted text-sm px-5 py-4">
Категории пока не загрузились. Попробуйте ещё раз через минуту.
</p>
) : (
payable.map((a) => (
<button
key={a.bucket}
onClick={() => pick(a.bucket)}
className="w-full flex justify-between items-center gap-3 px-5 py-3.5 border-b border-line last:border-0 text-left"
>
<span className="text-sm">{a.bucket}</span>
<span className="flex items-center gap-2 shrink-0">
<b className={a.balance < 0 ? "text-neg" : a.balance > 0 ? "text-pos" : "text-muted"}>
{formatMoney(a.balance)}
</b>
<ChevronRight size={16} className="text-muted" />
</span>
</button>
))
)}
</Sheet>
</div> </div>
); );
} }
@@ -11,6 +11,9 @@ vi.mock("@/api/staff", () => ({
vi.mock("@/api/driver", () => ({ vi.mock("@/api/driver", () => ({
apiErrorText: vi.fn(async (_e: unknown, f: string) => f), apiErrorText: vi.fn(async (_e: unknown, f: string) => f),
getBalance: vi.fn(async () => ({ accounts: [], pending_topup: 0 })), getBalance: vi.fn(async () => ({ accounts: [], pending_topup: 0 })),
// Тесты доходят до главного экрана, а у него в шапке колокольчик.
getNotifications: vi.fn(async () => ({ items: [], unread: 0 })),
markNotificationsRead: vi.fn(async () => ({ marked: 0 })),
})); }));
import App from "@/App"; import App from "@/App";
@@ -65,3 +68,35 @@ describe("вкладка отладки", () => {
expect(screen.getByText("123 мс")).toBeInTheDocument(); expect(screen.getByText("123 мс")).toBeInTheDocument();
}); });
}); });
describe("возврат к своему аккаунту", () => {
beforeEach(() => { useAuth.getState().clear(); useStaff.getState().clear(); clearLog(); });
it("свой вход не теряется при просмотре чужого экрана", () => {
useAuth.getState().setSession("мой-токен", 91);
useAuth.getState().startImpersonation("чужой-токен", 6866, "Тест Тестовый тестович");
let s = useAuth.getState();
expect([s.token, s.driverId, s.impersonated]).toEqual(["чужой-токен", 6866, true]);
// смена водителя внутри просмотра не должна подменять отложенный свой вход
useAuth.getState().startImpersonation("третий-токен", 2869, "Тестик Николай");
expect(useAuth.getState().own).toEqual({ token: "мой-токен", driverId: 91 });
expect(useAuth.getState().stopImpersonation()).toBe(true);
s = useAuth.getState();
expect([s.token, s.driverId, s.impersonated, s.own]).toEqual(["мой-токен", 91, false, null]);
});
it("если своего входа не было — просмотр просто заканчивается выходом", () => {
useAuth.getState().startImpersonation("чужой-токен", 6866, "Тест");
expect(useAuth.getState().stopImpersonation()).toBe(false);
expect(useAuth.getState().token).toBeNull();
});
it("кнопка предлагает вернуться к себе, когда есть куда", async () => {
useAuth.getState().setSession("мой-токен", 91);
useAuth.getState().startImpersonation("чужой-токен", 6866, "Тест Тестовый тестович");
renderAt("/debug");
expect(screen.getByRole("button", { name: /Вернуться к своему аккаунту/ })).toBeInTheDocument();
});
});
+30 -3
View File
@@ -27,7 +27,7 @@ function Row({ k, v }: { k: string; v: string }) {
*/ */
export function DebugPage() { export function DebugPage() {
const nav = useNavigate(); const nav = useNavigate();
const { token, driverId, driverName, impersonated, clear } = useAuth(); const { token, driverId, driverName, impersonated, own, clear, stopImpersonation } = useAuth();
const staff = useStaff(); const staff = useStaff();
const log = useSyncExternalStore(subscribe, getLog); const log = useSyncExternalStore(subscribe, getLog);
const [switching, setSwitching] = useState(false); const [switching, setSwitching] = useState(false);
@@ -68,10 +68,30 @@ export function DebugPage() {
)} )}
</div> </div>
{impersonated && (
<div className="card p-4 mb-4">
<p className="text-sm font-semibold mb-1">Боевая оплата</p>
<p className="text-muted text-xs mb-3">
Выключено пополнение открывает учебный экран, деньги не двигаются.
Включено платёж уходит в банк по-настоящему: спишется с вашей карты,
чек уйдёт на телефон водителя, сумма зачислится ему в 1С.
</p>
<button
className={staff.livePay ? "btn-primary" : "btn-ghost"}
onClick={() => staff.setLivePay(!staff.livePay)}
>
{staff.livePay ? "Боевая оплата включена — выключить" : "Включить боевую оплату"}
</button>
</div>
)}
<div className="card p-4 mb-4"> <div className="card p-4 mb-4">
<p className="text-muted text-xs mb-2">Сведения</p> <p className="text-muted text-xs mb-2">Сведения</p>
<Row k="driver_id" v={String(driverId ?? "—")} /> <Row k="driver_id" v={String(driverId ?? "—")} />
<Row k="режим" v={impersonated ? "просмотр сотрудником (imp)" : "обычный вход водителя"} /> <Row k="режим" v={impersonated ? "просмотр сотрудником (imp)" : "обычный вход водителя"} />
{impersonated && (
<Row k="оплата" v={staff.livePay ? "боевая — деньги двигаются" : "учебная"} />
)}
<Row k="сотрудник" v={staff.username ?? "не входил"} /> <Row k="сотрудник" v={staff.username ?? "не входил"} />
<Row k="токен истекает" v={expiresIn(info?.exp ?? null)} /> <Row k="токен истекает" v={expiresIn(info?.exp ?? null)} />
<Row k="API" v={API_BASE} /> <Row k="API" v={API_BASE} />
@@ -112,9 +132,16 @@ export function DebugPage() {
</button> </button>
<button <button
className="btn-ghost flex items-center justify-center gap-2" className="btn-ghost flex items-center justify-center gap-2"
onClick={() => { clear(); nav(staff.token ? "/dev" : "/login"); }} onClick={() => {
// Свой вход, отложенный на время просмотра, возвращаем на место: иначе
// «посмотреть чужой экран» означало бы заново входить через Telegram.
if (impersonated && stopImpersonation()) { nav("/"); return; }
clear();
nav(staff.token ? "/dev" : "/login");
}}
> >
<LogOut size={16} /> {impersonated ? "Выйти из просмотра" : "Выйти"} <LogOut size={16} />{" "}
{impersonated ? (own ? "Вернуться к своему аккаунту" : "Выйти из просмотра") : "Выйти"}
</button> </button>
{staff.token && ( {staff.token && (
<button className="text-muted text-xs w-full mt-4" <button className="text-muted text-xs w-full mt-4"
@@ -59,8 +59,9 @@ describe("DevLoginPage", () => {
await userEvent.click(screen.getByRole("button", { name: "Войти" })); await userEvent.click(screen.getByRole("button", { name: "Войти" }));
expect(useStaff.getState().token).toBeNull(); expect(useStaff.getState().token).toBeNull();
// Шестая цифра отправляет код сама: раньше форма ждала нажатия, а «Готово» на
// клавиатуре телефона ничего не делало — выглядело как «код не принят».
await userEvent.type(await screen.findByPlaceholderText("000000"), "123456"); await userEvent.type(await screen.findByPlaceholderText("000000"), "123456");
await userEvent.click(screen.getByRole("button", { name: "Подтвердить" }));
expect(staffApi.staffLogin2fa).toHaveBeenCalledWith("partial", "123456"); expect(staffApi.staffLogin2fa).toHaveBeenCalledWith("partial", "123456");
expect(useStaff.getState().token).toBe("staff-tok"); expect(useStaff.getState().token).toBe("staff-tok");
}); });
+18 -8
View File
@@ -42,11 +42,11 @@ export function DevLoginPage() {
} }
} }
async function submit2fa() { async function submit2fa(value = code) {
if (!partial || code.length < 6 || busy) return; if (!partial || value.length < 6 || busy) return;
setBusy(true); setBusy(true);
try { try {
const r = await staffLogin2fa(partial, code); const r = await staffLogin2fa(partial, value);
if (!r.access_token) throw new Error("no token"); if (!r.access_token) throw new Error("no token");
setStaff(r.access_token, username); setStaff(r.access_token, username);
setPartial(null); setPartial(null);
@@ -80,13 +80,23 @@ export function DevLoginPage() {
<> <>
<p className="text-sm mb-2">Код из приложения-аутентификатора.</p> <p className="text-sm mb-2">Код из приложения-аутентификатора.</p>
<input <input
autoFocus
inputMode="numeric" inputMode="numeric"
autoComplete="one-time-code"
value={code} value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))} onChange={(e) => {
const next = e.target.value.replace(/\D/g, "").slice(0, 6);
setCode(next);
// Шесть цифр — код собран, ждать нажатия нечего: у TOTP всё равно
// остаётся секунд тридцать. Раньше отправки не было ни по Enter, ни
// по «Готово» на клавиатуре телефона, и это выглядело как «код не принят».
if (next.length === 6) void submit2fa(next);
}}
onKeyDown={(e) => { if (e.key === "Enter") void submit2fa(); }}
placeholder="000000" placeholder="000000"
className="card w-full px-4 py-3 mb-3 text-base tracking-widest bg-white" className="card w-full px-4 py-3 mb-3 text-base tracking-widest bg-surface"
/> />
<button className="btn-primary" disabled={busy || code.length < 6} onClick={submit2fa}> <button className="btn-primary" disabled={busy || code.length < 6} onClick={() => submit2fa()}>
{busy ? <Spinner /> : "Подтвердить"} {busy ? <Spinner /> : "Подтвердить"}
</button> </button>
<button className="btn-ghost mt-2" onClick={() => { setPartial(null); setCode(""); }}> <button className="btn-ghost mt-2" onClick={() => { setPartial(null); setCode(""); }}>
@@ -101,7 +111,7 @@ export function DevLoginPage() {
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
placeholder="Логин" placeholder="Логин"
className="card w-full px-4 py-3 mb-2 text-base bg-white" className="card w-full px-4 py-3 mb-2 text-base bg-surface"
/> />
<input <input
type="password" type="password"
@@ -109,7 +119,7 @@ export function DevLoginPage() {
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") submit(); }} onKeyDown={(e) => { if (e.key === "Enter") submit(); }}
placeholder="Пароль" placeholder="Пароль"
className="card w-full px-4 py-3 mb-3 text-base bg-white" className="card w-full px-4 py-3 mb-3 text-base bg-surface"
/> />
<button className="btn-primary" disabled={busy || !username || !password} onClick={submit}> <button className="btn-primary" disabled={busy || !username || !password} onClick={submit}>
{busy ? <Spinner /> : "Войти"} {busy ? <Spinner /> : "Войти"}
@@ -0,0 +1,115 @@
import { useQuery } from "@tanstack/react-query";
import { useNavigate, useParams } from "react-router-dom";
import { FileText, Gauge } from "lucide-react";
import { getFine } from "@/api/driver";
import { fineStatusText, isOwed } from "@/lib/fineStatus";
import { formatDate, formatMoney } from "@/lib/format";
import { Spinner } from "@/components/Spinner";
function Row({ k, v }: { k: string; v: string }) {
return (
<div className="flex justify-between gap-3 py-2 border-b border-line last:border-0">
<span className="text-muted text-xs shrink-0">{k}</span>
<span className="text-xs text-right">{v}</span>
</div>
);
}
/**
* Карточка штрафа. Главное здесь — фотография с камеры: по ней водитель узнаёт
* свою машину и свою поездку либо понимает, что штраф не его. Поэтому она стоит
* первой, до реквизитов, и открывается в полный размер по нажатию.
*
* Что парк заплатил ГИБДД, со скидкой ли и какой номинал у постановления — здесь
* не показывается: водителю нужен факт штрафа и сумма к возврату, а разбор
* расчётов парка уводит разговор от долга к внутренней кухне.
*/
export function FineDetailPage() {
const { fineId = "" } = useParams();
const nav = useNavigate();
const { data: f, isLoading, isError } = useQuery({
queryKey: ["fine", fineId],
queryFn: () => getFine(Number(fineId)),
});
if (isLoading) return <div className="flex justify-center py-16"><Spinner /></div>;
if (isError || !f) {
return (
<div className="pt-16 text-center">
<p className="text-sm font-semibold mb-1">Штраф не найден</p>
<p className="text-muted text-xs mb-6">Возможно, он больше не числится за вами.</p>
<button className="btn-ghost" onClick={() => nav("/")}>На главную</button>
</div>
);
}
return (
<div className="pt-2 pb-4">
<button className="text-muted text-xs mb-3" onClick={() => nav("/")}> Назад</button>
{f.photos.length > 0 ? (
<div className="mb-4 space-y-2">
{f.photos.map((src, i) => (
<a key={src} href={src} target="_blank" rel="noopener noreferrer" className="block">
<img
src={src}
alt={`Фото нарушения ${i + 1}`}
loading="lazy"
className="w-full rounded-xl2 border border-line"
/>
</a>
))}
<p className="text-muted text-[11px]">Нажмите на фото, чтобы открыть в полном размере</p>
</div>
) : (
<p className="text-muted text-xs mb-4">
Фотографии по этому постановлению нет её присылают не по всем нарушениям.
</p>
)}
<h1 className="text-base font-extrabold mb-1">{f.article}</h1>
<p className="text-muted text-xs mb-4">
{formatDate(f.date)}{f.address ? ` · ${f.address}` : ""}
</p>
<div className="card p-4 mb-4 text-center">
<p className="text-muted text-xs mb-1">Начислено вам</p>
<p className={`text-2xl font-extrabold ${isOwed(f) ? "text-neg" : ""}`}>
{formatMoney(f.amount)}
</p>
<p className="text-muted text-xs mt-1">{fineStatusText(f)}</p>
</div>
{f.speed != null && f.speed_limit != null && (
<div className="card p-4 mb-4 flex items-center gap-3">
<Gauge size={20} strokeWidth={1.5} className="text-muted shrink-0" />
<p className="text-sm">
{f.speed} км/ч при разрешённых {f.speed_limit}
{f.speed_excess ? ` — превышение на ${f.speed_excess}` : ""}
</p>
</div>
)}
<div className="card p-4 mb-4">
<Row k="Постановление" v={f.number} />
{f.post_date && <Row k="Дата постановления" v={formatDate(f.post_date)} />}
{f.car_number && <Row k="Автомобиль" v={f.car_number} />}
{f.camera && <Row k="Камера" v={f.camera} />}
</div>
{f.document && (
<a href={f.document} target="_blank" rel="noopener noreferrer"
className="btn-ghost flex items-center justify-center gap-2 no-underline mb-2">
<FileText size={16} /> Открыть постановление
</a>
)}
{isOwed(f) && (
<button className="btn-primary"
onClick={() => nav("/topup", { state: { bucket: "Долг по штрафам" } })}>
Оплатить штрафы
</button>
)}
</div>
);
}
@@ -1,25 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("@/api/driver", () => ({
getPayments: vi.fn(async () => ({ payments: [
{ order_id: "o1", bucket: "Долг аренда", amount: 1000, commission: 52, method: "qr", status: "PAID", created_at: "2026-06-18T09:00:00Z", paid_at: "2026-06-18T09:01:00Z" },
] })),
}));
import { HistoryPage } from "./HistoryPage";
function wrap() {
const qc = new QueryClient();
return render(<QueryClientProvider client={qc}><MemoryRouter><HistoryPage /></MemoryRouter></QueryClientProvider>);
}
describe("HistoryPage", () => {
it("lists payments with amount + status", async () => {
wrap();
expect(await screen.findByText("Долг аренда")).toBeInTheDocument();
expect(screen.getByText("1 000 ₽")).toBeInTheDocument();
expect(screen.getByText(/Оплачено/i)).toBeInTheDocument();
});
});
@@ -1,67 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { getPayments } from "@/api/driver";
import { formatMoney } from "@/lib/format";
import { Spinner } from "@/components/Spinner";
/**
* Все статусы, которые может прислать бэкенд. Раньше их было пять из тринадцати,
* и водитель видел в истории «DEADLINE_EXPIRED» или «ATTEMPTS_EXPIRED» латиницей,
* а возврат денег выглядел ровно как обычный отказ.
*/
const STATUS_RU: Record<string, string> = {
NEW: "Ожидает оплаты",
FORM: "Ожидает оплаты",
PAID: "Оплачено",
CONFIRMED: "Оплачено",
REJECTED: "Отклонён банком",
AUTH_FAIL: "Отклонён банком",
CANCELED: "Отменён",
DEADLINE_EXPIRED: "Истёк срок оплаты",
ATTEMPTS_EXPIRED: "Исчерпаны попытки оплаты",
ERROR: "Ошибка",
REVERSED: "Деньги возвращены",
REFUNDED: "Деньги возвращены",
PARTIAL_REVERSED: "Возвращена часть",
PARTIAL_REFUNDED: "Возвращена часть",
};
const RETURNED = new Set(["REVERSED", "REFUNDED", "PARTIAL_REVERSED", "PARTIAL_REFUNDED"]);
export function HistoryPage() {
const nav = useNavigate();
const { data, isLoading, isError } = useQuery({ queryKey: ["payments"], queryFn: getPayments });
const rows = data?.payments ?? [];
return (
<div className="pt-2">
<button className="text-muted text-xs mb-3" onClick={() => nav("/")}> Назад</button>
<h1 className="text-lg font-extrabold mb-4">История пополнений</h1>
{isLoading ? (
<div className="flex justify-center py-10"><Spinner /></div>
) : isError ? (
<p className="text-muted text-sm">Не удалось загрузить историю</p>
) : rows.length === 0 ? (
<p className="text-muted text-sm">Пока нет пополнений</p>
) : (
rows.map((p) => {
const returned = RETURNED.has(p.status) || !!p.reversed_at;
return (
<div key={p.order_id} className="flex justify-between items-center py-3 border-b border-line">
<div>
<p className="text-sm">{p.bucket}</p>
<p className="text-muted text-xs">
{p.created_at ? new Date(p.created_at).toLocaleDateString("ru-RU") : ""} ·{" "}
{STATUS_RU[p.status] ?? p.status}
</p>
</div>
<b className={returned ? "text-muted line-through" : undefined}>
{formatMoney(p.amount)}
</b>
</div>
);
})
)}
</div>
);
}
+50 -9
View File
@@ -1,12 +1,23 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { apiErrorText, authRequest, authVerify, tgSession } from "@/api/driver"; import { apiErrorText, authRequest, authVerify, retryAfterSeconds, tgSession } from "@/api/driver";
import { digitsOnly, formatPhone } from "@/lib/format"; import { digitsOnly, formatPhone } from "@/lib/format";
import { savePairing } from "@/lib/pairing"; import { savePairing } from "@/lib/pairing";
import { takeReturnTo } from "@/lib/returnTo";
import { useAuth } from "@/store/auth"; import { useAuth } from "@/store/auth";
import { Spinner } from "@/components/Spinner"; import { Spinner } from "@/components/Spinner";
// Кулдаун повторной отправки кода — синхронно с бэкендом (60с). Точное значение
// приходит в Retry-After на 429; это дефолт после успешной отправки.
const RESEND_COOLDOWN_SEC = 60;
function fmtMMSS(sec: number): string {
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
export function LoginPage() { export function LoginPage() {
const nav = useNavigate(); const nav = useNavigate();
const setSession = useAuth((s) => s.setSession); const setSession = useAuth((s) => s.setSession);
@@ -16,6 +27,8 @@ export function LoginPage() {
const [channel, setChannel] = useState<string>(""); const [channel, setChannel] = useState<string>("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [mode, setMode] = useState<"choose" | "code">("choose"); const [mode, setMode] = useState<"choose" | "code">("choose");
// Секунды до разрешённой повторной отправки кода (0 — можно отправлять).
const [cooldown, setCooldown] = useState(0);
// Prefetched pairing session so the «Войти через Telegram» control is a real // Prefetched pairing session so the «Войти через Telegram» control is a real
// <a href> the OS opens in Telegram (a synchronous user gesture) WITHOUT // <a href> the OS opens in Telegram (a synchronous user gesture) WITHOUT
// navigating the PWA away — the app stays mounted on /tg-wait and polls. // navigating the PWA away — the app stays mounted on /tg-wait and polls.
@@ -29,6 +42,13 @@ export function LoginPage() {
return () => { alive = false; }; return () => { alive = false; };
}, []); }, []);
// Тик обратного отсчёта повторной отправки.
useEffect(() => {
if (cooldown <= 0) return;
const t = setTimeout(() => setCooldown((s) => Math.max(0, s - 1)), 1000);
return () => clearTimeout(t);
}, [cooldown]);
function startTg() { function startTg() {
if (!tg) return; if (!tg) return;
savePairing(tg); savePairing(tg);
@@ -36,7 +56,7 @@ export function LoginPage() {
} }
async function requestCode() { async function requestCode() {
if (phone.length < 10) return; if (phone.length < 10 || cooldown > 0) return;
setBusy(true); setBusy(true);
try { try {
const r = await authRequest(e164); const r = await authRequest(e164);
@@ -51,13 +71,23 @@ export function LoginPage() {
} }
setChannel(r.channel); setChannel(r.channel);
setStep("code"); setStep("code");
// Запускаем отсчёт до повторной отправки — чтобы водитель не жал повторно
// раньше времени и не упирался в «слишком часто».
setCooldown(RESEND_COOLDOWN_SEC);
} catch (err: unknown) { } catch (err: unknown) {
const status = (err as { response?: { status?: number } })?.response?.status; const status = (err as { response?: { status?: number } })?.response?.status;
if (status === 429) {
// Точный остаток — из Retry-After; ведём по нему отсчёт и не пугаем.
const wait = retryAfterSeconds(err) ?? RESEND_COOLDOWN_SEC;
setCooldown(wait);
if (step === "phone") setStep("code"); // код мог уйти ранее — дать ввести
toast.info(await apiErrorText(err, `Код уже отправляли. Повторить можно через ${wait} сек.`));
} else {
const fallback = const fallback =
status === 404 ? "Номер не найден. Обратитесь в парк." : status === 404 ? "Номер не найден. Обратитесь в парк." :
status === 429 ? "Слишком часто. Попробуйте позже." :
"Не удалось отправить код"; "Не удалось отправить код";
toast.error(await apiErrorText(err, fallback)); toast.error(await apiErrorText(err, fallback));
}
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -69,7 +99,8 @@ export function LoginPage() {
try { try {
const r = await authVerify(e164, code); const r = await authVerify(e164, code);
setSession(r.token, r.driver_id); setSession(r.token, r.driver_id);
nav("/"); // Возврат туда, откуда пришёл (напр. ссылка на оплату), иначе на баланс.
nav(takeReturnTo(), { replace: true });
} catch (err: unknown) { } catch (err: unknown) {
toast.error(await apiErrorText(err, "Неверный или просроченный код")); toast.error(await apiErrorText(err, "Неверный или просроченный код"));
} finally { } finally {
@@ -116,10 +147,10 @@ export function LoginPage() {
placeholder="Номер телефона" placeholder="Номер телефона"
value={phone ? formatPhone(phone) : ""} value={phone ? formatPhone(phone) : ""}
onChange={(e) => setPhone(digitsOnly(e.target.value).replace(/^7/, "").slice(0, 10))} onChange={(e) => setPhone(digitsOnly(e.target.value).replace(/^7/, "").slice(0, 10))}
className="card w-full px-4 py-3 mb-3 text-base bg-white" className="card w-full px-4 py-3 mb-3 text-base bg-surface"
/> />
<button className="btn-primary" disabled={busy || phone.length < 10} onClick={requestCode}> <button className="btn-primary" disabled={busy || phone.length < 10 || cooldown > 0} onClick={requestCode}>
{busy ? <Spinner /> : "Получить код"} {busy ? <Spinner /> : cooldown > 0 ? `Повторить через ${fmtMMSS(cooldown)}` : "Получить код"}
</button> </button>
<p className="text-muted text-xs mt-3">Код придёт в Telegram или MAX.</p> <p className="text-muted text-xs mt-3">Код придёт в Telegram или MAX.</p>
</> </>
@@ -131,12 +162,22 @@ export function LoginPage() {
placeholder="Код из сообщения" placeholder="Код из сообщения"
value={code} value={code}
onChange={(e) => setCode(digitsOnly(e.target.value).slice(0, 6))} onChange={(e) => setCode(digitsOnly(e.target.value).slice(0, 6))}
className="card w-full px-4 py-3 mb-3 text-base tracking-widest bg-white" className="card w-full px-4 py-3 mb-3 text-base tracking-widest bg-surface"
/> />
<button className="btn-primary" disabled={busy || code.length < 4} onClick={verify}> <button className="btn-primary" disabled={busy || code.length < 4} onClick={verify}>
{busy ? <Spinner /> : "Войти"} {busy ? <Spinner /> : "Войти"}
</button> </button>
<button className="btn-ghost mt-2" onClick={() => setStep("phone")}>Изменить номер</button> {/* Повторная отправка прямо здесь: не пришёл код — не надо
возвращаться к номеру и упираться в «слишком часто». Кнопка
ждёт кулдаун и показывает, сколько осталось. */}
<button
className="btn-ghost mt-2"
disabled={busy || cooldown > 0}
onClick={requestCode}
>
{cooldown > 0 ? `Отправить ещё раз через ${fmtMMSS(cooldown)}` : "Отправить код ещё раз"}
</button>
<button className="btn-ghost mt-1" onClick={() => setStep("phone")}>Изменить номер</button>
</> </>
)} )}
</> </>
@@ -0,0 +1,48 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
vi.mock("@/api/driver", () => ({
getNotifications: vi.fn(async () => ({
items: [
{ id: 2, kind: "fine", title: "Новый штраф", body: "Постановление от 18.08.2026.",
url: "/fines/12", created_at: "2026-08-18T10:15:00+10:00", read_at: null },
{ id: 1, kind: "topup", title: "Платёж получен", body: "Пополнение принято.",
url: "/", created_at: "2026-08-17T09:00:00+10:00", read_at: "2026-08-17T09:05:00+10:00" },
],
unread: 1,
})),
markNotificationsRead: vi.fn(async () => ({ marked: 1 })),
}));
import { NotificationsPage } from "./NotificationsPage";
import * as api from "@/api/driver";
const show = () => render(<MemoryRouter><NotificationsPage /></MemoryRouter>);
describe("лента уведомлений", () => {
beforeEach(() => vi.clearAllMocks());
it("показывает, что мы писали водителю", async () => {
show();
expect(await screen.findByText("Новый штраф")).toBeInTheDocument();
expect(screen.getByText("Платёж получен")).toBeInTheDocument();
// Дата человеку — ДД.ММ.ГГГГ, не ISO (текст постановления содержит её же).
expect(screen.getByText("18.08.2026 10:15")).toBeInTheDocument();
});
it("открытие ленты отмечает непрочитанное", async () => {
show();
await screen.findByText("Новый штраф");
// Это единственный честный источник отметки: пуш живёт до смахивания, а
// сообщение в боте тонет в переписке — там прочтения не видно вовсе.
expect(api.markNotificationsRead).toHaveBeenCalled();
});
it("без непрочитанного лишний запрос не делаем", async () => {
vi.mocked(api.getNotifications).mockResolvedValueOnce({ items: [], unread: 0 });
show();
expect(await screen.findByText(/Пока ничего не приходило/)).toBeInTheDocument();
expect(api.markNotificationsRead).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,122 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Bell } from "lucide-react";
import { getNotifications, markNotificationsRead, type DriverNotification } from "@/api/driver";
import { AppHeader } from "@/components/AppHeader";
import { Spinner } from "@/components/Spinner";
import { OperationIcon } from "@/components/operationIcon";
// Значок берём тем же подбором, что и в выписке: событие приложения переводим
// в тег 1С, чтобы штраф в ленте выглядел так же, как штраф в списке операций.
const ICON_BY_KIND: Record<string, string> = {
fine: "Штраф ГИБДД",
topup: "Пополнение баланса",
};
/**
* Лента уведомлений водителя.
*
* Нужна не только водителю. Пуш живёт до первого смахивания, сообщение в боте
* тонет в переписке — и разговор «мне ничего не приходило» упирался в слово
* против слова. Здесь у водителя есть место, куда посмотреть, а у парка —
* честная отметка «прочитано»: она ставится ровно тогда, когда человек открыл
* этот экран или нажал на само уведомление, и ниоткуда больше.
*
* Отмечаем прочитанным ПОСЛЕ показа, а не при загрузке: иначе счётчик гаснет у
* того, кто просто мазнул по экрану и ушёл.
*/
export function NotificationsPage() {
const nav = useNavigate();
const [items, setItems] = useState<DriverNotification[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let alive = true;
getNotifications()
.then((r) => {
if (!alive) return;
setItems(r.items);
if (r.unread > 0) void markNotificationsRead();
})
.catch(() => alive && setError("Не удалось загрузить уведомления"));
return () => {
alive = false;
};
}, []);
return (
<div className="p-4">
<AppHeader />
<h1 className="text-lg font-bold mb-3">Уведомления</h1>
{error && <div className="text-sm text-muted">{error}</div>}
{!items && !error && <Spinner />}
{items && items.length === 0 && (
<div className="text-sm text-muted">
Пока ничего не приходило. Здесь будут появляться сообщения о штрафах и платежах.
</div>
)}
<div className="flex flex-col gap-2">
{items?.map((n) => (
<button
key={n.id}
type="button"
className="flex gap-3 items-start text-left bg-surface border border-line rounded-xl p-3"
onClick={() => n.url && nav(n.url)}
>
<OperationIcon title={ICON_BY_KIND[n.kind] ?? ""} />
<span className="flex-1 min-w-0">
<span className="flex items-baseline gap-2">
<span className="font-semibold text-sm">{n.title}</span>
{!n.read_at && <span className="w-2 h-2 rounded-full bg-accent shrink-0" />}
</span>
<span className="block text-sm text-muted mt-0.5">{n.body}</span>
<span className="block text-xs text-muted mt-1">{when(n.created_at)}</span>
</span>
</button>
))}
</div>
</div>
);
}
/** Дата человеку — ДД.ММ.ГГГГ и время, без ISO. */
function when(ts: string | null): string {
if (!ts) return "";
const d = new Date(ts);
const p = (n: number) => String(n).padStart(2, "0");
return `${p(d.getDate())}.${p(d.getMonth() + 1)}.${d.getFullYear()} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
/** Колокольчик со счётчиком непрочитанного — для шапки главного экрана. */
export function NotificationsBell() {
const nav = useNavigate();
const [unread, setUnread] = useState(0);
useEffect(() => {
let alive = true;
getNotifications()
.then((r) => alive && setUnread(r.unread))
// Молча: колокольчик — не тот элемент, ради которого стоит показывать ошибку.
.catch(() => undefined);
return () => {
alive = false;
};
}, []);
return (
<button type="button" className="relative p-1 -m-1" aria-label="Уведомления"
onClick={() => nav("/notifications")}>
<Bell size={22} strokeWidth={1.5} />
{unread > 0 && (
<span className="absolute -top-0.5 -right-0.5 min-w-4 h-4 px-1 rounded-full bg-accent
text-white text-[10px] leading-4 text-center">
{unread > 9 ? "9+" : unread}
</span>
)}
</button>
);
}
@@ -3,6 +3,7 @@ import { useLocation, useNavigate, useParams } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { Check, Clock, Undo2, WifiOff, X } from "lucide-react"; import { Check, Clock, Undo2, WifiOff, X } from "lucide-react";
import { formatMoney } from "@/lib/format"; import { formatMoney } from "@/lib/format";
import { markPaymentActivity } from "@/lib/paymentWatch";
import { Spinner } from "@/components/Spinner"; import { Spinner } from "@/components/Spinner";
import { usePaymentStatus } from "@/hooks/usePaymentStatus"; import { usePaymentStatus } from "@/hooks/usePaymentStatus";
@@ -31,7 +32,12 @@ export function PaymentPage() {
const card = st.method ? st.method === "card" : !!st.payUrl && !st.payUrl.includes("qr.nspk.ru"); const card = st.method ? st.method === "card" : !!st.payUrl && !st.payUrl.includes("qr.nspk.ru");
useEffect(() => { useEffect(() => {
if (paid) qc.invalidateQueries({ queryKey: ["balance"] }); if (paid) {
// Отметка продлевает окно опроса баланса от ФАКТА оплаты: путь до нового
// итога (запись в 1С + снимок) начинается только теперь.
markPaymentActivity();
qc.invalidateQueries({ queryKey: ["balance"] });
}
}, [paid, qc]); }, [paid, qc]);
const retry = ( const retry = (
@@ -103,7 +109,7 @@ export function PaymentPage() {
return ( return (
<div className="pt-10 flex flex-col items-center text-center"> <div className="pt-10 flex flex-col items-center text-center">
<span className="px-4 py-2.5 rounded-xl2 bg-ink text-cream text-sm font-extrabold mb-4">{card ? "Картой" : "СБП"}</span> <span className="px-4 py-2.5 rounded-xl2 bg-ink text-white text-sm font-extrabold mb-4">{card ? "Картой" : "СБП"}</span>
<div className="mb-6"> <div className="mb-6">
{/* после перезагрузки суммы в state нет: лучше не показать ничего, чем показать 0 ₽ */} {/* после перезагрузки суммы в state нет: лучше не показать ничего, чем показать 0 ₽ */}
{st.total != null && <p className="text-lg font-extrabold">{formatMoney(st.total)}</p>} {st.total != null && <p className="text-lg font-extrabold">{formatMoney(st.total)}</p>}
+3 -1
View File
@@ -2,6 +2,7 @@ import { useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { usePairPoll } from "@/hooks/usePairPoll"; import { usePairPoll } from "@/hooks/usePairPoll";
import { clearPairing, loadPairing } from "@/lib/pairing"; import { clearPairing, loadPairing } from "@/lib/pairing";
import { takeReturnTo } from "@/lib/returnTo";
import { Spinner } from "@/components/Spinner"; import { Spinner } from "@/components/Spinner";
export function TgWaitPage() { export function TgWaitPage() {
@@ -16,7 +17,8 @@ export function TgWaitPage() {
useEffect(() => { useEffect(() => {
if (status === "authenticated") { if (status === "authenticated") {
clearPairing(); clearPairing();
nav("/", { replace: true }); // Возврат туда, откуда пришёл (напр. ссылка на оплату), иначе на баланс.
nav(takeReturnTo(), { replace: true });
} }
if (status === "expired") { if (status === "expired") {
clearPairing(); clearPairing();
@@ -5,7 +5,8 @@ import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("@/api/driver", () => ({ vi.mock("@/api/driver", () => ({
topup: vi.fn(async () => ({ order_id: "o9", pay_url: "https://qr.nspk.ru/x", commission: 52, total: 1052 })), topup: vi.fn(async () => ({ order_id: "o9", pay_url: "https://qr.nspk.ru/x", commission: 52,
total: 1052, mock: false })),
getCommission: vi.fn(async () => ({ rate: 0.052 })), getCommission: vi.fn(async () => ({ rate: 0.052 })),
getBuckets: vi.fn(async () => ({ getBuckets: vi.fn(async () => ({
buckets: ["Долг аренда", "Долг по штрафам"], with_debt: ["Долг аренда"], buckets: ["Долг аренда", "Долг по штрафам"], with_debt: ["Долг аренда"],
@@ -34,7 +35,7 @@ describe("TopupPage", () => {
expect(screen.getByTestId("amount").textContent).toBe("1 000 ₽"); expect(screen.getByTestId("amount").textContent).toBe("1 000 ₽");
expect(screen.getByTestId("total").textContent).toContain("1 052"); expect(screen.getByTestId("total").textContent).toContain("1 052");
await userEvent.click(screen.getByRole("button", { name: /Оплатить/i })); await userEvent.click(screen.getByRole("button", { name: /Оплатить/i }));
expect(driver.topup).toHaveBeenCalledWith("Долг аренда", 1000, "qr"); expect(driver.topup).toHaveBeenCalledWith("Долг аренда", 1000, "qr", false);
}); });
it("decimal key builds kopecks and they reach the backend", async () => { it("decimal key builds kopecks and they reach the backend", async () => {
@@ -46,7 +47,7 @@ describe("TopupPage", () => {
} }
expect(screen.getByTestId("amount").textContent).toBe("100,52 ₽"); expect(screen.getByTestId("amount").textContent).toBe("100,52 ₽");
await userEvent.click(screen.getByRole("button", { name: /Оплатить/i })); await userEvent.click(screen.getByRole("button", { name: /Оплатить/i }));
expect(driver.topup).toHaveBeenCalledWith("Долг аренда", 100.52, "qr"); expect(driver.topup).toHaveBeenCalledWith("Долг аренда", 100.52, "qr", false);
}); });
it("lets the driver choose the debt category instead of hardcoding rent", async () => { it("lets the driver choose the debt category instead of hardcoding rent", async () => {
@@ -57,6 +58,32 @@ describe("TopupPage", () => {
await userEvent.click(screen.getByRole("button", { name: "0" })); await userEvent.click(screen.getByRole("button", { name: "0" }));
await userEvent.click(screen.getByRole("button", { name: "0" })); await userEvent.click(screen.getByRole("button", { name: "0" }));
await userEvent.click(screen.getByRole("button", { name: /Оплатить/i })); await userEvent.click(screen.getByRole("button", { name: /Оплатить/i }));
expect(driver.topup).toHaveBeenCalledWith("Долг по штрафам", 500, "qr"); expect(driver.topup).toHaveBeenCalledWith("Долг по штрафам", 500, "qr", false);
});
});
describe("боевая оплата под просмотром", () => {
it("по умолчанию платёж учебный — флаг в запрос уходит выключенным", async () => {
const { useStaff } = await import("@/store/staff");
useStaff.getState().clear();
wrap();
await userEvent.click(screen.getByRole("button", { name: "5" }));
await userEvent.click(screen.getByRole("button", { name: "0" }));
await userEvent.click(screen.getByRole("button", { name: "0" }));
await userEvent.click(screen.getByRole("button", { name: /Оплатить/i }));
expect(driver.topup).toHaveBeenLastCalledWith("Долг аренда", 500, "qr", false);
});
it("включённая боевая оплата предупреждает и уходит с флагом", async () => {
const { useStaff } = await import("@/store/staff");
useStaff.getState().setLivePay(true);
wrap();
expect(screen.getByText(/деньги спишутся по-настоящему/i)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "5" }));
await userEvent.click(screen.getByRole("button", { name: "0" }));
await userEvent.click(screen.getByRole("button", { name: "0" }));
await userEvent.click(screen.getByRole("button", { name: /Оплатить/i }));
expect(driver.topup).toHaveBeenLastCalledWith("Долг аренда", 500, "qr", true);
useStaff.getState().clear();
}); });
}); });
+22 -3
View File
@@ -6,6 +6,8 @@ import { toast } from "sonner";
import { apiErrorText, topup, getCommission, getBuckets } from "@/api/driver"; import { apiErrorText, topup, getCommission, getBuckets } from "@/api/driver";
import { keypadReduce, parseAmount, withCommission } from "@/lib/amount"; import { keypadReduce, parseAmount, withCommission } from "@/lib/amount";
import { formatMoney } from "@/lib/format"; import { formatMoney } from "@/lib/format";
import { markPaymentActivity } from "@/lib/paymentWatch";
import { useStaff } from "@/store/staff";
import { Segment } from "@/components/Segment"; import { Segment } from "@/components/Segment";
import { Spinner } from "@/components/Spinner"; import { Spinner } from "@/components/Spinner";
@@ -14,6 +16,7 @@ const KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", ",", "0", "back"];
export function TopupPage() { export function TopupPage() {
const nav = useNavigate(); const nav = useNavigate();
const livePay = useStaff((s) => s.livePay);
const loc = useLocation(); const loc = useLocation();
const fromBalance = (loc.state as { bucket?: string } | null)?.bucket; const fromBalance = (loc.state as { bucket?: string } | null)?.bucket;
// Список категорий приходит с сервера — там же он и проверяется. Хардкод // Список категорий приходит с сервера — там же он и проверяется. Хардкод
@@ -40,13 +43,16 @@ export function TopupPage() {
if (base <= 0 || !bucket || busy) return; if (base <= 0 || !bucket || busy) return;
setBusy(true); setBusy(true);
try { try {
const r = await topup(bucket, base, method as "qr" | "card"); const r = await topup(bucket, base, method as "qr" | "card", livePay);
// Учебный платёж (сотрудник смотрит от чужого лица): банка нет, платить // Учебный платёж (сотрудник смотрит от чужого лица): банка нет, платить
// негде — сразу на экран выбора исхода, дальше всё как у водителя. // негде — сразу на экран выбора исхода, дальше всё как у водителя.
if (r.mock) { if (r.mock) {
nav(`/mock-pay/${r.order_id}`, { state: { total: r.total, bucket, base, method } }); nav(`/mock-pay/${r.order_id}`, { state: { total: r.total, bucket, base, method } });
return; return;
} }
// Отметка ДО ухода на оплату: вернись водитель на баланс жестом «назад»
// раньше вебхука банка, экран обязан опрашивать себя и подхватить платёж.
markPaymentActivity();
nav(`/pay/${r.order_id}`, { state: { payUrl: r.pay_url, total: r.total, bucket, base, method } }); nav(`/pay/${r.order_id}`, { state: { payUrl: r.pay_url, total: r.total, bucket, base, method } });
} catch (err: unknown) { } catch (err: unknown) {
// Текст с сервера, а не глухое «не удалось»: минимум СБП, потолок суммы и // Текст с сервера, а не глухое «не удалось»: минимум СБП, потолок суммы и
@@ -61,7 +67,13 @@ export function TopupPage() {
<div className="pt-2"> <div className="pt-2">
<button className="text-muted text-xs mb-3" onClick={() => nav(-1)}> Назад</button> <button className="text-muted text-xs mb-3" onClick={() => nav(-1)}> Назад</button>
{options.length > 1 && ( {/* Категорию выбирают в шторке на главном экране. Пришли оттуда — показываем
выбранное строкой, а не рядом чипов: второй выбор того же самого сбивает
с толку. Чипы остаются для входа сюда напрямую (кнопка «Пополнить» без
категории, возврат «пополнить ещё раз» с экрана оплаты). */}
{fromBalance ? (
<p className="text-muted text-xs mb-3">Пополняем: <b className="text-ink">{bucket}</b></p>
) : options.length > 1 ? (
<div className="flex gap-1.5 mb-3 overflow-x-auto"> <div className="flex gap-1.5 mb-3 overflow-x-auto">
{options.map((b) => ( {options.map((b) => (
<button <button
@@ -75,7 +87,7 @@ export function TopupPage() {
</button> </button>
))} ))}
</div> </div>
)} ) : null}
<p data-testid="amount" className="text-3xl font-extrabold text-center mt-2">{formatMoney(base)}</p> <p data-testid="amount" className="text-3xl font-extrabold text-center mt-2">{formatMoney(base)}</p>
<p data-testid="total" className="text-muted text-xs text-center mb-3"> <p data-testid="total" className="text-muted text-xs text-center mb-3">
@@ -107,6 +119,13 @@ export function TopupPage() {
<Segment value={method} onChange={setMethod} options={[{ value: "qr", label: "СБП" }, { value: "card", label: "Картой" }]} /> <Segment value={method} onChange={setMethod} options={[{ value: "qr", label: "СБП" }, { value: "card", label: "Картой" }]} />
</div> </div>
{livePay && (
// Предупреждение прямо над кнопкой: под чужим лицом ошибиться легко, а
// отыграть боевой платёж — это возврат в банке и правка в 1С.
<p className="text-neg text-xs mb-2 text-center">
Боевая оплата: деньги спишутся по-настоящему и зачислятся водителю
</p>
)}
<button className="btn-primary" disabled={busy || base <= 0 || !bucket} onClick={pay}> <button className="btn-primary" disabled={busy || base <= 0 || !bucket} onClick={pay}>
{busy ? <Spinner /> : "Оплатить"} {busy ? <Spinner /> : "Оплатить"}
</button> </button>
+43 -2
View File
@@ -10,20 +10,33 @@ export interface ViewAs {
driverName: string | null; driverName: string | null;
} }
/** Собственный сеанс, отложенный на время просмотра чужого. */
interface OwnSession {
token: string;
driverId: number | null;
}
interface AuthState extends ViewAs { interface AuthState extends ViewAs {
token: string | null; token: string | null;
driverId: number | null; driverId: number | null;
/** Сеанс, к которому вернёт «выйти из просмотра». null — возвращаться некуда. */
own: OwnSession | null;
setSession: (token: string, driverId: number, view?: Partial<ViewAs>) => void; setSession: (token: string, driverId: number, view?: Partial<ViewAs>) => void;
/** Начать просмотр от лица водителя, отложив собственный вход. */
startImpersonation: (token: string, driverId: number, driverName: string) => void;
/** Вернуться к себе. true — вернулись, false — своего входа не было. */
stopImpersonation: () => boolean;
clear: () => void; clear: () => void;
} }
export const useAuth = create<AuthState>()( export const useAuth = create<AuthState>()(
persist( persist(
(set) => ({ (set, get) => ({
token: null, token: null,
driverId: null, driverId: null,
impersonated: false, impersonated: false,
driverName: null, driverName: null,
own: null,
setSession: (token, driverId, view) => setSession: (token, driverId, view) =>
set({ set({
token, token,
@@ -31,7 +44,35 @@ export const useAuth = create<AuthState>()(
impersonated: view?.impersonated ?? false, impersonated: view?.impersonated ?? false,
driverName: view?.driverName ?? null, driverName: view?.driverName ?? null,
}), }),
clear: () => set({ token: null, driverId: null, impersonated: false, driverName: null }), startImpersonation: (token, driverId, driverName) => {
const s = get();
// Свой вход откладываем ОДИН раз — при переходе из обычного сеанса. Смена
// водителя внутри просмотра не должна затирать отложенное собственным
// чужим токеном, иначе «вернуться к себе» вернёт к предыдущему водителю.
const own = s.impersonated
? s.own
: s.token
? { token: s.token, driverId: s.driverId }
: null;
set({ token, driverId, impersonated: true, driverName, own });
},
stopImpersonation: () => {
const own = get().own;
if (!own) {
set({ token: null, driverId: null, impersonated: false, driverName: null });
return false;
}
set({
token: own.token,
driverId: own.driverId,
impersonated: false,
driverName: null,
own: null,
});
return true;
},
clear: () =>
set({ token: null, driverId: null, impersonated: false, driverName: null, own: null }),
}), }),
{ name: "pp-driver-auth" } { name: "pp-driver-auth" }
) )
+8 -1
View File
@@ -12,7 +12,11 @@ import { persist } from "zustand/middleware";
interface StaffState { interface StaffState {
token: string | null; token: string | null;
username: string | null; username: string | null;
/** Боевая оплата под просмотром: платёж уходит в банк и зачисляется водителю.
* Выключено по умолчанию — под чужим лицом легко забыть, кого пополняешь. */
livePay: boolean;
setStaff: (token: string, username: string) => void; setStaff: (token: string, username: string) => void;
setLivePay: (on: boolean) => void;
clear: () => void; clear: () => void;
} }
@@ -21,8 +25,11 @@ export const useStaff = create<StaffState>()(
(set) => ({ (set) => ({
token: null, token: null,
username: null, username: null,
livePay: false,
setStaff: (token, username) => set({ token, username }), setStaff: (token, username) => set({ token, username }),
clear: () => set({ token: null, username: null }), setLivePay: (livePay) => set({ livePay }),
// Выход сотрудника гасит и боевой режим: следующий раз он включается осознанно.
clear: () => set({ token: null, username: null, livePay: false }),
}), }),
{ name: "pp-driver-staff" } { name: "pp-driver-staff" }
) )
+76
View File
@@ -0,0 +1,76 @@
/// <reference lib="webworker" />
import { cleanupOutdatedCaches, precacheAndRoute } from "workbox-precaching";
import { clientsClaim } from "workbox-core";
/**
* Сервис-воркер приложения.
*
* Свой, а не сгенерированный: `generateSW` умеет только кэш, а нам нужны
* обработчики `push` и `notificationclick` — уведомления показывает именно
* воркер, страница для этого может быть закрыта.
*
* Кэш остаётся прежним (precache от workbox), поведение обновления тоже:
* `skipWaiting` + `clientsClaim`, как было при registerType: "autoUpdate".
*/
declare const self: ServiceWorkerGlobalScope;
precacheAndRoute(self.__WB_MANIFEST);
cleanupOutdatedCaches();
self.skipWaiting();
clientsClaim();
interface PushPayload {
title?: string;
body?: string;
url?: string;
tag?: string;
/** Номер уведомления в журнале — по нему отмечается прочтение. */
id?: number;
}
self.addEventListener("push", (event: PushEvent) => {
let data: PushPayload = {};
try {
data = event.data ? (event.data.json() as PushPayload) : {};
} catch {
// Пуш без разбираемой нагрузки: показать что-то всё равно надо — молчание
// выглядит как «уведомления не работают».
data = {};
}
const title = data.title || "Премиум Водитель";
event.waitUntil(
self.registration.showNotification(title, {
body: data.body || "",
icon: "/icons/icon-192.png",
badge: "/icons/icon-192.png",
// tag схлопывает однотипные: пять штрафов подряд не завалят шторку.
tag: data.tag,
data: { url: data.url || "/", id: data.id },
}),
);
});
self.addEventListener("notificationclick", (event: NotificationEvent) => {
event.notification.close();
const url = (event.notification.data?.url as string) || "/";
const id = event.notification.data?.id as number | undefined;
// Нажатие — единственный честный признак, что уведомление увидели. Отметить
// его прямо отсюда нельзя: токен водителя лежит в хранилище страницы, а
// воркеру оно недоступно. Поэтому номер едет параметром, а отмечает страница.
const target = id ? `${url}${url.includes("?") ? "&" : "?"}n=${id}` : url;
event.waitUntil(
(async () => {
const all = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
// Уже открытое окно приложения переиспользуем: второй экземпляр рядом с
// первым сбивает с толку, и водитель теряет то, что было на экране.
for (const client of all) {
if ("focus" in client) {
await client.focus();
if ("navigate" in client) await client.navigate(target);
return;
}
}
await self.clients.openWindow(target);
})(),
);
});
+26 -3
View File
@@ -3,12 +3,35 @@ export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"], content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: { theme: {
extend: { extend: {
/**
* Палитра сайта pptaxi.ru — снята с ЖИВОГО сайта (переменные --s-* в его
* инлайн-CSS), а не из локального чекаута: тот отстал на редизайн и
* предлагал тёмно-синюю тему, которой на сайте давно нет.
*
* Имена — роли, а не краски: «canvas» останется фоном страницы, даже если
* однажды перестанет быть светлым.
*/
colors: { colors: {
cream: "#F3EEE3", surface: "#FBF8F1", line: "#E4DCCB", canvas: "#EEF1F5", // --s-bg — фон страницы
ink: "#1C1B19", muted: "#7C7361", neg: "#C0493A", pos: "#3B6E4A", "canvas-soft": "#F5F7FA", // --s-bg-soft — подложка под блоками
surface: "#FFFFFF", // --s-paper — карточки, поля ввода
line: "#DDE3EC", // --s-line — границы и разделители
"line-soft": "#E5EAF1",
ink: "#0F172A", // --s-ink — основной текст
"ink-soft": "#475569", // --s-ink-soft — вторичный текст
muted: "#94A3B8", // --s-ink-mute — подписи
accent: "#DC2626", // --s-accent — фирменный красный, кнопка действия
"accent-press": "#B91C1C",
"accent-soft": "#FEF2F2",
warn: "#D97706", // --s-warn
// Деньги: приход зелёным (--s-ok), долг — фирменным красным. Свой оттенок
// тут заводить не стали: у долга и главного действия один цвет намеренно,
// это то, ради чего экран открывают.
pos: "#059669",
neg: "#DC2626",
}, },
fontFamily: { sans: ["Inter", "system-ui", "sans-serif"] }, fontFamily: { sans: ["Inter", "system-ui", "sans-serif"] },
borderRadius: { xl2: "14px" }, borderRadius: { xl2: "12px" }, // --s-radius-md
}, },
}, },
plugins: [], plugins: [],
+2 -2
View File
@@ -1,11 +1,11 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2021", "useDefineForClassFields": true, "lib": ["ES2021", "DOM", "DOM.Iterable"], "target": "ES2021", "useDefineForClassFields": true, "lib": ["ES2021", "DOM", "DOM.Iterable", "WebWorker"],
"module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler", "module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true,
"noEmit": true, "jsx": "react-jsx", "strict": true, "noUnusedLocals": true, "noEmit": true, "jsx": "react-jsx", "strict": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true,
"baseUrl": ".", "paths": { "@/*": ["src/*"] }, "types": ["vitest/globals", "@testing-library/jest-dom"] "baseUrl": ".", "paths": { "@/*": ["src/*"] }, "types": ["vite-plugin-pwa/client", "vitest/globals", "@testing-library/jest-dom"]
}, },
"include": ["src"] "include": ["src"]
} }
+11 -1
View File
@@ -54,7 +54,17 @@ export default defineConfig(({ command, mode }) => {
}, },
plugins: [ plugins: [
react(), react(),
VitePWA({ registerType: "autoUpdate", manifest: false, includeAssets: ["icons/*.png"] }), // injectManifest, а не generateSW: воркер свой, потому что уведомления
// показывает он (см. src/sw.ts), а сгенерированный умеет только кэш.
VitePWA({
strategies: "injectManifest",
srcDir: "src",
filename: "sw.ts",
registerType: "autoUpdate",
manifest: false,
includeAssets: ["icons/*.png"],
injectManifest: { globPatterns: ["**/*.{js,css,html,png,svg,webmanifest}"] },
}),
], ],
resolve: { alias: { "@": path.resolve(__dirname, "src") } }, resolve: { alias: { "@": path.resolve(__dirname, "src") } },
server: { server: {