Compare commits

..
2 Commits
Author SHA1 Message Date
tremble7681andClaude Opus 5 ceec3db364 feat(pwa): вход для сотрудника, просмотр от лица водителя и вкладка «Отладка»
Приложение обкатывается на боевом контуре, поэтому интерфейс надо проверять на
живых данных — а живые данные лежат у настоящих водителей.

* «Вход для сотрудника» (/dev) — логин учётки CRM, поддержан второй шаг 2FA;
  дальше поиск водителя по ФИО/телефону/id и просмотр его экранов.
* Плашка «Смотрю как …» над всеми экранами: интерфейс в этом режиме неотличим
  от водительского, и чужой баланс легко принять за свой.
* Вкладка «Отладка» (/debug, водителю недоступна): смена водителя, driver_id,
  режим, срок токена, адрес API, коммит и время сборки, журнал последних
  40 ответивших запросов, сброс кэша сервис-воркера.
* Пополнение под просмотром идёт в учебный контур: экран /mock-pay/:orderId
  выбирает, что «ответит» банк, а настоящий экран оплаты разбирает эти статусы
  своим боевым кодом. Ни рубля, ни чека 54-ФЗ.

Заодно доезжает незакоммиченная работа прошлой сессии: дев-прокси в
vite.config.ts, его тест и разобранный .env.example.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 13:15:02 +10:00
tremble7681andClaude Opus 5 aff1a45006 feat(pwa): подтверждение входа, копейки, выбор категории долга
- pairing-сессия хранит poll_secret и confirm_code (lib/pairing), опрос ушёл
  на POST: pair_session уезжал в query-параметре и оседал в access-логах, а
  это половина ключа к JWT. Вторая половина в deep-link не уходит;
- на экране ожидания видно число, которое обязан назвать бот. По чужой ссылке
  человек его не увидит — подтверждать нечего, токен не выдаётся;
- онбординг больше не просит вернуться и запросить код: Telegram подтверждает
  номер шарингом контакта, приложение забирает токен опросом;
- суммы с копейками. formatMoney округлял до рубля, и «к оплате 350 ₽»
  расходилось и со списанием банка, и с чеком (комиссия почти всегда даёт
  копейки). Клавиша разделителя, нарисованная на клавиатуре, наконец работает;
- категорию долга выбирает водитель. Хардкод «Долг аренда» отправлял платёж
  в аренду даже тем, у кого её нет; список приходит с сервера и там же
  проверяется;
- ошибки бэкенда показываются словами (минимум СБП, потолок суммы) вместо
  молчаливого «не удалось», статусы истории переведены полностью, возврат
  денег отличим от отказа.

