Приложение обкатывается на боевом контуре, поэтому интерфейс надо проверять на живых данных — а живые данные лежат у настоящих водителей. * «Вход для сотрудника» (/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>
78 lines
3.5 KiB
TypeScript
78 lines
3.5 KiB
TypeScript
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>
|
||
);
|
||
}
|