Compare commits

..
3 Commits
Author SHA1 Message Date
tremble7681andClaude Opus 5 1dca1fe3c9 feat(pwa): экран оплаты честно показывает отказ, таймаут и возврат
Экран успеха рисовался только при PAID. При любом другом финальном статусе
опрос останавливался, а человек навсегда оставался на «Ожидаем оплату…» со
спиннером — то есть два из трёх тестовых сценариев СБП глазами не проверить.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 18:12:13 +10:00
tremble7681 9742da439a feat(pwa): balance hero shows net «Баланс» (was «Общий долг» of negatives) 2026-06-20 00:59:53 +10:00
tremble7681andclaude-flow d2cd7632a5 fix(pwa): keep PWA alive during TG login (anchor opens Telegram, app polls on /tg-wait)
window.location.href=deep_link navigated the whole PWA to t.me, destroying the
/tg-wait polling screen -> the authenticated token was never fetched. Prefetch
the pairing session and open Telegram via an <a href> (OS app-switch, PWA stays
mounted polling); add an Open-Telegram fallback link on the wait screen.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-19 23:44:57 +10:00
5 changed files with 170 additions and 33 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 };
} }
@@ -13,14 +13,14 @@ export function BalancePage() {
const { data, isLoading, isError, refetch } = useQuery({ queryKey: ["balance"], queryFn: getBalance }); const { data, isLoading, isError, refetch } = useQuery({ queryKey: ["balance"], queryFn: getBalance });
const accounts = data?.accounts ?? []; const accounts = data?.accounts ?? [];
const totalDebt = accounts.filter((a) => a.balance < 0).reduce((s, a) => s + a.balance, 0); const total = accounts.reduce((s, a) => s + a.balance, 0);
return ( return (
<div> <div>
<AppHeader /> <AppHeader />
<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 ${totalDebt < 0 ? "text-neg" : ""}`}>{formatMoney(totalDebt)}</p> <p className={`text-2xl font-extrabold ${total < 0 ? "text-neg" : "text-pos"}`}>{formatMoney(total)}</p>
</div> </div>
{isLoading ? ( {isLoading ? (
+31 -15
View File
@@ -1,4 +1,4 @@
import { 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 { authRequest, authVerify, tgSession } from "@/api/driver";
@@ -15,19 +15,23 @@ export function LoginPage() {
const [channel, setChannel] = useState<string>(""); const [channel, setChannel] = useState<string>("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [mode, setMode] = useState<"choose" | "code">("choose"); const [mode, setMode] = useState<"choose" | "code">("choose");
// Prefetched pairing session so the «Войти через Telegram» control is a real
// <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.
const [tg, setTg] = useState<{ pair_session: string; deep_link: string | null } | null>(null);
const e164 = "7" + phone; const e164 = "7" + phone;
async function loginViaTelegram() { useEffect(() => {
setBusy(true); let alive = true;
try { tgSession().then((r) => { if (alive) setTg(r); }).catch(() => {});
const r = await tgSession(); return () => { alive = false; };
sessionStorage.setItem("pp-pair-session", r.pair_session); }, []);
if (r.deep_link) { window.location.href = r.deep_link; }
nav("/tg-wait"); function startTg() {
} catch { if (!tg) return;
toast.error("Не удалось начать вход. Попробуйте «по коду»."); sessionStorage.setItem("pp-pair-session", tg.pair_session);
setBusy(false); if (tg.deep_link) sessionStorage.setItem("pp-pair-deeplink", tg.deep_link);
} nav("/tg-wait");
} }
async function requestCode() { async function requestCode() {
@@ -75,9 +79,21 @@ export function LoginPage() {
{mode === "choose" ? ( {mode === "choose" ? (
<> <>
<button className="btn-primary" disabled={busy} onClick={loginViaTelegram}> {tg?.deep_link ? (
{busy ? <Spinner /> : "Войти через Telegram"} <a
</button> href={tg.deep_link}
target="_blank"
rel="noopener noreferrer"
className="btn-primary block text-center"
onClick={startTg}
>
Войти через Telegram
</a>
) : (
<button className="btn-primary" disabled>
<Spinner />
</button>
)}
<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>
</> </>
+88 -10
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 ₽ */}
<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>
); );
+19 -3
View File
@@ -6,6 +6,7 @@ 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 pairSession = sessionStorage.getItem("pp-pair-session");
const deepLink = sessionStorage.getItem("pp-pair-deeplink");
const { status } = usePairPoll(pairSession); const { status } = usePairPoll(pairSession);
useEffect(() => { useEffect(() => {
@@ -13,14 +14,29 @@ export function TgWaitPage() {
}, [pairSession, nav]); }, [pairSession, nav]);
useEffect(() => { useEffect(() => {
if (status === "authenticated") { sessionStorage.removeItem("pp-pair-session"); nav("/", { replace: true }); } if (status === "authenticated") {
if (status === "expired") { sessionStorage.removeItem("pp-pair-session"); nav("/login", { replace: true, state: { expired: true } }); } sessionStorage.removeItem("pp-pair-session");
sessionStorage.removeItem("pp-pair-deeplink");
nav("/", { replace: true });
}
if (status === "expired") {
sessionStorage.removeItem("pp-pair-session");
sessionStorage.removeItem("pp-pair-deeplink");
nav("/login", { replace: true, state: { expired: true } });
}
}, [status, nav]); }, [status, nav]);
return ( return (
<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-8">Подтвердите номер в Telegram нажмите «Поделиться номером», затем вернитесь сюда.</p> <p className="text-muted text-sm mb-6">
В Telegram нажмите «Поделиться номером для входа», затем вернитесь сюда вход произойдёт автоматически.
</p>
{deepLink && (
<a href={deepLink} target="_blank" rel="noopener noreferrer" className="btn-primary block text-center mb-6">
Открыть Telegram
</a>
)}
<Spinner /> <Spinner />
</div> </div>
); );