diff --git a/driver-pwa/frontend/src/hooks/usePaymentStatus.ts b/driver-pwa/frontend/src/hooks/usePaymentStatus.ts index 660633a..12582d4 100644 --- a/driver-pwa/frontend/src/hooks/usePaymentStatus.ts +++ b/driver-pwa/frontend/src/hooks/usePaymentStatus.ts @@ -1,12 +1,39 @@ +import { useEffect, useState } from "react"; import { useQuery } from "@tanstack/react-query"; 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) { - return useQuery({ + const query = useQuery({ queryKey: ["payment", orderId], queryFn: () => getPaymentState(orderId), 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 }; } diff --git a/driver-pwa/frontend/src/pages/PaymentPage.tsx b/driver-pwa/frontend/src/pages/PaymentPage.tsx index 542c322..e59acbb 100644 --- a/driver-pwa/frontend/src/pages/PaymentPage.tsx +++ b/driver-pwa/frontend/src/pages/PaymentPage.tsx @@ -1,41 +1,119 @@ import { useEffect } from "react"; import { useLocation, useNavigate, useParams } from "react-router-dom"; 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 { Spinner } from "@/components/Spinner"; 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() { const { orderId = "" } = useParams(); const nav = useNavigate(); const qc = useQueryClient(); - const st = (useLocation().state as { payUrl?: string; total?: number; bucket?: string; base?: number } | null) ?? {}; - const { data } = usePaymentStatus(orderId); - const paid = data?.status === "PAID"; + const st = (useLocation().state as + { payUrl?: string; total?: number; bucket?: string; base?: number; method?: string } | null) ?? {}; + 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(() => { if (paid) qc.invalidateQueries({ queryKey: ["balance"] }); }, [paid, qc]); + const retry = ( + + ); + if (paid) { return (

Оплачено

-

{st.bucket} пополнен на {formatMoney(st.base ?? 0)}

+

+ {st.base != null ? `${st.bucket ?? "Баланс"} пополнен на ${formatMoney(st.base)}` : "Баланс пополнен"} +

); } + // без этих веток человек оставался на «Ожидаем оплату…» навсегда: опрос уже остановлен + if (status === "DEADLINE_EXPIRED") { + return ( +
+ +

Срок оплаты истёк

+

Счёт больше не действует. Деньги не списаны — создайте новый.

+ {retry} +
+ ); + } + + if (FAILED.has(status)) { + return ( +
+ +

Оплата не прошла

+

Банк не провёл перевод. Деньги не списаны — попробуйте ещё раз.

+ {retry} +
+ ); + } + + if (RETURNED.has(status)) { + return ( +
+ +

Платёж возвращён

+

Деньги вернулись на счёт, с которого платили. Баланс пересчитаем.

+ +
+ ); + } + + // раньше упавший бэкенд выглядел ровно как честное ожидание оплаты — крутился спиннер. + // сам опрос не прекращаем: как только бэкенд ответит, экран вернётся к оплате + if (lastFailed && failStreak >= FAIL_LIMIT) { + return ( +
+ +

Не удаётся проверить оплату

+

Похоже, пропала связь. Если вы уже оплатили, деньги дойдут.

+ + +
+ ); + } + return (
- СБП -

{formatMoney(st.total ?? 0)}

-

{st.bucket}

- Оплатить -

Откроется ваше приложение банка

+ {card ? "Картой" : "СБП"} +
+ {/* после перезагрузки суммы в state нет: лучше не показать ничего, чем показать 0 ₽ */} + {st.total != null &&

{formatMoney(st.total)}

} + {st.bucket &&

{st.bucket}

} +
+ {st.payUrl ? ( + <> + Оплатить +

Откроется ваше приложение банка

+ + ) : ( +

Завершите оплату в приложении банка — экран обновится сам

+ )}
Ожидаем оплату…
);