Тесты: 33 зелёных, tsc чист.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:54:04 +10:00
41 changed files with 1693 additions and 90 deletions
+26 -1
View File
@@ -1 +1,26 @@
VITE_API_BASE=https://crm.pptaxi.ru/api # Премиум Водитель (PWA) — переменные сборки.
# Как поднять фронт локально — README.md рядом с этим файлом.
# --- VITE_API_BASE: база API для ky (src/api/client.ts) ----------------------
#
# Боевой сборке задавать её НЕ обязательно: если переменной нет, client.ts
# подставляет https://crm.pptaxi.ru/api. Поэтому строка ниже закомментирована
# намеренно — скопированный в .env боевой адрес сборке ничего не добавляет,
# а локальную разработку ломает: значение из файла приоритетнее дев-дефолта,
# и `npm run dev` начнёт бить в боевой терминал вместо локального бэкенда.
#
#VITE_API_BASE=https://crm.pptaxi.ru/api
# --- Локальная разработка ----------------------------------------------------
#
# Копировать этот файл в .env не нужно. `npm run dev` сам ставит
# VITE_API_BASE=/api и проксирует /api на локальный бэкенд (vite.config.ts),
# то есть запрос идёт с того же origin, что и страница, и CORS не участвует —
# CORS_ALLOW_LOCALHOST в backend/.env для этого фронта не нужен.
#
# Свой адрес бэкенда, поход в API мимо прокси, PowerShell-вариант команд —
# в README.md. Рецепты держим в одном месте: разъедутся — поверят не тому.
#
# ВНИМАНИЕ: crm.pptaxi.ru — боевой терминал Т-Банка и боевая касса АТОЛ.
# Пополнение, сделанное «просто посмотреть», — это реальные деньги водителя,
# реальный чек 54-ФЗ и реальная проводка в леджере.
+147
View File
@@ -0,0 +1,147 @@
# Премиум Водитель — PWA водителя
React + Vite + TypeScript. Прод: **https://app.pptaxi.ru**.
Бэкенд — модуль `payments` в Premium CRM (`https://crm.pptaxi.ru/api`,
репозиторий `crm2`, `backend/app/modules/payments/`).
## Как поднять локально
```bash
npm install
npm run dev # http://localhost:5174
```
Отдельно поднимается бэкенд `crm2` на **127.0.0.1:8000** (тот же порт, что в
`backend/Dockerfile`). Больше настраивать нечего: dev-сервер сам подставляет
`VITE_API_BASE=/api` и проксирует `/api` на бэкенд — см. `vite.config.ts`.
**Почему через прокси, а не «разрешить localhost в CORS бэкенда».** Прокси
делает запрос ОДНОГО происхождения со страницей (`http://localhost:5174/api/…`),
поэтому CORS в разработке не участвует вообще. Боевой список origin'ов на
бэкенде остаётся закрытым, а `CORS_ALLOW_LOCALHOST=true` в `backend/.env` для
этого фронта включать не надо. (Раньше рецепт с `CORS_ALLOW_LOCALHOST` тут и не
работал: прокси не было, и фронт по умолчанию уходил на боевой домен, а не на
локальный бэкенд.)
Сервис-воркер в режиме разработки не регистрируется (`devOptions` у
`vite-plugin-pwa` по умолчанию выключены) — старый кэш прод-версии в дев не
лезет.
### Бэкенд не на `127.0.0.1:8000`
```bash
VITE_API_TARGET=http://127.0.0.1:8090 npm run dev
```
В PowerShell инлайн-префикса `VAR=... команда` нет — переменную ставят отдельно:
```powershell
$env:VITE_API_TARGET="http://127.0.0.1:8090"; npm run dev
```
Имя переменной то же, что в CRM (`crm2/frontend/vite.config.ts`), — рецепт один
на оба фронта.
### Ходить в API напрямую, мимо прокси
`.env.local` (в git не попадает):
```
VITE_API_BASE=http://127.0.0.1:8000/api
```
Своя настройка приоритетнее дев-дефолта — специально, чтобы `npm run dev` её не
перебивал. Для похода в чужой origin бэкенду понадобится
`CORS_ALLOW_LOCALHOST=true` в `backend/.env` и рестарт.
> ⚠️ `crm.pptaxi.ru` — боевой терминал Т-Банка и боевая касса АТОЛ. Пополнение,
> сделанное «просто посмотреть», — это реальные деньги водителя, реальный чек
> 54-ФЗ и реальная проводка в леджере. Поэтому прокси по умолчанию смотрит в
> локальный бэкенд, а не в прод.
## Проверки
```bash
npx tsc -b # типы
npm test # vitest run
```
`npx tsc --noEmit` **без `-b`** здесь всегда зелёный и ничего не значит:
корневой `tsconfig.json` — solution-файл (`files: []` + `references`), под него
не попадает ни один файл (`tsc --noEmit --listFiles` печатает пустоту). Типы
живут в `tsconfig.app.json` (`src`) и `tsconfig.node.json` (конфиги +
`dev-proxy.test.ts`), и до обоих добирается только `tsc -b`.
## Сборка
```bash
npm run build # tsc -b && vite build → dist/
```
Дев-дефолт `VITE_API_BASE=/api` подставляется только при запущенном dev-сервере
и в бандл не попадает: в собранном `assets/index-*.js` лежит
`https://crm.pptaxi.ru/api` (проверено сборкой). Поэтому `VITE_API_BASE` боевой
сборке задавать не обязательно — без неё `src/api/client.ts` подставляет ровно
этот адрес.
> ⚠️ `npm run preview` поднимает **уже собранный** бандл, а база в нём
> абсолютная: запросы уходят на боевой `crm.pptaxi.ru` мимо прокси dev-сервера.
> Смотреть локальные правки — только `npm run dev`.
Куда выкладывается `dist/` — в плане проекта:
`../../docs/superpowers/plans/2026-06-18-premium-driver-pwa.md`, раздел
«Deployment notes»: статика на `app.pptaxi.ru`, Caddy с фолбэком на `index.html`.
## Обкатка: кто может войти и как смотреть чужой экран
Приложение выложено на боевой домен, а обкатывается интерфейс. Поэтому на время
обкатки включены две вещи.
### 1. Вход только по списку номеров
Переменная бэкенда (`crm2/backend/.env`, читается в `app/config.py`):
```
DRIVER_APP_ALLOWED_PHONES=+7 (914) 123-45-67, 79990001122
```
Разделители — запятая, точка с запятой, перенос строки (пробел разделителем не
считается: номер вписывают в привычном виде). Сверяются последние 10 цифр.
**Пусто — гейт выключен, вход открыт всем водителям.**
Проверка стоит в единственной воронке выдачи токена
(`modules/payments/driver_auth.create_driver_token_for`), поэтому закрыты сразу
все пять путей входа: код в бот, кнопка «Войти через Telegram», колбэки своего
бота и два аварийных отката на n8n. Бот отвечает человеку текстом, остальные
пути — 403. Логика гейта: `modules/payments/login_gate.py`.
### 2. Просмотр от лица водителя (сотрудник)
На экране входа внизу — «Вход для сотрудника» (`/dev`): логин и пароль **учётки
CRM**, при включённой двухфакторке спросит код. Дальше поиск по ФИО, телефону
или id — и приложение открывается от лица выбранного водителя.
* Токен помечен claim'ом `imp`, живёт 8 часов (обычный водительский — 30 дней).
* Гейт — `is_admin` в CRM. Каждая выдача пишется в аудит (`driver_app.impersonate`).
* Кнопка «Отозвать доступ» в CRM гасит и просмотр сотрудника — поколение общее.
* Гейт обкатки на этот путь не распространяется: смотреть надо как раз на
водителей, которых в allowlist нет.
Пока просмотр действует, сверху на всех экранах висит плашка «Смотрю как …» со
ссылкой на вкладку **«Отладка»** (`/debug`, водителю недоступна):
* от чьего лица открыт экран и кнопка сменить водителя;
* `driver_id`, режим, сотрудник, когда истекает токен, адрес API, коммит и время
сборки, состояние сети;
* журнал последних 40 ответивших запросов (метод, путь, статус, миллисекунды);
* сброс кэша сервис-воркера с перезагрузкой — лечит «правка не приехала»;
* выходы: из просмотра, из учётки сотрудника.
### Деньги под просмотром: учебный платёж
Пополнение под `imp`-токеном **не идёт в банк**. Проверки суммы, категории и
расчёт комиссии настоящие, а вместо Init на терминале заводится учебный заказ в
Redis (`modules/payments/mock_pay.py`, префикс `mock-`, час жизни). Экран
`/mock-pay/:orderId` даёт выбрать, что «ответит» банк — оплачено, отказ,
истёкший счёт, возврат, — и настоящий экран оплаты разбирает эти статусы своим
боевым кодом. В `driver_payments` не попадает ни строки, чек 54-ФЗ не пробивается.
+103
View File
@@ -0,0 +1,103 @@
// @vitest-environment node
/**
* Локальная разработка PWA водителя.
*
* Гейт CORS на бэкенде (CORS_ALLOW_LOCALHOST, по умолчанию выключен) закрыл
* дыру, но заодно оставил этот фронт без рабочего дева: прокси не было, а
* client.ts по умолчанию уходит на боевой https://crm.pptaxi.ru/api — то есть
* «включи CORS_ALLOW_LOCALHOST» тут не помогало, запрос вообще не доходил до
* локального бэкенда.
*
* Тесты держат обе половины договорённости:
* 1) `npm run dev` доводит фронт до локального бэкенда сам, без правки .env;
* 2) боевая сборка от этого не меняется — относительная дев-база в бандл
* попасть не может (в проде он живёт на app.pptaxi.ru, где /api нет).
*
* Конфиг резолвится по-настоящему (resolveConfig читает vite.config.ts и
* env-файлы) — иначе тест проверял бы не то, что увидит vite.
*
* Файл лежит рядом с vite.config.ts и НЕ называется vite.config.test.ts:
* в дефолтном exclude vitest есть `**\/{...,vite,...}.config.*`, под который
* такое имя попадает целиком — тест молча не запускался бы.
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import path from "path";
import { fileURLToPath } from "url";
import { resolveConfig } from "vite";
const ROOT = path.dirname(fileURLToPath(import.meta.url));
/** Обе переменные — process.env, и dev-дефолт их же и трогает: сохраняем. */
const SAVED = {
base: process.env.VITE_API_BASE,
target: process.env.VITE_API_TARGET,
};
function restore(key: "VITE_API_BASE" | "VITE_API_TARGET", value: string | undefined): void {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
beforeEach(() => {
delete process.env.VITE_API_BASE;
delete process.env.VITE_API_TARGET;
});
afterEach(() => {
restore("VITE_API_BASE", SAVED.base);
restore("VITE_API_TARGET", SAVED.target);
});
const resolve = (command: "serve" | "build", mode: "development" | "production") =>
resolveConfig({ root: ROOT, configFile: path.join(ROOT, "vite.config.ts"), logLevel: "silent" },
command, mode, mode);
describe("dev-сервер доводит фронт до локального бэкенда", () => {
it("подставляет относительную базу — запрос идёт со своего origin, CORS не участвует", async () => {
const cfg = await resolve("serve", "development");
expect(cfg.env.VITE_API_BASE).toBe("/api");
});
it("проксирует /api на локальный бэкенд, а не на боевой домен", async () => {
const cfg = await resolve("serve", "development");
const proxy = cfg.server.proxy?.["/api"];
expect(typeof proxy === "object" && proxy.target).toBe("http://127.0.0.1:8000");
});
it("цель прокси переопределяется VITE_API_TARGET (стенд, тайлнет)", async () => {
process.env.VITE_API_TARGET = "http://127.0.0.1:8090";
const cfg = await resolve("serve", "development");
const proxy = cfg.server.proxy?.["/api"];
expect(typeof proxy === "object" && proxy.target).toBe("http://127.0.0.1:8090");
});
it("база и путь прокси — одно и то же", async () => {
// Две половины рецепта живут в разных местах конфига: базу подставляем в
// env, путь перечисляем в server.proxy. Разъедутся (например, база станет
// "/backend") — dev-сервер отдаст на этот путь index.html вместо ответа
// бэкенда, и падать будет уже в разборе JSON, далеко от причины.
const cfg = await resolve("serve", "development");
expect(Object.keys(cfg.server.proxy ?? {})).toContain(cfg.env.VITE_API_BASE);
});
it("не перебивает базу, заданную разработчиком", async () => {
process.env.VITE_API_BASE = "https://stand.example/api";
const cfg = await resolve("serve", "development");
expect(cfg.env.VITE_API_BASE).toBe("https://stand.example/api");
});
});
describe("боевая сборка остаётся боевой", () => {
it("дев-дефолт в сборку не течёт: base пустая → client.ts берёт прод-адрес", async () => {
const cfg = await resolve("build", "production");
expect(cfg.env.VITE_API_BASE).toBeUndefined();
expect(process.env.VITE_API_BASE).toBeUndefined();
});
it("ни один env-файл не подсовывает сборке относительную базу", async () => {
// Относительный /api в проде означал бы app.pptaxi.ru/api — такого нет.
// Ловит и коммит .env с дев-значением, и локальный .env на машине сборки.
const cfg = await resolve("build", "production");
expect(cfg.env.VITE_API_BASE).not.toBe("/api");
});
});
+20
View File
@@ -1,6 +1,8 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes } from "react-router-dom";
import { useAuth } from "@/store/auth"; import { useAuth } from "@/store/auth";
import { useStaff } from "@/store/staff";
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";
import { OnboardingPage } from "@/pages/OnboardingPage"; import { OnboardingPage } from "@/pages/OnboardingPage";
@@ -8,23 +10,41 @@ 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 { HistoryPage } from "@/pages/HistoryPage";
import { DevLoginPage } from "@/pages/DevLoginPage";
import { DebugPage } from "@/pages/DebugPage";
import { MockPayPage } from "@/pages/MockPayPage";
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 />; return token ? <>{children}</> : <Navigate to="/login" replace />;
} }
/** Отладка существует только для сотрудника: либо он вошёл учёткой CRM, либо
* прямо сейчас смотрит от лица водителя. Обычному водителю адрес /debug ничего
* не даёт — его уводит на главную. */
function RequireStaff({ children }: { children: ReactNode }) {
const impersonated = useAuth((s) => s.impersonated);
const staffToken = useStaff((s) => s.token);
return impersonated || staffToken ? <>{children}</> : <Navigate to="/" replace />;
}
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 />
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/dev" element={<DevLoginPage />} />
<Route path="/tg-wait" element={<TgWaitPage />} /> <Route path="/tg-wait" element={<TgWaitPage />} />
<Route path="/onboarding" element={<OnboardingPage />} /> <Route path="/onboarding" element={<OnboardingPage />} />
<Route path="/" element={<RequireAuth><BalancePage /></RequireAuth>} /> <Route path="/" element={<RequireAuth><BalancePage /></RequireAuth>} />
<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="/history" element={<RequireAuth><HistoryPage /></RequireAuth>} /> <Route path="/history" element={<RequireAuth><HistoryPage /></RequireAuth>} />
<Route path="/debug" element={<RequireAuth><RequireStaff><DebugPage /></RequireStaff></RequireAuth>} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</div> </div>
+19 -1
View File
@@ -1,7 +1,11 @@
import ky, { type KyInstance } from "ky"; import ky, { type KyInstance } from "ky";
import { pushEntry, shortPath } from "@/lib/reqlog";
import { useAuth } from "@/store/auth"; import { useAuth } from "@/store/auth";
const API_BASE = import.meta.env.VITE_API_BASE ?? "https://crm.pptaxi.ru/api"; export const API_BASE = import.meta.env.VITE_API_BASE ?? "https://crm.pptaxi.ru/api";
/** Засечка времени на конкретный запрос. WeakMap — чтобы не держать Request'ы. */
const started = new WeakMap<Request, number>();
export const api: KyInstance = ky.create({ export const api: KyInstance = ky.create({
prefixUrl: API_BASE, prefixUrl: API_BASE,
@@ -10,10 +14,24 @@ export const api: KyInstance = ky.create({
hooks: { hooks: {
beforeRequest: [ beforeRequest: [
(request) => { (request) => {
started.set(request, performance.now());
const token = useAuth.getState().token; const token = useAuth.getState().token;
if (token) request.headers.set("Authorization", `Bearer ${token}`); if (token) request.headers.set("Authorization", `Bearer ${token}`);
}, },
], ],
// В журнал попадают ОТВЕТИВШИЕ запросы, включая 4xx/5xx. Запрос, оборвавшийся
// без ответа (таймаут, пропавшая сеть), ky сюда не приносит — на экране
// отладки об этом сказано прямо, чтобы пустая строка не читалась как «дошло».
afterResponse: [
(request, _options, response) => {
pushEntry({
method: request.method,
path: shortPath(request.url),
status: response.status,
ms: Math.round(performance.now() - (started.get(request) ?? performance.now())),
});
},
],
beforeError: [ beforeError: [
(error) => { (error) => {
if (error.response?.status === 401) useAuth.getState().clear(); if (error.response?.status === 401) useAuth.getState().clear();
+8 -3
View File
@@ -12,10 +12,11 @@ describe("driver api schemas", () => {
}); });
it("parses auth request onboarding + code_sent", () => { it("parses auth request onboarding + code_sent", () => {
expect(AuthRequestSchema.parse({ status: "code_sent", channel: "tg" }).status).toBe("code_sent"); expect(AuthRequestSchema.parse({ status: "code_sent", channel: "tg" }).status).toBe("code_sent");
const o = AuthRequestSchema.parse({ status: "onboarding", deep_links: { tg: "t", max: "m" } }); const pair = { pair_session: "S", poll_secret: "SEC", confirm_code: "4217" };
const o = AuthRequestSchema.parse({ status: "onboarding", deep_links: { tg: "t", max: "m" }, ...pair });
expect(o.status === "onboarding" && o.deep_links.tg).toBe("t"); expect(o.status === "onboarding" && o.deep_links.tg).toBe("t");
// prod: MAX bot not configured → backend omits `max`, must still parse (regression guard) // prod: MAX bot not configured → backend omits `max`, must still parse (regression guard)
const tgOnly = AuthRequestSchema.parse({ status: "onboarding", deep_links: { tg: "t" } }); const tgOnly = AuthRequestSchema.parse({ status: "onboarding", deep_links: { tg: "t" }, ...pair });
expect(tgOnly.status === "onboarding" && tgOnly.deep_links.tg).toBe("t"); expect(tgOnly.status === "onboarding" && tgOnly.deep_links.tg).toBe("t");
}); });
it("parses payments history", () => { it("parses payments history", () => {
@@ -27,8 +28,12 @@ describe("driver api schemas", () => {
describe("tg verified-login schemas", () => { describe("tg verified-login schemas", () => {
it("parses tg session", () => { it("parses tg session", () => {
const v = TgSessionSchema.parse({ pair_session: "S", deep_link: "https://t.me/x?start=S" }); const v = TgSessionSchema.parse({ pair_session: "S", poll_secret: "SEC", confirm_code: "4217",
deep_link: "https://t.me/x?start=S" });
expect(v.pair_session).toBe("S"); expect(v.pair_session).toBe("S");
expect(v.poll_secret).toBe("SEC");
// вторая половина ключа в deep-link не уходит
expect(v.deep_link).not.toContain("SEC");
}); });
it("parses tg poll states", () => { it("parses tg poll states", () => {
expect(TgPollSchema.parse({ status: "pending" }).status).toBe("pending"); expect(TgPollSchema.parse({ status: "pending" }).status).toBe("pending");
+63 -7
View File
@@ -1,18 +1,43 @@
import { HTTPError } from "ky";
import { z } from "zod"; import { z } from "zod";
import { api } from "./client"; import { api } from "./client";
/** Pairing-сессия входа. `poll_secret` не покидает приложение (в deep-link уходит
* только `pair_session`), `confirm_code` человек сверяет с сообщением бота. */
export const TgSessionSchema = z.object({
pair_session: z.string(),
poll_secret: z.string(),
confirm_code: z.string(),
deep_link: z.string().nullable(),
});
export const AuthRequestSchema = z.union([ export const AuthRequestSchema = z.union([
z.object({ status: z.literal("code_sent"), channel: z.string() }), z.object({ status: z.literal("code_sent"), channel: z.string() }),
z.object({ status: z.literal("onboarding"), deep_links: z.object({ tg: z.string().optional(), max: z.string().optional() }) }), z.object({
status: z.literal("onboarding"),
deep_links: z.object({ tg: z.string().optional(), max: z.string().optional() }),
pair_session: z.string(),
poll_secret: z.string(),
confirm_code: z.string(),
}),
]); ]);
export type AuthRequest = z.infer<typeof AuthRequestSchema>; 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С. В сумму баланса не входит.
pending_topup: z.number().default(0),
});
export const BucketsSchema = z.object({
buckets: z.array(z.string()),
with_debt: z.array(z.string()),
}); });
export const TopupSchema = z.object({ export const TopupSchema = z.object({
order_id: z.string(), pay_url: z.string(), commission: z.number(), total: z.number(), order_id: z.string(), pay_url: z.string(), commission: z.number(), total: z.number(),
// Учебный платёж: сотрудник смотрит от лица водителя, банка в этой ветке нет
// (см. backend modules/payments/mock_pay.py). У обычного водителя всегда false.
mock: z.boolean().default(false),
}); });
export const PaymentStateSchema = z.object({ order_id: z.string(), status: z.string() }); export const PaymentStateSchema = z.object({ order_id: z.string(), status: z.string() });
export const CommissionSchema = z.object({ rate: z.number() }); export const CommissionSchema = z.object({ rate: z.number() });
@@ -21,23 +46,45 @@ export const PaymentsSchema = z.object({
order_id: z.string(), bucket: z.string(), amount: z.number(), commission: z.number(), order_id: z.string(), bucket: z.string(), amount: z.number(), commission: z.number(),
method: z.string(), status: z.string(), method: z.string(), status: z.string(),
created_at: z.string().nullable(), paid_at: z.string().nullable(), created_at: z.string().nullable(), paid_at: z.string().nullable(),
reversed_at: z.string().nullable().default(null),
})), })),
}); });
export const TgSessionSchema = z.object({
pair_session: z.string(),
deep_link: z.string().nullable(),
});
export const TgPollSchema = z.union([ export const TgPollSchema = z.union([
z.object({ status: z.literal("pending") }), z.object({ status: z.literal("pending") }),
z.object({ status: z.literal("expired") }), z.object({ status: z.literal("expired") }),
z.object({ status: z.literal("authenticated"), token: z.string(), driver_id: z.number() }), z.object({ status: z.literal("authenticated"), token: z.string(), driver_id: z.number() }),
]); ]);
/**
* Текст ошибки от бэкенда.
*
* Раньше все отказы схлопывались в «Не удалось создать платёж», и осмысленные
* сообщения — про минимум СБП, про потолок суммы, про неизвестную категорию —
* до водителя не доходили: экран просто не реагировал.
*/
export async function apiErrorText(err: unknown, fallback: string): Promise<string> {
if (err instanceof HTTPError) {
try {
const body = await err.response.json();
const detail = (body as { detail?: unknown })?.detail;
if (typeof detail === "string" && detail.trim()) return detail;
} catch {
/* тело не JSON — отдадим запасной текст */
}
}
return fallback;
}
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());
export const tgPoll = async (pairSession: string) => /** POST, а не GET: в query-параметре pair_session оседал в access-логах. */
TgPollSchema.parse(await api.get("driver/auth/tg/poll", { searchParams: { pair_session: pairSession } }).json()); export const tgPoll = async (pairSession: string, pollSecret: string) =>
TgPollSchema.parse(
await api.post("driver/auth/tg/poll", {
json: { pair_session: pairSession, poll_secret: pollSecret },
}).json(),
);
export const authRequest = async (phone: string) => export const authRequest = async (phone: string) =>
AuthRequestSchema.parse(await api.post("driver/auth/request", { json: { phone } }).json()); AuthRequestSchema.parse(await api.post("driver/auth/request", { json: { phone } }).json());
@@ -45,6 +92,8 @@ export const authVerify = async (phone: string, code: string) =>
VerifySchema.parse(await api.post("driver/auth/verify", { json: { phone, code } }).json()); VerifySchema.parse(await api.post("driver/auth/verify", { json: { phone, code } }).json());
export const getBalance = async () => export const getBalance = async () =>
BalanceSchema.parse(await api.get("driver/balance").json()); BalanceSchema.parse(await api.get("driver/balance").json());
export const getBuckets = async () =>
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()); TopupSchema.parse(await api.post("driver/topup", { json: { bucket, amount, method } }).json());
export const getPaymentState = async (orderId: string) => export const getPaymentState = async (orderId: string) =>
@@ -53,3 +102,10 @@ export const getPayments = async () =>
PaymentsSchema.parse(await api.get("driver/payments").json()); PaymentsSchema.parse(await api.get("driver/payments").json());
export const getCommission = async () => export const getCommission = async () =>
CommissionSchema.parse(await api.get("driver/commission").json()); CommissionSchema.parse(await api.get("driver/commission").json());
/** Исход учебного платежа. Работает только под просмотром сотрудника. */
export const mockPayOutcome = async (
orderId: string, outcome: "paid" | "rejected" | "expired" | "reversed",
) => PaymentStateSchema.parse(
await api.post(`driver/mock-pay/${orderId}`, { json: { outcome } }).json(),
);
+85
View File
@@ -0,0 +1,85 @@
import ky from "ky";
import { z } from "zod";
import { pushEntry, shortPath } from "@/lib/reqlog";
import { API_BASE } from "./client";
import { useStaff } from "@/store/staff";
/**
* Учётка CRM внутри приложения водителя — только ради просмотра от чужого лица.
*
* Свой экземпляр ky, а не общий `api`: тот подставляет ВОДИТЕЛЬСКИЙ токен, и на
* запросе к /driver/staff/* заголовок оказался бы чужим. Плюс 401 здесь означает
* «протух сеанс сотрудника», а не «водителя выкинуло».
*/
const started = new WeakMap<Request, number>();
const staffApi = ky.create({
prefixUrl: API_BASE,
timeout: 30_000,
hooks: {
beforeRequest: [
(request) => {
started.set(request, performance.now());
const token = useStaff.getState().token;
if (token) request.headers.set("Authorization", `Bearer ${token}`);
},
],
afterResponse: [
(request, _o, response) => {
pushEntry({
method: request.method, path: shortPath(request.url), status: response.status,
ms: Math.round(performance.now() - (started.get(request) ?? performance.now())),
});
},
],
beforeError: [
(error) => {
if (error.response?.status === 401) useStaff.getState().clear();
return error;
},
],
},
});
export const LoginSchema = z.object({
access_token: z.string().nullable().default(null),
requires_2fa: z.boolean().default(false),
partial_token: z.string().nullable().default(null),
});
export const DriverBriefSchema = z.object({
id: z.number(),
fio: z.string(),
phone: z.string(),
status: z.string(),
driver_kind: z.string(),
});
export type DriverBrief = z.infer<typeof DriverBriefSchema>;
export const DriversSchema = z.object({ drivers: z.array(DriverBriefSchema) });
export const ImpersonateSchema = z.object({
token: z.string(),
driver: DriverBriefSchema,
});
/** Логин CRM. Форма, а не JSON: эндпоинт принимает OAuth2PasswordRequestForm. */
export const staffLogin = async (username: string, password: string) => {
const body = new URLSearchParams({ username, password });
return LoginSchema.parse(await staffApi.post("auth/login", { body }).json());
};
/** Второй шаг, если у учётки включена двухфакторка. */
export const staffLogin2fa = async (partialToken: string, code: string) =>
LoginSchema.parse(await staffApi.post("auth/2fa", {
json: { partial_token: partialToken, code, remember_device: false },
}).json());
export const searchDrivers = async (q: string) =>
DriversSchema.parse(await staffApi.get("driver/staff/drivers", {
searchParams: { q, limit: 30 },
}).json());
export const impersonate = async (driverId: number) =>
ImpersonateSchema.parse(await staffApi.post("driver/staff/impersonate", {
json: { driver_id: driverId },
}).json());
@@ -0,0 +1,86 @@
import { useEffect, useState } from "react";
import { Search } from "lucide-react";
import { toast } from "sonner";
import { apiErrorText } from "@/api/driver";
import { impersonate, searchDrivers, type DriverBrief } from "@/api/staff";
import { useAuth } from "@/store/auth";
import { Spinner } from "@/components/Spinner";
/**
* Выбор водителя, от лица которого смотреть. Один компонент на два места:
* первый вход сотрудника (/dev) и смена водителя на вкладке «Отладка».
*/
export function DriverPicker({ onPicked }: { onPicked: (d: DriverBrief) => void }) {
const setSession = useAuth((s) => s.setSession);
const [q, setQ] = useState("");
const [list, setList] = useState<DriverBrief[]>([]);
const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState<number | null>(null);
useEffect(() => {
let alive = true;
setLoading(true);
// Пауза перед запросом: иначе поиск уходит на каждую букву, и ответы
// возвращаются вперемешку — список прыгает.
const t = setTimeout(() => {
searchDrivers(q)
.then((r) => { if (alive) setList(r.drivers); })
.catch(async (e) => { if (alive) toast.error(await apiErrorText(e, "Не удалось найти водителей")); })
.finally(() => { if (alive) setLoading(false); });
}, 300);
return () => { alive = false; clearTimeout(t); };
}, [q]);
async function pick(d: DriverBrief) {
setBusyId(d.id);
try {
const r = await impersonate(d.id);
setSession(r.token, r.driver.id, { impersonated: true, driverName: r.driver.fio });
onPicked(r.driver);
} catch (e: unknown) {
toast.error(await apiErrorText(e, "Не удалось открыть приложение за водителя"));
} finally {
setBusyId(null);
}
}
return (
<div>
<div className="card flex items-center gap-2 px-3 py-2.5 mb-3 bg-white">
<Search size={16} className="text-muted shrink-0" />
<input
autoFocus
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="ФИО, телефон или id"
className="w-full text-base bg-transparent outline-none"
/>
</div>
{loading ? (
<div className="flex justify-center py-8"><Spinner /></div>
) : list.length === 0 ? (
<p className="text-muted text-sm text-center py-8">Никого не нашли</p>
) : (
<div className="card divide-y divide-line">
{list.map((d) => (
<button
key={d.id}
disabled={busyId !== null}
onClick={() => pick(d)}
className="w-full text-left px-4 py-3 flex items-center justify-between gap-3 disabled:opacity-50"
>
<span className="min-w-0">
<span className="block text-sm font-semibold truncate">{d.fio}</span>
<span className="block text-muted text-xs truncate">
#{d.id}{d.phone ? ` · ${d.phone}` : ""}{d.status ? ` · ${d.status}` : ""}
</span>
</span>
{busyId === d.id ? <Spinner /> : <span className="text-muted text-xs shrink-0">смотреть</span>}
</button>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,34 @@
import { useLocation, useNavigate } from "react-router-dom";
import { Eye } from "lucide-react";
import { useAuth } from "@/store/auth";
/**
* Плашка «вы смотрите чужой экран». Видна на каждом экране и всегда, пока
* действует токен просмотра.
*
* Она не украшение: интерфейс в этом режиме неотличим от водительского — те же
* суммы, те же долги, та же история. Без постоянного напоминания легко принять
* чужой баланс за свой и, например, позвонить водителю с чужими цифрами.
*/
export function ImpersonationBar() {
const nav = useNavigate();
const loc = useLocation();
const { impersonated, driverName, driverId } = useAuth();
if (!impersonated) return null;
const onDebug = loc.pathname === "/debug";
return (
<div className="flex items-center gap-2 mb-3 px-3 py-2 rounded-xl2 bg-ink text-cream text-xs">
<Eye size={14} className="shrink-0" />
<span className="min-w-0 truncate">
Смотрю как <b>{driverName ?? `водитель #${driverId}`}</b>
</span>
<button
className="ml-auto shrink-0 underline underline-offset-2"
onClick={() => nav(onDebug ? "/" : "/debug")}
>
{onDebug ? "к приложению" : "отладка"}
</button>
</div>
);
}
@@ -10,20 +10,34 @@ beforeEach(() => { useAuth.getState().clear(); vi.restoreAllMocks(); });
describe("usePairPoll", () => { describe("usePairPoll", () => {
it("sets session on authenticated", async () => { it("sets session on authenticated", async () => {
vi.spyOn(driver, "tgPoll").mockResolvedValue({ status: "authenticated", token: "T", driver_id: 7 } as any); vi.spyOn(driver, "tgPoll").mockResolvedValue({ status: "authenticated", token: "T", driver_id: 7 } as any);
const { result } = renderHook(() => usePairPoll("SESS")); const { result } = renderHook(() => usePairPoll("SESS", "SEC"));
await waitFor(() => expect(result.current.status).toBe("authenticated")); await waitFor(() => expect(result.current.status).toBe("authenticated"));
expect(useAuth.getState().token).toBe("T"); expect(useAuth.getState().token).toBe("T");
expect(useAuth.getState().driverId).toBe(7); expect(useAuth.getState().driverId).toBe(7);
}); });
it("stays pending then expired", async () => { it("stays pending then expired", async () => {
vi.spyOn(driver, "tgPoll").mockResolvedValue({ status: "expired" } as any); vi.spyOn(driver, "tgPoll").mockResolvedValue({ status: "expired" } as any);
const { result } = renderHook(() => usePairPoll("SESS")); const { result } = renderHook(() => usePairPoll("SESS", "SEC"));
await waitFor(() => expect(result.current.status).toBe("expired")); await waitFor(() => expect(result.current.status).toBe("expired"));
}); });
it("does nothing when session is null", () => { it("does nothing when session is null", () => {
const spy = vi.spyOn(driver, "tgPoll"); const spy = vi.spyOn(driver, "tgPoll");
const { result } = renderHook(() => usePairPoll(null)); const { result } = renderHook(() => usePairPoll(null, "SEC"));
expect(result.current.status).toBe("pending"); expect(result.current.status).toBe("pending");
expect(spy).not.toHaveBeenCalled(); expect(spy).not.toHaveBeenCalled();
}); });
it("does not poll without the secret half of the key", () => {
// pair_session уходит боту в deep-link и оседает в логах; одной его половины
// для получения токена быть недостаточно.
const spy = vi.spyOn(driver, "tgPoll");
const { result } = renderHook(() => usePairPoll("SESS", null));
expect(result.current.status).toBe("pending");
expect(spy).not.toHaveBeenCalled();
});
it("sends both halves to the backend", async () => {
const spy = vi.spyOn(driver, "tgPoll").mockResolvedValue({ status: "expired" } as any);
const { result } = renderHook(() => usePairPoll("SESS", "SEC"));
await waitFor(() => expect(result.current.status).toBe("expired"));
expect(spy).toHaveBeenCalledWith("SESS", "SEC");
});
}); });
+10 -4
View File
@@ -7,13 +7,19 @@ type Status = "pending" | "authenticated" | "expired";
const INTERVAL_MS = 2000; const INTERVAL_MS = 2000;
const TIMEOUT_MS = 5 * 60 * 1000; const TIMEOUT_MS = 5 * 60 * 1000;
export function usePairPoll(pairSession: string | null): { status: Status } { /**
* Опрос pairing-сессии. Нужны ОБЕ половины ключа: `pairSession` уходит боту в
* deep-link, `pollSecret` остаётся только здесь — знать одну сессию (например,
* подсмотрев её в access-логе) для получения токена уже недостаточно.
*/
export function usePairPoll(pairSession: string | null,
pollSecret: string | null): { status: Status } {
const [status, setStatus] = useState<Status>("pending"); const [status, setStatus] = useState<Status>("pending");
const setSession = useAuth((s) => s.setSession); const setSession = useAuth((s) => s.setSession);
const startedAt = useRef<number>(0); const startedAt = useRef<number>(0);
useEffect(() => { useEffect(() => {
if (!pairSession) return; if (!pairSession || !pollSecret) return;
let alive = true; let alive = true;
let timer: ReturnType<typeof setTimeout>; let timer: ReturnType<typeof setTimeout>;
startedAt.current = Date.now(); startedAt.current = Date.now();
@@ -21,7 +27,7 @@ export function usePairPoll(pairSession: string | null): { status: Status } {
const tick = async () => { const tick = async () => {
if (!alive) return; if (!alive) return;
try { try {
const r = await tgPoll(pairSession); const r = await tgPoll(pairSession, pollSecret);
if (!alive) return; if (!alive) return;
if (r.status === "authenticated") { if (r.status === "authenticated") {
setSession(r.token, r.driver_id); setSession(r.token, r.driver_id);
@@ -37,7 +43,7 @@ export function usePairPoll(pairSession: string | null): { status: Status } {
}; };
tick(); tick();
return () => { alive = false; clearTimeout(timer); }; return () => { alive = false; clearTimeout(timer); };
}, [pairSession, setSession]); }, [pairSession, pollSecret, setSession]);
return { status }; return { status };
} }
+15 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { keypadReduce, withCommission } from "./amount"; import { keypadReduce, parseAmount, withCommission } from "./amount";
describe("amount keypad", () => { describe("amount keypad", () => {
it("appends digits, ignores leading zero, caps length", () => { it("appends digits, ignores leading zero, caps length", () => {
@@ -16,4 +16,18 @@ describe("amount keypad", () => {
expect(withCommission(1000, 0.052)).toEqual({ commission: 52, total: 1052 }); expect(withCommission(1000, 0.052)).toEqual({ commission: 52, total: 1052 });
expect(withCommission(333, 0.052)).toEqual({ commission: 17.32, total: 350.32 }); expect(withCommission(333, 0.052)).toEqual({ commission: 17.32, total: 350.32 });
}); });
it("separator key builds kopecks — раньше клавиша была нарисована, но не работала", () => {
expect(keypadReduce("100", ",")).toBe("100,");
expect(keypadReduce("100,", "5")).toBe("100,5");
expect(keypadReduce("100,5", "2")).toBe("100,52");
expect(keypadReduce("100,52", "9")).toBe("100,52"); // копеек ровно две
expect(keypadReduce("100,52", ",")).toBe("100,52"); // второй разделитель не нужен
expect(keypadReduce("", ",")).toBe("0,");
expect(keypadReduce("100,5", "back")).toBe("100,");
});
it("parseAmount reads the keypad string", () => {
expect(parseAmount("100,52")).toBe(100.52);
expect(parseAmount("100,")).toBe(100);
expect(parseAmount("")).toBe(0);
});
}); });
+25 -1
View File
@@ -1,14 +1,38 @@
const MAX_DIGITS = 6; const MAX_DIGITS = 6; // целая часть
const SEP = ","; // русский разделитель — как на клавише
/**
* Одно нажатие клавиатуры суммы.
*
* Разделитель раньше был нарисован на клавише, но ничего не делал: `press`
* молча игнорировал его, а `keypadReduce` принимал только цифры. Заплатить
* точный долг вроде 1234,56 было нечем.
*/
export function keypadReduce(current: string, key: string): string { export function keypadReduce(current: string, key: string): string {
if (key === "back") return current.slice(0, -1); if (key === "back") return current.slice(0, -1);
if (key === SEP || key === "." || key === "·") {
if (current.includes(SEP)) return current; // второй разделитель не нужен
return current === "" ? "0" + SEP : current + SEP;
}
if (!/^\d$/.test(key)) return current; if (!/^\d$/.test(key)) return current;
const [whole, frac] = current.split(SEP);
if (frac !== undefined) {
if (frac.length >= 2) return current; // копеек ровно две
return `${whole}${SEP}${frac}${key}`;
}
if (current === "0") return key === "0" ? "0" : key; // replace leading zero if (current === "0") return key === "0" ? "0" : key; // replace leading zero
if (current === "" && key === "0") return ""; // no leading zero from empty if (current === "" && key === "0") return ""; // no leading zero from empty
if (current.length >= MAX_DIGITS) return current; if (current.length >= MAX_DIGITS) return current;
return current + key; return current + key;
} }
/** Строка с клавиатуры → число рублей. Незавершённое «100,» читается как 100. */
export function parseAmount(raw: string): number {
if (!raw) return 0;
const n = parseFloat(raw.replace(SEP, "."));
return Number.isFinite(n) ? Math.round(n * 100) / 100 : 0;
}
export function withCommission(base: number, rate: number): { commission: number; total: number } { export function withCommission(base: number, rate: number): { commission: number; total: number } {
const commission = Math.round(base * rate * 100) / 100; const commission = Math.round(base * rate * 100) / 100;
const total = Math.round((base + commission) * 100) / 100; const total = Math.round((base + commission) * 100) / 100;
@@ -7,6 +7,12 @@ describe("format", () => {
expect(formatMoney(-3200)).toBe("3 200 ₽"); expect(formatMoney(-3200)).toBe("3 200 ₽");
expect(formatMoney(0)).toBe("0 ₽"); expect(formatMoney(0)).toBe("0 ₽");
}); });
it("formatMoney keeps kopecks — раньше Math.round() показывал не ту сумму, что спишет банк", () => {
expect(formatMoney(350.32)).toBe("350,32 ₽");
expect(formatMoney(1052)).toBe("1 052 ₽");
expect(formatMoney(10.5)).toBe("10,50 ₽");
expect(formatMoney(-350.32)).toBe("350,32 ₽");
});
it("digitsOnly strips non-digits", () => { it("digitsOnly strips non-digits", () => {
expect(digitsOnly("+7 (914) 305-44-00")).toBe("79143054400"); expect(digitsOnly("+7 (914) 305-44-00")).toBe("79143054400");
}); });
+14 -3
View File
@@ -2,11 +2,22 @@ export function digitsOnly(s: string): string {
return (s || "").replace(/\D/g, ""); return (s || "").replace(/\D/g, "");
} }
/**
* Деньги для показа. Копейки печатаем ТОЛЬКО когда они есть.
*
* Раньше здесь было Math.round(), и водителю показывали одну сумму, а банк
* списывал другую: комиссия 5.2% почти всегда даёт копейки (1000 → 1052,00, но
* 333 → 350,32), и «к оплате 350 ₽» расходилось и со списанием, и с чеком.
*/
export function formatMoney(rub: number): string { export function formatMoney(rub: number): string {
const neg = rub < 0; const neg = rub < 0;
const abs = Math.abs(Math.round(rub)); const abs = Math.abs(rub);
const grouped = abs.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " "); const kop = Math.round(abs * 100);
return `${neg ? "" : ""}${grouped}`; const whole = Math.floor(kop / 100);
const rest = kop % 100;
const grouped = whole.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " ");
const tail = rest ? `,${rest.toString().padStart(2, "0")}` : "";
return `${neg ? "" : ""}${grouped}${tail}`;
} }
/** Render a Russian mobile from up-to-10 national digits as +7 914 305-44-00. */ /** Render a Russian mobile from up-to-10 national digits as +7 914 305-44-00. */
+40
View File
@@ -0,0 +1,40 @@
/**
* Чтение полезной нагрузки JWT без проверки подписи — для экрана отладки.
*
* Проверять подпись здесь нечем и не нужно: решение о доступе принимает бэкенд,
* а приложению эти поля нужны ровно для того, чтобы показать человеку, чем он
* сейчас держится — до какого времени живёт токен и чей это сеанс.
*/
export interface TokenInfo {
sub: string | null;
exp: number | null;
impersonated: boolean;
}
export function readToken(token: string | null): TokenInfo | null {
if (!token) return null;
const part = token.split(".")[1];
if (!part) return null;
try {
const json = atob(part.replace(/-/g, "+").replace(/_/g, "/"));
const claims = JSON.parse(json) as Record<string, unknown>;
return {
sub: typeof claims.sub === "string" ? claims.sub : null,
exp: typeof claims.exp === "number" ? claims.exp : null,
impersonated: claims.imp === true,
};
} catch {
return null;
}
}
/** «через 7 ч 20 мин» / «истёк» — человеку, а не в секундах. */
export function expiresIn(exp: number | null): string {
if (exp == null) return "—";
const left = exp * 1000 - Date.now();
if (left <= 0) return "истёк";
const h = Math.floor(left / 3_600_000);
const m = Math.floor((left % 3_600_000) / 60_000);
if (h >= 24) return `через ${Math.floor(h / 24)} дн ${h % 24} ч`;
return h > 0 ? `через ${h} ч ${m} мин` : `через ${m} мин`;
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Pairing-сессия входа через Telegram, пока приложение ждёт бота.
*
* Держим в sessionStorage: экран ожидания переживает уход в Telegram и возврат,
* но сессия не должна пережить закрытие вкладки — это половина ключа к JWT.
* Раньше ключи раскладывались по три штуки в каждом экране; теперь одно место,
* чтобы `poll_secret` нельзя было забыть положить или забыть стереть.
*/
const K_SESSION = "pp-pair-session";
const K_SECRET = "pp-pair-secret";
const K_CONFIRM = "pp-pair-confirm";
const K_LINK = "pp-pair-deeplink";
export interface Pairing {
pairSession: string;
pollSecret: string;
confirmCode: string;
deepLink: string | null;
}
export function savePairing(p: {
pair_session: string; poll_secret: string; confirm_code: string; deep_link?: string | null;
}): void {
sessionStorage.setItem(K_SESSION, p.pair_session);
sessionStorage.setItem(K_SECRET, p.poll_secret);
sessionStorage.setItem(K_CONFIRM, p.confirm_code);
if (p.deep_link) sessionStorage.setItem(K_LINK, p.deep_link);
else sessionStorage.removeItem(K_LINK);
}
export function loadPairing(): Pairing | null {
const pairSession = sessionStorage.getItem(K_SESSION);
const pollSecret = sessionStorage.getItem(K_SECRET);
if (!pairSession || !pollSecret) return null;
return {
pairSession,
pollSecret,
confirmCode: sessionStorage.getItem(K_CONFIRM) ?? "",
deepLink: sessionStorage.getItem(K_LINK),
};
}
export function clearPairing(): void {
[K_SESSION, K_SECRET, K_CONFIRM, K_LINK].forEach((k) => sessionStorage.removeItem(k));
}
@@ -0,0 +1,28 @@
import { describe, it, expect, beforeEach } from "vitest";
import { clearLog, getLog, pushEntry, shortPath, subscribe } from "./reqlog";
describe("журнал запросов", () => {
beforeEach(() => clearLog());
it("новые записи сверху, старые вытесняются", () => {
for (let i = 0; i < 45; i++) pushEntry({ method: "GET", path: `/api/${i}`, status: 200, ms: 1 });
const log = getLog();
expect(log.length).toBe(40);
expect(log[0].path).toBe("/api/44");
});
it("подписчик узнаёт о записи — на этом держится экран отладки", () => {
let calls = 0;
const off = subscribe(() => { calls += 1; });
pushEntry({ method: "GET", path: "/api/x", status: 200, ms: 1 });
expect(calls).toBe(1);
off();
pushEntry({ method: "GET", path: "/api/y", status: 200, ms: 1 });
expect(calls).toBe(1);
});
it("query отбрасывается: в нём ездят идентификаторы", () => {
expect(shortPath("https://crm.pptaxi.ru/api/driver/payment/x?token=secret"))
.toBe("/api/driver/payment/x");
});
});
+55
View File
@@ -0,0 +1,55 @@
/**
* Кольцевой журнал запросов приложения — для вкладки «Отладка».
*
* Зачем он, если есть DevTools: приложение живёт на телефоне, и половина
* интересных случаев (протухший токен, отвалившийся Wi-Fi, 429 от лимита) видна
* только там, где DevTools нет. Журнал даёт те же четыре факта — метод, путь,
* статус, длительность — прямо на экране.
*
* Живёт в памяти и умирает вместе с вкладкой: это отладочный след, а не история.
* В нём НЕТ ни тел запросов, ни заголовков — токен водителя в отладочный экран
* попасть не должен.
*/
export interface ReqEntry {
id: number;
method: string;
path: string;
status: number | null;
ms: number;
at: number;
}
const LIMIT = 40;
let seq = 0;
let entries: ReqEntry[] = [];
const listeners = new Set<() => void>();
export function pushEntry(e: Omit<ReqEntry, "id" | "at">): void {
seq += 1;
entries = [{ ...e, id: seq, at: Date.now() }, ...entries].slice(0, LIMIT);
listeners.forEach((l) => l());
}
export function clearLog(): void {
entries = [];
listeners.forEach((l) => l());
}
/** Снимок для useSyncExternalStore: ссылка меняется только при новой записи. */
export function getLog(): ReqEntry[] {
return entries;
}
export function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
/** Путь без origin и без query: в query ездят идентификаторы, а экран публичный. */
export function shortPath(url: string): string {
try {
return new URL(url).pathname;
} catch {
return url.split("?")[0];
}
}
@@ -14,6 +14,7 @@ export function BalancePage() {
const accounts = data?.accounts ?? []; const accounts = data?.accounts ?? [];
const total = accounts.reduce((s, a) => s + a.balance, 0); const total = accounts.reduce((s, a) => s + a.balance, 0);
const pending = data?.pending_topup ?? 0;
return ( return (
<div> <div>
@@ -21,6 +22,14 @@ export function BalancePage() {
<div className="card p-4 mb-4 text-center"> <div className="card p-4 mb-4 text-center">
<p className="text-muted text-xs mb-1">Баланс</p> <p className="text-muted text-xs mb-1">Баланс</p>
<p className={`text-2xl font-extrabold ${total < 0 ? "text-neg" : "text-pos"}`}>{formatMoney(total)}</p> <p className={`text-2xl font-extrabold ${total < 0 ? "text-neg" : "text-pos"}`}>{formatMoney(total)}</p>
{pending > 0 && (
// Платёж принят нами, но 1С его ещё не провела. В сумму баланса он не
// входит намеренно: сложить оба источника значило бы показать
// пополнение дважды, как только расчётный лист принесёт тот же платёж.
<p className="text-muted text-xs mt-2">
{formatMoney(pending)} зачисляется учтётся после проведения
</p>
)}
</div> </div>
{isLoading ? ( {isLoading ? (
@@ -0,0 +1,67 @@
import { describe, it, expect, vi, beforeEach } 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/staff", () => ({
searchDrivers: vi.fn(async () => ({ drivers: [] })),
impersonate: vi.fn(),
}));
// Экран баланса, на который уводит редирект, тянет свои запросы — отдаём пустышки.
vi.mock("@/api/driver", () => ({
apiErrorText: vi.fn(async (_e: unknown, f: string) => f),
getBalance: vi.fn(async () => ({ accounts: [], pending_topup: 0 })),
}));
import App from "@/App";
import { DebugPage } from "./DebugPage";
import { clearLog, pushEntry } from "@/lib/reqlog";
import { useAuth } from "@/store/auth";
import { useStaff } from "@/store/staff";
function renderAt(path: string) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={[path]}><App /></MemoryRouter>
</QueryClientProvider>
);
}
describe("вкладка отладки", () => {
beforeEach(() => { useAuth.getState().clear(); useStaff.getState().clear(); clearLog(); });
it("водителя на /debug не пускает — уводит в приложение", () => {
useAuth.getState().setSession("t", 42);
renderAt("/debug");
expect(screen.queryByRole("heading", { name: "Отладка" })).not.toBeInTheDocument();
});
it("сотруднику показывает, от чьего лица открыт экран", () => {
useAuth.getState().setSession("t", 42, { impersonated: true, driverName: "Петров Пётр" });
renderAt("/debug");
expect(screen.getByRole("heading", { name: "Отладка" })).toBeInTheDocument();
// Плашка живёт над маршрутами и видна на любом экране, включая этот.
expect(screen.getByText(/Смотрю как/)).toBeInTheDocument();
expect(screen.getAllByText(/Петров Пётр/).length).toBeGreaterThan(0);
});
it("плашки нет у обычного водителя", () => {
useAuth.getState().setSession("t", 42);
renderAt("/");
expect(screen.queryByText(/Смотрю как/)).not.toBeInTheDocument();
});
it("журнал запросов виден со статусами и временем", () => {
useAuth.getState().setSession("t", 42, { impersonated: true, driverName: "Петров" });
pushEntry({ method: "GET", path: "/api/driver/balance", status: 401, ms: 123 });
render(
<QueryClientProvider client={new QueryClient()}>
<MemoryRouter><DebugPage /></MemoryRouter>
</QueryClientProvider>
);
expect(screen.getByText("/api/driver/balance")).toBeInTheDocument();
expect(screen.getByText("401")).toBeInTheDocument();
expect(screen.getByText("123 мс")).toBeInTheDocument();
});
});
+127
View File
@@ -0,0 +1,127 @@
import { useState, useSyncExternalStore } from "react";
import { useNavigate } from "react-router-dom";
import { LogOut, RefreshCw, Trash2, UserCog } from "lucide-react";
import { toast } from "sonner";
import { API_BASE } from "@/api/client";
import { clearLog, getLog, subscribe } from "@/lib/reqlog";
import { expiresIn, readToken } from "@/lib/jwt";
import { useAuth } from "@/store/auth";
import { useStaff } from "@/store/staff";
import { DriverPicker } from "@/components/DriverPicker";
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 break-all">{v}</span>
</div>
);
}
/**
* Вкладка «Отладка» — то, чего в приложении водителя нет и быть не должно:
* от чьего лица открыт экран, куда ходят запросы, чем они отвечают.
*
* Показывается только сотруднику (есть учётка CRM или действует просмотр). У
* водителя маршрут ведёт на главную — см. App.
*/
export function DebugPage() {
const nav = useNavigate();
const { token, driverId, driverName, impersonated, clear } = useAuth();
const staff = useStaff();
const log = useSyncExternalStore(subscribe, getLog);
const [switching, setSwitching] = useState(false);
const info = readToken(token);
async function hardReload() {
// Кэш сервис-воркера переживает деплой и показывает вчерашний бандл — на
// отладке это стоит часа «почему правка не приехала».
try {
const regs = await navigator.serviceWorker?.getRegistrations?.();
await Promise.all((regs ?? []).map((r) => r.unregister()));
const keys = await caches?.keys?.();
await Promise.all((keys ?? []).map((k) => caches.delete(k)));
toast.success("Кэш сброшен, перезагружаю");
} catch {
toast.error("Не удалось сбросить кэш");
}
location.reload();
}
return (
<div className="pt-3">
<h1 className="text-lg font-extrabold mb-4">Отладка</h1>
<div className="card p-4 mb-4">
<p className="text-muted text-xs mb-1">Сейчас смотрю</p>
<p className="text-sm font-bold mb-3">
{impersonated ? (driverName ?? `Водитель #${driverId}`) : "Свой аккаунт водителя"}
</p>
<button className="btn-ghost flex items-center justify-center gap-2"
onClick={() => setSwitching((s) => !s)}>
<UserCog size={16} /> {switching ? "Свернуть" : "Сменить водителя"}
</button>
{switching && (
<div className="mt-4">
<DriverPicker onPicked={(d) => { setSwitching(false); toast.success(`Смотрю как ${d.fio}`); }} />
</div>
)}
</div>
<div className="card p-4 mb-4">
<p className="text-muted text-xs mb-2">Сведения</p>
<Row k="driver_id" v={String(driverId ?? "—")} />
<Row k="режим" v={impersonated ? "просмотр сотрудником (imp)" : "обычный вход водителя"} />
<Row k="сотрудник" v={staff.username ?? "не входил"} />
<Row k="токен истекает" v={expiresIn(info?.exp ?? null)} />
<Row k="API" v={API_BASE} />
<Row k="сборка" v={`${__BUILD_COMMIT__} · ${__BUILD_TIME__}`} />
<Row k="сеть" v={navigator.onLine ? "онлайн" : "офлайн"} />
</div>
<div className="card p-4 mb-4">
<div className="flex items-center justify-between mb-2">
<p className="text-muted text-xs">Последние запросы</p>
<button className="text-muted text-xs flex items-center gap-1" onClick={clearLog}>
<Trash2 size={12} /> очистить
</button>
</div>
{log.length === 0 ? (
<p className="text-muted text-xs py-2">Пока пусто</p>
) : (
<div className="font-mono text-[11px] leading-5">
{log.map((e) => (
<div key={e.id} className="flex gap-2 border-b border-line last:border-0 py-1">
<span className={e.status && e.status >= 400 ? "text-neg" : "text-pos"}>
{e.status ?? "—"}
</span>
<span className="text-muted">{e.method}</span>
<span className="truncate">{e.path}</span>
<span className="ml-auto text-muted shrink-0">{e.ms} мс</span>
</div>
))}
</div>
)}
<p className="text-muted text-[11px] mt-2">
Запрос, оборвавшийся без ответа, сюда не попадает.
</p>
</div>
<button className="btn-ghost flex items-center justify-center gap-2 mb-2" onClick={hardReload}>
<RefreshCw size={16} /> Сбросить кэш и перезагрузить
</button>
<button
className="btn-ghost flex items-center justify-center gap-2"
onClick={() => { clear(); nav(staff.token ? "/dev" : "/login"); }}
>
<LogOut size={16} /> {impersonated ? "Выйти из просмотра" : "Выйти"}
</button>
{staff.token && (
<button className="text-muted text-xs w-full mt-4"
onClick={() => { clear(); staff.clear(); nav("/login"); }}>
Выйти и из учётки сотрудника
</button>
)}
</div>
);
}
@@ -0,0 +1,67 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
vi.mock("@/api/staff", () => ({
staffLogin: vi.fn(async () => ({ access_token: "staff-tok", requires_2fa: false, partial_token: null })),
staffLogin2fa: vi.fn(async () => ({ access_token: "staff-tok", requires_2fa: false, partial_token: null })),
searchDrivers: vi.fn(async () => ({
drivers: [{ id: 42, fio: "Петров Пётр", phone: "+79141234567", status: "Работает", driver_kind: "park" }],
})),
impersonate: vi.fn(async () => ({
token: "driver-tok",
driver: { id: 42, fio: "Петров Пётр", phone: "+79141234567", status: "Работает", driver_kind: "park" },
})),
}));
vi.mock("@/api/driver", () => ({ apiErrorText: vi.fn(async (_e: unknown, f: string) => f) }));
import { DevLoginPage } from "./DevLoginPage";
import * as staffApi from "@/api/staff";
import { useAuth } from "@/store/auth";
import { useStaff } from "@/store/staff";
const wrap = () => render(<MemoryRouter><DevLoginPage /></MemoryRouter>);
describe("DevLoginPage", () => {
beforeEach(() => {
useAuth.getState().clear();
useStaff.getState().clear();
vi.clearAllMocks();
});
it("логин учёткой CRM, выбор водителя — и сеанс помечен как просмотр", async () => {
wrap();
await userEvent.type(screen.getByPlaceholderText("Логин"), "vlad");
await userEvent.type(screen.getByPlaceholderText("Пароль"), "secret");
await userEvent.click(screen.getByRole("button", { name: "Войти" }));
expect(staffApi.staffLogin).toHaveBeenCalledWith("vlad", "secret");
expect(useStaff.getState().token).toBe("staff-tok");
await userEvent.click(await screen.findByRole("button", { name: /Петров Пётр/ }));
expect(staffApi.impersonate).toHaveBeenCalledWith(42);
const auth = useAuth.getState();
// Метка просмотра — то, на чём держатся и плашка, и запрет боевой оплаты.
expect(auth.token).toBe("driver-tok");
expect(auth.driverId).toBe(42);
expect(auth.impersonated).toBe(true);
expect(auth.driverName).toBe("Петров Пётр");
});
it("двухфакторка: пароль сам по себе сеанса не открывает", async () => {
vi.mocked(staffApi.staffLogin).mockResolvedValueOnce({
access_token: null, requires_2fa: true, partial_token: "partial",
});
wrap();
await userEvent.type(screen.getByPlaceholderText("Логин"), "vlad");
await userEvent.type(screen.getByPlaceholderText("Пароль"), "secret");
await userEvent.click(screen.getByRole("button", { name: "Войти" }));
expect(useStaff.getState().token).toBeNull();
await userEvent.type(await screen.findByPlaceholderText("000000"), "123456");
await userEvent.click(screen.getByRole("button", { name: "Подтвердить" }));
expect(staffApi.staffLogin2fa).toHaveBeenCalledWith("partial", "123456");
expect(useStaff.getState().token).toBe("staff-tok");
});
});
@@ -0,0 +1,122 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { apiErrorText } from "@/api/driver";
import { staffLogin, staffLogin2fa } from "@/api/staff";
import { useStaff } from "@/store/staff";
import { DriverPicker } from "@/components/DriverPicker";
import { Spinner } from "@/components/Spinner";
/**
* Вход сотрудника — тот же логин и пароль, что в CRM.
*
* Экран нужен, пока приложение обкатывается: интерфейс проверяется на живых
* данных, а живые данные лежат у настоящих водителей. Отдельного входа «за
* водителя» здесь нет: сотрудник входит СОБОЙ, а дальше выбирает, чей экран
* смотреть, — и это записывается в аудит CRM.
*/
export function DevLoginPage() {
const nav = useNavigate();
const { token: staffToken, username: staffName, setStaff, clear } = useStaff();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [partial, setPartial] = useState<string | null>(null);
const [code, setCode] = useState("");
const [busy, setBusy] = useState(false);
async function submit() {
if (!username || !password || busy) return;
setBusy(true);
try {
const r = await staffLogin(username, password);
if (r.requires_2fa && r.partial_token) {
setPartial(r.partial_token);
return;
}
if (!r.access_token) throw new Error("no token");
setStaff(r.access_token, username);
} catch (e: unknown) {
toast.error(await apiErrorText(e, "Неверный логин или пароль"));
} finally {
setBusy(false);
}
}
async function submit2fa() {
if (!partial || code.length < 6 || busy) return;
setBusy(true);
try {
const r = await staffLogin2fa(partial, code);
if (!r.access_token) throw new Error("no token");
setStaff(r.access_token, username);
setPartial(null);
} catch (e: unknown) {
toast.error(await apiErrorText(e, "Код не подошёл"));
} finally {
setBusy(false);
}
}
if (staffToken) {
return (
<div className="pt-10">
<h1 className="text-lg font-extrabold mb-1">Кого смотрим</h1>
<p className="text-muted text-xs mb-5">
Сотрудник: {staffName}. Приложение откроется от лица выбранного водителя,
деньги в этом режиме не двигаются.
</p>
<DriverPicker onPicked={() => nav("/")} />
<button className="btn-ghost mt-5" onClick={clear}>Выйти из учётки сотрудника</button>
</div>
);
}
return (
<div className="pt-16">
<h1 className="text-xl font-extrabold mb-1">Вход для сотрудника</h1>
<p className="text-muted text-sm mb-8">Учётная запись Premium CRM</p>
{partial ? (
<>
<p className="text-sm mb-2">Код из приложения-аутентификатора.</p>
<input
inputMode="numeric"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 6))}
placeholder="000000"
className="card w-full px-4 py-3 mb-3 text-base tracking-widest bg-white"
/>
<button className="btn-primary" disabled={busy || code.length < 6} onClick={submit2fa}>
{busy ? <Spinner /> : "Подтвердить"}
</button>
<button className="btn-ghost mt-2" onClick={() => { setPartial(null); setCode(""); }}>
Назад
</button>
</>
) : (
<>
<input
autoCapitalize="none"
autoCorrect="off"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Логин"
className="card w-full px-4 py-3 mb-2 text-base bg-white"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") submit(); }}
placeholder="Пароль"
className="card w-full px-4 py-3 mb-3 text-base bg-white"
/>
<button className="btn-primary" disabled={busy || !username || !password} onClick={submit}>
{busy ? <Spinner /> : "Войти"}
</button>
<button className="btn-ghost mt-2" onClick={() => nav("/login")}>Я водитель</button>
</>
)}
</div>
);
}
+31 -5
View File
@@ -4,10 +4,30 @@ import { getPayments } from "@/api/driver";
import { formatMoney } from "@/lib/format"; import { formatMoney } from "@/lib/format";
import { Spinner } from "@/components/Spinner"; import { Spinner } from "@/components/Spinner";
/**
* Все статусы, которые может прислать бэкенд. Раньше их было пять из тринадцати,
* и водитель видел в истории «DEADLINE_EXPIRED» или «ATTEMPTS_EXPIRED» латиницей,
* а возврат денег выглядел ровно как обычный отказ.
*/
const STATUS_RU: Record<string, string> = { const STATUS_RU: Record<string, string> = {
PAID: "Оплачено", NEW: "Ожидает", FORM: "Ожидает", CONFIRMED: "Оплачено", REJECTED: "Отклонён", 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() { export function HistoryPage() {
const nav = useNavigate(); const nav = useNavigate();
const { data, isLoading, isError } = useQuery({ queryKey: ["payments"], queryFn: getPayments }); const { data, isLoading, isError } = useQuery({ queryKey: ["payments"], queryFn: getPayments });
@@ -24,17 +44,23 @@ export function HistoryPage() {
) : rows.length === 0 ? ( ) : rows.length === 0 ? (
<p className="text-muted text-sm">Пока нет пополнений</p> <p className="text-muted text-sm">Пока нет пополнений</p>
) : ( ) : (
rows.map((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 key={p.order_id} className="flex justify-between items-center py-3 border-b border-line">
<div> <div>
<p className="text-sm">{p.bucket}</p> <p className="text-sm">{p.bucket}</p>
<p className="text-muted text-xs"> <p className="text-muted text-xs">
{p.created_at ? new Date(p.created_at).toLocaleDateString("ru-RU") : ""} · {STATUS_RU[p.status] ?? p.status} {p.created_at ? new Date(p.created_at).toLocaleDateString("ru-RU") : ""} ·{" "}
{STATUS_RU[p.status] ?? p.status}
</p> </p>
</div> </div>
<b>{formatMoney(p.amount)}</b> <b className={returned ? "text-muted line-through" : undefined}>
{formatMoney(p.amount)}
</b>
</div> </div>
)) );
})
)} )}
</div> </div>
); );
+18 -8
View File
@@ -1,8 +1,9 @@
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 { authRequest, authVerify, tgSession } from "@/api/driver"; import { apiErrorText, authRequest, authVerify, tgSession } from "@/api/driver";
import { digitsOnly, formatPhone } from "@/lib/format"; import { digitsOnly, formatPhone } from "@/lib/format";
import { savePairing } from "@/lib/pairing";
import { useAuth } from "@/store/auth"; import { useAuth } from "@/store/auth";
import { Spinner } from "@/components/Spinner"; import { Spinner } from "@/components/Spinner";
@@ -18,7 +19,8 @@ export function LoginPage() {
// 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.
const [tg, setTg] = useState<{ pair_session: string; deep_link: string | null } | null>(null); const [tg, setTg] = useState<{ pair_session: string; poll_secret: string;
confirm_code: string; deep_link: string | null } | null>(null);
const e164 = "7" + phone; const e164 = "7" + phone;
useEffect(() => { useEffect(() => {
@@ -29,8 +31,7 @@ export function LoginPage() {
function startTg() { function startTg() {
if (!tg) return; if (!tg) return;
sessionStorage.setItem("pp-pair-session", tg.pair_session); savePairing(tg);
if (tg.deep_link) sessionStorage.setItem("pp-pair-deeplink", tg.deep_link);
nav("/tg-wait"); nav("/tg-wait");
} }
@@ -40,6 +41,10 @@ export function LoginPage() {
try { try {
const r = await authRequest(e164); const r = await authRequest(e164);
if (r.status === "onboarding") { if (r.status === "onboarding") {
// Онбординг теперь идёт той же pairing-сессией: бот попросит поделиться
// контактом, Telegram подтвердит номер, приложение заберёт токен опросом.
// Второй раз номер вводить не нужно.
savePairing({ ...r, deep_link: r.deep_links.tg ?? null });
sessionStorage.setItem("pp-onboarding", JSON.stringify({ ...r.deep_links, phone })); sessionStorage.setItem("pp-onboarding", JSON.stringify({ ...r.deep_links, phone }));
nav("/onboarding"); nav("/onboarding");
return; return;
@@ -48,11 +53,11 @@ export function LoginPage() {
setStep("code"); setStep("code");
} catch (err: unknown) { } catch (err: unknown) {
const status = (err as { response?: { status?: number } })?.response?.status; const status = (err as { response?: { status?: number } })?.response?.status;
const msg = const fallback =
status === 404 ? "Номер не найден. Обратитесь в парк." : status === 404 ? "Номер не найден. Обратитесь в парк." :
status === 429 ? "Слишком часто. Попробуйте позже." : status === 429 ? "Слишком часто. Попробуйте позже." :
"Не удалось отправить код"; "Не удалось отправить код";
toast.error(msg); toast.error(await apiErrorText(err, fallback));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -65,8 +70,8 @@ export function LoginPage() {
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("/");
} catch { } catch (err: unknown) {
toast.error("Неверный или просроченный код"); toast.error(await apiErrorText(err, "Неверный или просроченный код"));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -96,6 +101,11 @@ export function LoginPage() {
)} )}
<p className="text-muted text-xs mt-3">Telegram подтвердит ваш номер автоматически.</p> <p className="text-muted text-xs mt-3">Telegram подтвердит ваш номер автоматически.</p>
<button className="btn-ghost mt-4" onClick={() => setMode("code")}>Войти по коду</button> <button className="btn-ghost mt-4" onClick={() => setMode("code")}>Войти по коду</button>
{/* Пока идёт обкатка, сотруднику надо попадать внутрь без карточки
водителя и без бота — учёткой CRM. Ссылка неприметная намеренно. */}
<button className="text-muted text-xs w-full mt-6" onClick={() => nav("/dev")}>
Вход для сотрудника
</button>
</> </>
) : ( ) : (
<> <>
@@ -0,0 +1,42 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router-dom";
vi.mock("@/api/driver", () => ({
mockPayOutcome: vi.fn(async () => ({ order_id: "mock-1", status: "PAID" })),
apiErrorText: vi.fn(async (_e: unknown, f: string) => f),
}));
import { MockPayPage } from "./MockPayPage";
import * as driver from "@/api/driver";
import { useAuth } from "@/store/auth";
const wrap = () =>
render(
<MemoryRouter initialEntries={["/mock-pay/mock-1"]}>
<Routes>
<Route path="/mock-pay/:orderId" element={<MockPayPage />} />
<Route path="/pay/:orderId" element={<p>экран оплаты</p>} />
</Routes>
</MemoryRouter>
);
describe("MockPayPage", () => {
beforeEach(() => { useAuth.getState().clear(); vi.clearAllMocks(); });
it("исход уходит на бэкенд и возвращает на настоящий экран оплаты", async () => {
useAuth.getState().setSession("t", 42, { impersonated: true, driverName: "Петров" });
wrap();
await userEvent.click(screen.getByRole("button", { name: /Банк отклонил/ }));
expect(driver.mockPayOutcome).toHaveBeenCalledWith("mock-1", "rejected");
expect(await screen.findByText("экран оплаты")).toBeInTheDocument();
});
it("водителю тут делать нечего", async () => {
useAuth.getState().setSession("t", 42);
wrap();
expect(screen.getByText(/Учебная оплата недоступна/)).toBeInTheDocument();
expect(driver.mockPayOutcome).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,77 @@
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { apiErrorText, mockPayOutcome } from "@/api/driver";
import { useAuth } from "@/store/auth";
import { Spinner } from "@/components/Spinner";
/**
* Учебная «касса»: чем закончится платёж, который сотрудник создал под просмотром.
*
* Это подмена банка, а не экрана приложения. Настоящий экран оплаты
* (`/pay/:orderId`) остаётся тем же самым и разбирает те же статусы — иначе
* отладка проверяла бы поведение, которого у водителя не будет.
*/
const OUTCOMES = [
{ key: "paid", label: "Оплата прошла", hint: "PAID — экран «Оплачено»" },
{ key: "rejected", label: "Банк отклонил", hint: "REJECTED — «Оплата не прошла»" },
{ key: "expired", label: "Счёт протух", hint: "DEADLINE_EXPIRED — «Срок оплаты истёк»" },
{ key: "reversed", label: "Деньги вернулись", hint: "REVERSED — «Платёж возвращён»" },
] as const;
export function MockPayPage() {
const { orderId = "" } = useParams();
const nav = useNavigate();
const impersonated = useAuth((s) => s.impersonated);
const [busy, setBusy] = useState<string | null>(null);
async function choose(outcome: (typeof OUTCOMES)[number]["key"]) {
setBusy(outcome);
try {
await mockPayOutcome(orderId, outcome);
nav(`/pay/${orderId}`, { replace: true });
} catch (e: unknown) {
toast.error(await apiErrorText(e, "Не удалось разыграть исход"));
} finally {
setBusy(null);
}
}
if (!impersonated) {
return (
<div className="pt-24 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-8">
<button className="text-muted text-xs mb-3" onClick={() => nav(-1)}> Назад</button>
<h1 className="text-lg font-extrabold mb-1">Учебная оплата</h1>
<p className="text-muted text-xs mb-5">
Банка в этом платеже нет: ни списания, ни чека, ни проводки. Выберите, что
«ответит» банк приложение покажет ровно то же, что увидел бы водитель.
</p>
<div className="card divide-y divide-line">
{OUTCOMES.map((o) => (
<button
key={o.key}
disabled={busy !== null}
onClick={() => choose(o.key)}
className="w-full text-left px-4 py-3 flex items-center justify-between gap-3 disabled:opacity-50"
>
<span>
<span className="block text-sm font-semibold">{o.label}</span>
<span className="block text-muted text-xs">{o.hint}</span>
</span>
{busy === o.key && <Spinner />}
</button>
))}
</div>
<p className="text-muted text-xs mt-4">Заказ: {orderId}</p>
</div>
);
}
@@ -2,22 +2,41 @@ import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import { OnboardingPage } from "./OnboardingPage"; import { OnboardingPage } from "./OnboardingPage";
import { savePairing, clearPairing } from "@/lib/pairing";
describe("OnboardingPage", () => { describe("OnboardingPage", () => {
beforeEach(() => beforeEach(() => {
sessionStorage.setItem("pp-onboarding", JSON.stringify({ tg: "https://t.me/b?start=x", max: "https://max.ru/b?start=x", phone: "9143054400" })) clearPairing();
); sessionStorage.setItem("pp-onboarding", JSON.stringify({ tg: "https://t.me/b?start=x", max: "https://max.ru/b?start=x", phone: "9143054400" }));
it("shows both bot links + back-to-code action", () => { });
it("shows both bot links + back to login", () => {
render(<MemoryRouter><OnboardingPage /></MemoryRouter>); render(<MemoryRouter><OnboardingPage /></MemoryRouter>);
expect(screen.getByText(/Подключите бот/i)).toBeInTheDocument(); expect(screen.getByText(/Подключите бот/i)).toBeInTheDocument();
expect(screen.getByRole("link", { name: /Telegram/i })).toHaveAttribute("href", "https://t.me/b?start=x"); expect(screen.getByRole("link", { name: /Telegram/i })).toHaveAttribute("href", "https://t.me/b?start=x");
expect(screen.getByRole("link", { name: /MAX/i })).toHaveAttribute("href", "https://max.ru/b?start=x"); expect(screen.getByRole("link", { name: /MAX/i })).toHaveAttribute("href", "https://max.ru/b?start=x");
expect(screen.getByRole("button", { name: /запросить код/i })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /Назад ко входу/i })).toBeInTheDocument();
}); });
it("renders only Telegram when max is absent", () => { it("renders only Telegram when max is absent", () => {
sessionStorage.setItem("pp-onboarding", JSON.stringify({ tg: "https://t.me/ppdriver_bot?start=x", phone: "9143054400" })); sessionStorage.setItem("pp-onboarding", JSON.stringify({ tg: "https://t.me/ppdriver_bot?start=x", phone: "9143054400" }));
render(<MemoryRouter><OnboardingPage /></MemoryRouter>); render(<MemoryRouter><OnboardingPage /></MemoryRouter>);
expect(screen.getByRole("link", { name: /Telegram/i })).toBeInTheDocument(); expect(screen.getByRole("link", { name: /Telegram/i })).toBeInTheDocument();
expect(screen.queryByRole("link", { name: /MAX/i })).toBeNull(); expect(screen.queryByRole("link", { name: /MAX/i })).toBeNull();
}); });
it("shows the confirm code the bot must repeat", () => {
// Защита от фишинга: если ссылку прислал посторонний, у человека этого числа
// на экране нет — значит и делиться номером не нужно.
savePairing({ pair_session: "S", poll_secret: "SEC", confirm_code: "4217", deep_link: "https://t.me/b?start=S" });
render(<MemoryRouter><OnboardingPage /></MemoryRouter>);
expect(screen.getByText("4217")).toBeInTheDocument();
});
it("no longer asks the driver to come back for a code", () => {
// Онбординг идёт той же pairing-сессией: Telegram подтверждает номер, и
// приложение забирает токен опросом — второй раз номер вводить не нужно.
render(<MemoryRouter><OnboardingPage /></MemoryRouter>);
expect(screen.queryByRole("button", { name: /запросить код/i })).toBeNull();
});
}); });
@@ -1,19 +1,37 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { loadPairing } from "@/lib/pairing";
export function OnboardingPage() { export function OnboardingPage() {
const nav = useNavigate(); const nav = useNavigate();
const raw = sessionStorage.getItem("pp-onboarding"); const raw = sessionStorage.getItem("pp-onboarding");
const data = raw ? (JSON.parse(raw) as { tg?: string; max?: string; phone: string }) : null; const data = raw ? (JSON.parse(raw) as { tg?: string; max?: string; phone: string }) : null;
const pairing = loadPairing();
if (!data) return null; if (!data) return null;
return ( return (
<div className="pt-16"> <div className="pt-16">
<h1 className="text-xl font-extrabold mb-1">Подключите бот</h1> <h1 className="text-xl font-extrabold mb-1">Подключите бот</h1>
<p className="text-muted text-sm mb-8"> <p className="text-muted text-sm mb-8">
Чтобы получать код входа, откройте бот и нажмите «Старт». Затем вернитесь и запросите код снова. Откройте бот и поделитесь своим номером Telegram подтвердит его, и вход
произойдёт автоматически. Возвращаться за кодом не нужно.
</p> </p>
{pairing?.confirmCode && (
<div className="card p-4 mb-6 text-center">
<p className="text-muted text-xs mb-1">Код подтверждения</p>
<p className="text-2xl font-extrabold tracking-widest">{pairing.confirmCode}</p>
<p className="text-muted text-xs mt-2">
Бот назовёт это число. Не совпало закройте бот и не делитесь номером.
</p>
</div>
)}
{data.tg && ( {data.tg && (
<a href={data.tg} target="_blank" rel="noopener" className="btn-primary mb-3 no-underline block"> <a
href={data.tg}
target="_blank"
rel="noopener"
className="btn-primary mb-3 no-underline block"
onClick={() => nav("/tg-wait")}
>
Подключить Telegram Подключить Telegram
</a> </a>
)} )}
@@ -23,7 +41,7 @@ export function OnboardingPage() {
</a> </a>
)} )}
<button className="btn-ghost" onClick={() => nav("/login")}> <button className="btn-ghost" onClick={() => nav("/login")}>
Я подключил запросить код Назад ко входу
</button> </button>
</div> </div>
); );
+11 -1
View File
@@ -22,6 +22,9 @@ export function PaymentPage() {
const st = (useLocation().state as const st = (useLocation().state as
{ payUrl?: string; total?: number; bucket?: string; base?: number; method?: string } | null) ?? {}; { payUrl?: string; total?: number; bucket?: string; base?: number; method?: string } | null) ?? {};
const { data, lastFailed, failStreak, refetch } = usePaymentStatus(orderId); const { data, lastFailed, failStreak, refetch } = usePaymentStatus(orderId);
// Учебный платёж узнаётся по номеру заказа, а не по state роутера: state
// теряется при перезагрузке, а префикс — нет (см. backend mock_pay).
const mock = orderId.startsWith("mock-");
const status = data?.status ?? ""; const status = data?.status ?? "";
const paid = status === "PAID"; const paid = status === "PAID";
// пока метод не передан в state — узнаём по ссылке: СБП отдаёт платёжку qr.nspk.ru, карта — форму банка // пока метод не передан в state — узнаём по ссылке: СБП отдаёт платёжку qr.nspk.ru, карта — форму банка
@@ -106,7 +109,14 @@ export function PaymentPage() {
{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>}
{st.bucket && <p className="text-muted text-xs">{st.bucket}</p>} {st.bucket && <p className="text-muted text-xs">{st.bucket}</p>}
</div> </div>
{st.payUrl ? ( {mock ? (
<>
<button className="btn-primary" onClick={() => nav(`/mock-pay/${orderId}`)}>
Выбрать исход
</button>
<p className="text-muted text-xs mt-2">Учебный платёж банк не задействован</p>
</>
) : st.payUrl ? (
<> <>
<a className="btn-primary no-underline" href={st.payUrl} target="_blank" rel="noopener">Оплатить</a> <a className="btn-primary no-underline" href={st.payUrl} target="_blank" rel="noopener">Оплатить</a>
<p className="text-muted text-xs mt-2">Откроется ваше приложение банка</p> <p className="text-muted text-xs mt-2">Откроется ваше приложение банка</p>
+24 -12
View File
@@ -1,27 +1,25 @@
import { useEffect } from "react"; 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 { Spinner } from "@/components/Spinner"; import { Spinner } from "@/components/Spinner";
export function TgWaitPage() { export function TgWaitPage() {
const nav = useNavigate(); const nav = useNavigate();
const pairSession = sessionStorage.getItem("pp-pair-session"); const pairing = loadPairing();
const deepLink = sessionStorage.getItem("pp-pair-deeplink"); const { status } = usePairPoll(pairing?.pairSession ?? null, pairing?.pollSecret ?? null);
const { status } = usePairPoll(pairSession);
useEffect(() => { useEffect(() => {
if (!pairSession) { nav("/login", { replace: true }); } if (!pairing) { nav("/login", { replace: true }); }
}, [pairSession, nav]); }, [pairing, nav]);
useEffect(() => { useEffect(() => {
if (status === "authenticated") { if (status === "authenticated") {
sessionStorage.removeItem("pp-pair-session"); clearPairing();
sessionStorage.removeItem("pp-pair-deeplink");
nav("/", { replace: true }); nav("/", { replace: true });
} }
if (status === "expired") { if (status === "expired") {
sessionStorage.removeItem("pp-pair-session"); clearPairing();
sessionStorage.removeItem("pp-pair-deeplink");
nav("/login", { replace: true, state: { expired: true } }); nav("/login", { replace: true, state: { expired: true } });
} }
}, [status, nav]); }, [status, nav]);
@@ -30,10 +28,24 @@ export function TgWaitPage() {
<div className="pt-16 text-center"> <div className="pt-16 text-center">
<h1 className="text-xl font-extrabold mb-2">Премиум Водитель</h1> <h1 className="text-xl font-extrabold mb-2">Премиум Водитель</h1>
<p className="text-muted text-sm mb-6"> <p className="text-muted text-sm mb-6">
В Telegram нажмите «Поделиться номером для входа», затем вернитесь сюда вход произойдёт автоматически. Бот назовёт число ниже и попросит подтвердить вход нажмите «Это я, войти»
(или поделитесь номером, если входите впервые). Затем вернитесь сюда, вход
произойдёт автоматически.
</p> </p>
{deepLink && ( {pairing?.confirmCode && (
<a href={deepLink} target="_blank" rel="noopener noreferrer" className="btn-primary block text-center mb-6"> // Число сверки: бот называет его в своём сообщении. Если человек попал в
// бот по чужой ссылке, у него на экране этого числа нет — подтверждать
// нечего, и токен не выдаётся.
<div className="card p-4 mb-6">
<p className="text-muted text-xs mb-1">Код подтверждения</p>
<p className="text-2xl font-extrabold tracking-widest">{pairing.confirmCode}</p>
<p className="text-muted text-xs mt-2">
Это же число должен назвать бот. Не совпало нажмите в боте «Это не я».
</p>
</div>
)}
{pairing?.deepLink && (
<a href={pairing.deepLink} target="_blank" rel="noopener noreferrer" className="btn-primary block text-center mb-6">
Открыть Telegram Открыть Telegram
</a> </a>
)} )}
@@ -7,12 +7,16 @@ 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 })),
getCommission: vi.fn(async () => ({ rate: 0.052 })), getCommission: vi.fn(async () => ({ rate: 0.052 })),
getBuckets: vi.fn(async () => ({
buckets: ["Долг аренда", "Долг по штрафам"], with_debt: ["Долг аренда"],
})),
apiErrorText: vi.fn(async (_e: unknown, fallback: string) => fallback),
})); }));
import { TopupPage } from "./TopupPage"; import { TopupPage } from "./TopupPage";
import * as driver from "@/api/driver"; import * as driver from "@/api/driver";
const wrap = () => { const wrap = () => {
const qc = new QueryClient(); const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render( return render(
<QueryClientProvider client={qc}> <QueryClientProvider client={qc}>
<MemoryRouter><TopupPage /></MemoryRouter> <MemoryRouter><TopupPage /></MemoryRouter>
@@ -32,4 +36,27 @@ describe("TopupPage", () => {
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");
}); });
it("decimal key builds kopecks and they reach the backend", async () => {
// Клавиша разделителя была нарисована, но ничего не делала: заплатить точный
// долг вроде 1234,56 было нечем.
wrap();
for (const k of ["1", "0", "0", ",", "5", "2"]) {
await userEvent.click(screen.getByRole("button", { name: k }));
}
expect(screen.getByTestId("amount").textContent).toBe("100,52 ₽");
await userEvent.click(screen.getByRole("button", { name: /Оплатить/i }));
expect(driver.topup).toHaveBeenCalledWith("Долг аренда", 100.52, "qr");
});
it("lets the driver choose the debt category instead of hardcoding rent", async () => {
wrap();
await screen.findByRole("button", { name: "Долг по штрафам" });
await userEvent.click(screen.getByRole("button", { name: "Долг по штрафам" }));
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).toHaveBeenCalledWith("Долг по штрафам", 500, "qr");
});
}); });
+43 -12
View File
@@ -3,41 +3,55 @@ import { useLocation, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Delete } from "lucide-react"; import { Delete } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { topup, getCommission } from "@/api/driver"; import { apiErrorText, topup, getCommission, getBuckets } from "@/api/driver";
import { keypadReduce, withCommission } from "@/lib/amount"; import { keypadReduce, parseAmount, withCommission } from "@/lib/amount";
import { formatMoney } from "@/lib/format"; import { formatMoney } from "@/lib/format";
import { Segment } from "@/components/Segment"; import { Segment } from "@/components/Segment";
import { Spinner } from "@/components/Spinner"; import { Spinner } from "@/components/Spinner";
const QUICK = ["500", "1000", "2000"]; const QUICK = ["500", "1000", "2000"];
const KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "·", "0", "back"]; 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 loc = useLocation(); const loc = useLocation();
const bucket = (loc.state as { bucket?: string } | null)?.bucket ?? "Долг аренда"; const fromBalance = (loc.state as { bucket?: string } | null)?.bucket;
// Список категорий приходит с сервера — там же он и проверяется. Хардкод
// «Долг аренда» отправлял платёж в аренду даже тем, у кого её нет.
const { data: bucketData } = useQuery({ queryKey: ["buckets"], queryFn: getBuckets });
const options = bucketData?.buckets ?? (fromBalance ? [fromBalance] : []);
const [picked, setPicked] = useState<string | null>(null);
const bucket = picked ?? fromBalance ?? bucketData?.with_debt[0] ?? options[0] ?? "";
const [raw, setRaw] = useState(""); const [raw, setRaw] = useState("");
const [method, setMethod] = useState("qr"); const [method, setMethod] = useState("qr");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const { data: commission } = useQuery({ queryKey: ["commission"], queryFn: getCommission }); const { data: commission } = useQuery({ queryKey: ["commission"], queryFn: getCommission });
const rate = commission?.rate ?? 0.052; // fallback only while loading const rate = commission?.rate ?? 0.052; // fallback only while loading
const base = raw ? parseInt(raw, 10) : 0; const base = parseAmount(raw);
const { commission: fee, total } = withCommission(base, rate); const { commission: fee, total } = withCommission(base, rate);
function press(k: string) { function press(k: string) {
if (k === "·") return;
setRaw((r) => keypadReduce(r, k)); setRaw((r) => keypadReduce(r, k));
} }
async function pay() { async function pay() {
if (base <= 0) 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");
nav(`/pay/${r.order_id}`, { state: { payUrl: r.pay_url, total: r.total, bucket, base } }); // Учебный платёж (сотрудник смотрит от чужого лица): банка нет, платить
} catch { // негде — сразу на экран выбора исхода, дальше всё как у водителя.
toast.error("Не удалось создать платёж"); if (r.mock) {
nav(`/mock-pay/${r.order_id}`, { state: { total: r.total, bucket, base, method } });
return;
}
nav(`/pay/${r.order_id}`, { state: { payUrl: r.pay_url, total: r.total, bucket, base, method } });
} catch (err: unknown) {
// Текст с сервера, а не глухое «не удалось»: минимум СБП, потолок суммы и
// неизвестная категория объясняются словами, иначе экран молча не реагирует.
toast.error(await apiErrorText(err, "Не удалось создать платёж"));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@@ -45,7 +59,24 @@ export function TopupPage() {
return ( return (
<div className="pt-2"> <div className="pt-2">
<button className="text-muted text-xs mb-3" onClick={() => nav(-1)}> {bucket}</button> <button className="text-muted text-xs mb-3" onClick={() => nav(-1)}> Назад</button>
{options.length > 1 && (
<div className="flex gap-1.5 mb-3 overflow-x-auto">
{options.map((b) => (
<button
key={b}
onClick={() => setPicked(b)}
className={`shrink-0 px-3 py-2 rounded-xl2 text-xs border ${
b === bucket ? "border-line bg-surface font-semibold" : "border-line text-muted"
}`}
>
{b}
</button>
))}
</div>
)}
<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">
к оплате {formatMoney(total)} (комиссия {formatMoney(fee)}) к оплате {formatMoney(total)} (комиссия {formatMoney(fee)})
@@ -76,7 +107,7 @@ 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>
<button className="btn-primary" disabled={busy || base <= 0} onClick={pay}> <button className="btn-primary" disabled={busy || base <= 0 || !bucket} onClick={pay}>
{busy ? <Spinner /> : "Оплатить"} {busy ? <Spinner /> : "Оплатить"}
</button> </button>
</div> </div>
+21 -4
View File
@@ -1,10 +1,19 @@
import { create } from "zustand"; import { create } from "zustand";
import { persist } from "zustand/middleware"; import { persist } from "zustand/middleware";
interface AuthState { /** Кем открыт сеанс: сам водитель или сотрудник, смотрящий от его лица.
* Признак приезжает из токена (claim `imp`), здесь он только продублирован для
* интерфейса — проверять его на бэкенде по этому полю нельзя и не нужно. */
export interface ViewAs {
impersonated: boolean;
/** ФИО водителя — показывать в плашке. У обычного входа неизвестно и не нужно. */
driverName: string | null;
}
interface AuthState extends ViewAs {
token: string | null; token: string | null;
driverId: number | null; driverId: number | null;
setSession: (token: string, driverId: number) => void; setSession: (token: string, driverId: number, view?: Partial<ViewAs>) => void;
clear: () => void; clear: () => void;
} }
@@ -13,8 +22,16 @@ export const useAuth = create<AuthState>()(
(set) => ({ (set) => ({
token: null, token: null,
driverId: null, driverId: null,
setSession: (token, driverId) => set({ token, driverId }), impersonated: false,
clear: () => set({ token: null, driverId: null }), driverName: null,
setSession: (token, driverId, view) =>
set({
token,
driverId,
impersonated: view?.impersonated ?? false,
driverName: view?.driverName ?? null,
}),
clear: () => set({ token: null, driverId: null, impersonated: false, driverName: null }),
}), }),
{ name: "pp-driver-auth" } { name: "pp-driver-auth" }
) )
+29
View File
@@ -0,0 +1,29 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
/**
* Сеанс СОТРУДНИКА — учётка CRM, которой берут просмотр от лица водителя.
*
* Хранится отдельно от водительского: это два разных токена с разным смыслом.
* «Перестать смотреть за водителя» обязано оставлять сотрудника залогиненным,
* иначе смена водителя каждый раз означала бы новый ввод пароля; и наоборот —
* `clear()` водительского сеанса не должен трогать сотрудника.
*/
interface StaffState {
token: string | null;
username: string | null;
setStaff: (token: string, username: string) => void;
clear: () => void;
}
export const useStaff = create<StaffState>()(
persist(
(set) => ({
token: null,
username: null,
setStaff: (token, username) => set({ token, username }),
clear: () => set({ token: null, username: null }),
}),
{ name: "pp-driver-staff" }
)
);
+5
View File
@@ -7,3 +7,8 @@ interface ImportMetaEnv {
interface ImportMeta { interface ImportMeta {
readonly env: ImportMetaEnv; readonly env: ImportMetaEnv;
} }
// Подставляются на сборке (vite.config.ts → define). Нужны экрану отладки:
// показывают, какой именно бандл сейчас открыт.
declare const __BUILD_COMMIT__: string;
declare const __BUILD_TIME__: string;
+1 -1
View File
@@ -4,5 +4,5 @@
"moduleResolution": "bundler", "allowImportingTsExtensions": true, "isolatedModules": true, "moduleResolution": "bundler", "allowImportingTsExtensions": true, "isolatedModules": true,
"noEmit": true, "strict": true "noEmit": true, "strict": true
}, },
"include": ["vite.config.ts", "vitest.config.ts"] "include": ["vite.config.ts", "vitest.config.ts", "dev-proxy.test.ts"]
} }
+63 -3
View File
@@ -1,13 +1,73 @@
import { defineConfig } from "vite"; import { execSync } from "child_process";
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { VitePWA } from "vite-plugin-pwa"; import { VitePWA } from "vite-plugin-pwa";
import path from "path"; import path from "path";
export default defineConfig({ /**
* Что именно сейчас открыто — видно на вкладке «Отладка».
*
* Без этого «правка не приехала» и «приехала, но кэш старый» выглядят
* одинаково, а проверяются по-разному. Коммит берём из git; вне репозитория
* (сборка из архива, CI без .git) — честный «unknown», а не падение сборки.
*/
function buildCommit(): string {
try {
return execSync("git rev-parse --short HEAD", { stdio: ["ignore", "pipe", "ignore"] })
.toString().trim() || "unknown";
} catch {
return "unknown";
}
}
/**
* Локальная разработка ходит в бэкенд ЧЕРЕЗ ПРОКСИ dev-сервера, а не напрямую.
*
* Почему не «разрешить localhost в CORS бэкенда»: через прокси запрос уходит с
* того же origin, что и страница (http://localhost:5174/api/...), поэтому CORS
* в разработке не участвует вообще — ни preflight, ни credentials. Боевой
* список origin'ов на бэкенде остаётся закрытым, и CORS_ALLOW_LOCALHOST ради
* этого фронта включать не нужно.
*
* Дефолтная цель прокси — ЛОКАЛЬНЫЙ бэкенд, а не crm.pptaxi.ru. Контур боевой:
* «по умолчанию проксировать в прод» означало бы, что первый же тычок в
* «Пополнить» на машине разработчика создаёт платёж на живом терминале и живой
* чек 54-ФЗ. Другой адрес задаётся VITE_API_TARGET — тем же именем, что в CRM
* (crm2/frontend/vite.config.ts), чтобы рецепт был один на оба фронта.
*/
export default defineConfig(({ command, mode }) => {
// База для ky (src/api/client.ts): в dev — свой же origin, дальше прокси.
// Ставим ТОЛЬКО если разработчик не задал базу сам (.env.local, .env,
// VITE_API_BASE=... перед командой): loadEnv отдаёт и файлы, и process.env,
// причём process.env приоритетнее файлов — без этой проверки собственная
// настройка молча перебивалась бы нашим дефолтом.
// В сборку (command === "build") не лезем никогда: там база остаётся боевой —
// из .env, либо из дефолта в client.ts.
if (command === "serve" && !loadEnv(mode, __dirname, "VITE_").VITE_API_BASE) {
process.env.VITE_API_BASE = "/api";
}
return {
define: {
__BUILD_COMMIT__: JSON.stringify(buildCommit()),
__BUILD_TIME__: JSON.stringify(new Date().toISOString().slice(0, 16).replace("T", " ")),
},
plugins: [ plugins: [
react(), react(),
VitePWA({ registerType: "autoUpdate", manifest: false, includeAssets: ["icons/*.png"] }), VitePWA({ registerType: "autoUpdate", manifest: false, includeAssets: ["icons/*.png"] }),
], ],
resolve: { alias: { "@": path.resolve(__dirname, "src") } }, resolve: { alias: { "@": path.resolve(__dirname, "src") } },
server: { port: 5174 }, server: {
port: 5174,
proxy: {
"/api": {
// secure: false нужен только для VITE_API_TARGET на https-цель с
// самоподписанным сертификатом; на дефолтную http-цель не влияет.
target: process.env.VITE_API_TARGET || "http://127.0.0.1:8000",
changeOrigin: true,
secure: false,
},
},
},
};
}); });
+6
View File
@@ -3,6 +3,12 @@ import react from "@vitejs/plugin-react";
import path from "path"; import path from "path";
export default defineConfig({ export default defineConfig({
// Те же подстановки, что и в боевой сборке (vite.config.ts): без них модуль с
// __BUILD_COMMIT__ падает в тестах на ReferenceError, хотя в приложении работает.
define: {
__BUILD_COMMIT__: JSON.stringify("test"),
__BUILD_TIME__: JSON.stringify("test"),
},
plugins: [react()], plugins: [react()],
resolve: { alias: { "@": path.resolve(__dirname, "src") } }, resolve: { alias: { "@": path.resolve(__dirname, "src") } },
test: { globals: true, environment: "jsdom", setupFiles: ["./src/test/setup.ts"] }, test: { globals: true, environment: "jsdom", setupFiles: ["./src/test/setup.ts"] },