feat(pwa): экран оплаты честно показывает отказ, таймаут и возврат
Экран успеха рисовался только при PAID. При любом другом финальном статусе опрос останавливался, а человек навсегда оставался на «Ожидаем оплату…» со спиннером — то есть два из трёх тестовых сценариев СБП глазами не проверить. - ветки отказа (в том числе исчерпанных попыток), истёкшего срока и возврата средств: у возврата свой текст, новая попытка там ни при чём; - ошибка запроса больше не выглядит как честное ожидание: после серии неудач подряд экран говорит, что не может проверить оплату, и даёт повторить. Раньше 404, 500 и рестарт бэкенда были неотличимы от «ждём оплату»; - плашка способа оплаты показывала «СБП» и при оплате картой; - после перезагрузки страницы сумма бралась из пустого router state и показывалась как 0 ₽ — лучше не показать ничего, чем показать неправду. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ₽ */}
|
||||||
<a className="btn-primary no-underline" href={st.payUrl} target="_blank" rel="noopener">Оплатить</a>
|
{st.total != null && <p className="text-lg font-extrabold">{formatMoney(st.total)}</p>}
|
||||||
<p className="text-muted text-xs mt-2">Откроется ваше приложение банка</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>
|
||||||
|
<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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user