Compare commits
3
Commits
5ab4e9e74e
...
1dca1fe3c9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1dca1fe3c9 | ||
|
|
9742da439a | ||
|
|
d2cd7632a5 |
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -13,14 +13,14 @@ export function BalancePage() {
|
||||
const { data, isLoading, isError, refetch } = useQuery({ queryKey: ["balance"], queryFn: getBalance });
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<AppHeader />
|
||||
<div className="card p-4 mb-4 text-center">
|
||||
<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-muted text-xs mb-1">Баланс</p>
|
||||
<p className={`text-2xl font-extrabold ${total < 0 ? "text-neg" : "text-pos"}`}>{formatMoney(total)}</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { authRequest, authVerify, tgSession } from "@/api/driver";
|
||||
@@ -15,19 +15,23 @@ export function LoginPage() {
|
||||
const [channel, setChannel] = useState<string>("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
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;
|
||||
|
||||
async function loginViaTelegram() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await tgSession();
|
||||
sessionStorage.setItem("pp-pair-session", r.pair_session);
|
||||
if (r.deep_link) { window.location.href = r.deep_link; }
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
tgSession().then((r) => { if (alive) setTg(r); }).catch(() => {});
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
function startTg() {
|
||||
if (!tg) return;
|
||||
sessionStorage.setItem("pp-pair-session", tg.pair_session);
|
||||
if (tg.deep_link) sessionStorage.setItem("pp-pair-deeplink", tg.deep_link);
|
||||
nav("/tg-wait");
|
||||
} catch {
|
||||
toast.error("Не удалось начать вход. Попробуйте «по коду».");
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestCode() {
|
||||
@@ -75,9 +79,21 @@ export function LoginPage() {
|
||||
|
||||
{mode === "choose" ? (
|
||||
<>
|
||||
<button className="btn-primary" disabled={busy} onClick={loginViaTelegram}>
|
||||
{busy ? <Spinner /> : "Войти через Telegram"}
|
||||
{tg?.deep_link ? (
|
||||
<a
|
||||
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>
|
||||
<button className="btn-ghost mt-4" onClick={() => setMode("code")}>Войти по коду</button>
|
||||
</>
|
||||
|
||||
@@ -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 = (
|
||||
<button className="btn-primary mt-8" onClick={() => nav("/topup", { state: { bucket: st.bucket }, replace: true })}>
|
||||
Пополнить ещё раз
|
||||
</button>
|
||||
);
|
||||
|
||||
if (paid) {
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
</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 (
|
||||
<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>
|
||||
<p className="text-lg font-extrabold">{formatMoney(st.total ?? 0)}</p>
|
||||
<p className="text-muted text-xs mb-6">{st.bucket}</p>
|
||||
<span className="px-4 py-2.5 rounded-xl2 bg-ink text-cream text-sm font-extrabold mb-4">{card ? "Картой" : "СБП"}</span>
|
||||
<div className="mb-6">
|
||||
{/* после перезагрузки суммы в 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>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Spinner } from "@/components/Spinner";
|
||||
export function TgWaitPage() {
|
||||
const nav = useNavigate();
|
||||
const pairSession = sessionStorage.getItem("pp-pair-session");
|
||||
const deepLink = sessionStorage.getItem("pp-pair-deeplink");
|
||||
const { status } = usePairPoll(pairSession);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -13,14 +14,29 @@ export function TgWaitPage() {
|
||||
}, [pairSession, nav]);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "authenticated") { sessionStorage.removeItem("pp-pair-session"); nav("/", { replace: true }); }
|
||||
if (status === "expired") { sessionStorage.removeItem("pp-pair-session"); nav("/login", { replace: true, state: { expired: true } }); }
|
||||
if (status === "authenticated") {
|
||||
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]);
|
||||
|
||||
return (
|
||||
<div className="pt-16 text-center">
|
||||
<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 />
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user