feat(pwa): экран оплаты честно показывает отказ, таймаут и возврат

Экран успеха рисовался только при PAID. При любом другом финальном статусе
опрос останавливался, а человек навсегда оставался на «Ожидаем оплату…» со
спиннером — то есть два из трёх тестовых сценариев СБП глазами не проверить.

- ветки отказа (в том числе исчерпанных попыток), истёкшего срока и возврата
  средств: у возврата свой текст, новая попытка там ни при чём;
- ошибка запроса больше не выглядит как честное ожидание: после серии неудач
  подряд экран говорит, что не может проверить оплату, и даёт повторить.
  Раньше 404, 500 и рестарт бэкенда были неотличимы от «ждём оплату»;
- плашка способа оплаты показывала «СБП» и при оплате картой;
- после перезагрузки страницы сумма бралась из пустого router state и
  показывалась как 0 ₽ — лучше не показать ничего, чем показать неправду.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 18:12:13 +10:00
co-authored by Claude Opus 5
parent 9742da439a
commit 1dca1fe3c9
2 changed files with 117 additions and 12 deletions
@@ -1,12 +1,39 @@
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { getPaymentState } from "@/api/driver"; import { getPaymentState } from "@/api/driver";
const TERMINAL = new Set(["PAID", "CONFIRMED", "REJECTED", "REVERSED", "REFUNDED", "CANCELED", "AUTH_FAIL"]); // финальные статусы: дальше опрашивать нечего, экран показывает итог —
// оплачено, отказ, истёкший срок или ошибка
const TERMINAL = new Set([
"PAID", "CONFIRMED", "REJECTED", "REVERSED", "REFUNDED",
"PARTIAL_REVERSED", "PARTIAL_REFUNDED",
"CANCELED", "AUTH_FAIL", "DEADLINE_EXPIRED", "ATTEMPTS_EXPIRED", "ERROR",
]);
export function usePaymentStatus(orderId: string) { export function usePaymentStatus(orderId: string) {
return useQuery({ const query = useQuery({
queryKey: ["payment", orderId], queryKey: ["payment", orderId],
queryFn: () => getPaymentState(orderId), queryFn: () => getPaymentState(orderId),
refetchInterval: (q) => (q.state.data && TERMINAL.has(q.state.data.status) ? false : 1500), refetchInterval: (q) => (q.state.data && TERMINAL.has(q.state.data.status) ? false : 1500),
// повтором здесь работает сам опрос через 1.5 с, а внутренние ретраи с backoff
// только оттягивают момент, когда сбой станет виден на экране
retry: false,
}); });
// серию неудач считаем сами: failureCount обнуляется в начале каждого опроса
// и о том, сколько раз подряд не ответил бэкенд, ничего не говорит
const [failStreak, setFailStreak] = useState(0);
useEffect(() => {
if (query.errorUpdatedAt) setFailStreak((n) => n + 1);
}, [query.errorUpdatedAt]);
useEffect(() => {
if (query.dataUpdatedAt) setFailStreak(0);
}, [query.dataUpdatedAt]);
// пока ни одного удачного ответа не было, на время запроса isError снова гаснет
// (react-query возвращает статус в pending) — экран бы моргал ожиданием на каждом опросе.
// отметки времени такого провала не дают и гаснут ровно тогда, когда пришли данные
const lastFailed = query.errorUpdatedAt > query.dataUpdatedAt;
return { ...query, failStreak, lastFailed };
} }
+86 -8
View File
@@ -1,41 +1,119 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom"; import { useLocation, useNavigate, useParams } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { Check } from "lucide-react"; import { Check, Clock, Undo2, WifiOff, X } from "lucide-react";
import { formatMoney } from "@/lib/format"; import { formatMoney } from "@/lib/format";
import { Spinner } from "@/components/Spinner"; import { Spinner } from "@/components/Spinner";
import { usePaymentStatus } from "@/hooks/usePaymentStatus"; import { usePaymentStatus } from "@/hooks/usePaymentStatus";
// платёж не состоялся, деньги не ушли — помогает только новая попытка.
// ATTEMPTS_EXPIRED — исчерпаны попытки ввода на форме банка, тот же исход для водителя
const FAILED = new Set(["REJECTED", "AUTH_FAIL", "ERROR", "CANCELED", "ATTEMPTS_EXPIRED"]);
// деньги дошли и вернулись обратно — новая попытка тут ни при чём, человеку нужен другой текст
const RETURNED = new Set(["REVERSED", "REFUNDED", "PARTIAL_REVERSED", "PARTIAL_REFUNDED"]);
// сколько неудач подряд терпим, прежде чем сказать вслух, что связи нет: ~15 секунд опроса.
// одиночный сбой пережидаем молча — следующий опрос обычно всё чинит
const FAIL_LIMIT = 10;
export function PaymentPage() { export function PaymentPage() {
const { orderId = "" } = useParams(); const { orderId = "" } = useParams();
const nav = useNavigate(); const nav = useNavigate();
const qc = useQueryClient(); const qc = useQueryClient();
const st = (useLocation().state as { payUrl?: string; total?: number; bucket?: string; base?: number } | null) ?? {}; const st = (useLocation().state as
const { data } = usePaymentStatus(orderId); { payUrl?: string; total?: number; bucket?: string; base?: number; method?: string } | null) ?? {};
const paid = data?.status === "PAID"; const { data, lastFailed, failStreak, refetch } = usePaymentStatus(orderId);
const status = data?.status ?? "";
const paid = status === "PAID";
// пока метод не передан в state — узнаём по ссылке: СБП отдаёт платёжку qr.nspk.ru, карта — форму банка
const card = st.method ? st.method === "card" : !!st.payUrl && !st.payUrl.includes("qr.nspk.ru");
useEffect(() => { useEffect(() => {
if (paid) qc.invalidateQueries({ queryKey: ["balance"] }); if (paid) qc.invalidateQueries({ queryKey: ["balance"] });
}, [paid, qc]); }, [paid, qc]);
const retry = (
<button className="btn-primary mt-8" onClick={() => nav("/topup", { state: { bucket: st.bucket }, replace: true })}>
Пополнить ещё раз
</button>
);
if (paid) { if (paid) {
return ( return (
<div className="pt-24 flex flex-col items-center text-center"> <div className="pt-24 flex flex-col items-center text-center">
<span className="w-14 h-14 rounded-full bg-pos text-white flex items-center justify-center mb-3"><Check size={28} /></span> <span className="w-14 h-14 rounded-full bg-pos text-white flex items-center justify-center mb-3"><Check size={28} /></span>
<p className="text-lg font-extrabold">Оплачено</p> <p className="text-lg font-extrabold">Оплачено</p>
<p className="text-muted text-xs mt-1">{st.bucket} пополнен на {formatMoney(st.base ?? 0)}</p> <p className="text-muted text-xs mt-1">
{st.base != null ? `${st.bucket ?? "Баланс"} пополнен на ${formatMoney(st.base)}` : "Баланс пополнен"}
</p>
<button className="btn-primary mt-8" onClick={() => nav("/")}>На главную</button> <button className="btn-primary mt-8" onClick={() => nav("/")}>На главную</button>
</div> </div>
); );
} }
// без этих веток человек оставался на «Ожидаем оплату…» навсегда: опрос уже остановлен
if (status === "DEADLINE_EXPIRED") {
return (
<div className="pt-24 flex flex-col items-center text-center">
<span className="w-14 h-14 rounded-full bg-neg text-white flex items-center justify-center mb-3"><Clock size={28} /></span>
<p className="text-lg font-extrabold">Срок оплаты истёк</p>
<p className="text-muted text-xs mt-1">Счёт больше не действует. Деньги не списаны создайте новый.</p>
{retry}
</div>
);
}
if (FAILED.has(status)) {
return (
<div className="pt-24 flex flex-col items-center text-center">
<span className="w-14 h-14 rounded-full bg-neg text-white flex items-center justify-center mb-3"><X size={28} /></span>
<p className="text-lg font-extrabold">Оплата не прошла</p>
<p className="text-muted text-xs mt-1">Банк не провёл перевод. Деньги не списаны попробуйте ещё раз.</p>
{retry}
</div>
);
}
if (RETURNED.has(status)) {
return (
<div className="pt-24 flex flex-col items-center text-center">
<span className="w-14 h-14 rounded-full bg-neg text-white flex items-center justify-center mb-3"><Undo2 size={28} /></span>
<p className="text-lg font-extrabold">Платёж возвращён</p>
<p className="text-muted text-xs mt-1">Деньги вернулись на счёт, с которого платили. Баланс пересчитаем.</p>
<button className="btn-primary mt-8" onClick={() => nav("/")}>На главную</button>
</div>
);
}
// раньше упавший бэкенд выглядел ровно как честное ожидание оплаты — крутился спиннер.
// сам опрос не прекращаем: как только бэкенд ответит, экран вернётся к оплате
if (lastFailed && failStreak >= FAIL_LIMIT) {
return (
<div className="pt-24 flex flex-col items-center text-center">
<span className="w-14 h-14 rounded-full bg-muted text-white flex items-center justify-center mb-3"><WifiOff size={28} /></span>
<p className="text-lg font-extrabold">Не удаётся проверить оплату</p>
<p className="text-muted text-xs mt-1">Похоже, пропала связь. Если вы уже оплатили, деньги дойдут.</p>
<button className="btn-primary mt-8" onClick={() => refetch()}>Проверить ещё раз</button>
<button className="btn-ghost mt-3" onClick={() => nav("/")}>На главную</button>
</div>
);
}
return ( return (
<div className="pt-10 flex flex-col items-center text-center"> <div className="pt-10 flex flex-col items-center text-center">
<span className="w-12 h-12 rounded-xl2 bg-ink text-cream flex items-center justify-center font-extrabold mb-4">СБП</span> <span className="px-4 py-2.5 rounded-xl2 bg-ink text-cream text-sm font-extrabold mb-4">{card ? "Картой" : "СБП"}</span>
<p className="text-lg font-extrabold">{formatMoney(st.total ?? 0)}</p> <div className="mb-6">
<p className="text-muted text-xs mb-6">{st.bucket}</p> {/* после перезагрузки суммы в state нет: лучше не показать ничего, чем показать 0 ₽ */}
{st.total != null && <p className="text-lg font-extrabold">{formatMoney(st.total)}</p>}
{st.bucket && <p className="text-muted text-xs">{st.bucket}</p>}
</div>
{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>
</>
) : (
<p className="text-muted text-xs">Завершите оплату в приложении банка экран обновится сам</p>
)}
<div className="flex items-center gap-2 mt-6 text-muted text-xs"><Spinner /> Ожидаем оплату</div> <div className="flex items-center gap-2 mt-6 text-muted text-xs"><Spinner /> Ожидаем оплату</div>
</div> </div>
); );