Files
mechanic-pwa/driver-pwa/frontend/src/pages/LoginPage.tsx
T
tremble7681andClaude Opus 5 aff1a45006 feat(pwa): подтверждение входа, копейки, выбор категории долга
- pairing-сессия хранит poll_secret и confirm_code (lib/pairing), опрос ушёл
  на POST: pair_session уезжал в query-параметре и оседал в access-логах, а
  это половина ключа к JWT. Вторая половина в deep-link не уходит;
- на экране ожидания видно число, которое обязан назвать бот. По чужой ссылке
  человек его не увидит — подтверждать нечего, токен не выдаётся;
- онбординг больше не просит вернуться и запросить код: Telegram подтверждает
  номер шарингом контакта, приложение забирает токен опросом;
- суммы с копейками. formatMoney округлял до рубля, и «к оплате 350 ₽»
  расходилось и со списанием банка, и с чеком (комиссия почти всегда даёт
  копейки). Клавиша разделителя, нарисованная на клавиатуре, наконец работает;
- категорию долга выбирает водитель. Хардкод «Долг аренда» отправлял платёж
  в аренду даже тем, у кого её нет; список приходит с сервера и там же
  проверяется;
- ошибки бэкенда показываются словами (минимум СБП, потолок суммы) вместо
  молчаливого «не удалось», статусы истории переведены полностью, возврат
  денег отличим от отказа.

Тесты: 33 зелёных, tsc чист.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:54:04 +10:00

142 lines
5.6 KiB
TypeScript

import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { apiErrorText, authRequest, authVerify, tgSession } from "@/api/driver";
import { digitsOnly, formatPhone } from "@/lib/format";
import { savePairing } from "@/lib/pairing";
import { useAuth } from "@/store/auth";
import { Spinner } from "@/components/Spinner";
export function LoginPage() {
const nav = useNavigate();
const setSession = useAuth((s) => s.setSession);
const [phone, setPhone] = useState(""); // national 10 digits
const [code, setCode] = useState("");
const [step, setStep] = useState<"phone" | "code">("phone");
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; poll_secret: string;
confirm_code: string; deep_link: string | null } | null>(null);
const e164 = "7" + phone;
useEffect(() => {
let alive = true;
tgSession().then((r) => { if (alive) setTg(r); }).catch(() => {});
return () => { alive = false; };
}, []);
function startTg() {
if (!tg) return;
savePairing(tg);
nav("/tg-wait");
}
async function requestCode() {
if (phone.length < 10) return;
setBusy(true);
try {
const r = await authRequest(e164);
if (r.status === "onboarding") {
// Онбординг теперь идёт той же pairing-сессией: бот попросит поделиться
// контактом, Telegram подтвердит номер, приложение заберёт токен опросом.
// Второй раз номер вводить не нужно.
savePairing({ ...r, deep_link: r.deep_links.tg ?? null });
sessionStorage.setItem("pp-onboarding", JSON.stringify({ ...r.deep_links, phone }));
nav("/onboarding");
return;
}
setChannel(r.channel);
setStep("code");
} catch (err: unknown) {
const status = (err as { response?: { status?: number } })?.response?.status;
const fallback =
status === 404 ? "Номер не найден. Обратитесь в парк." :
status === 429 ? "Слишком часто. Попробуйте позже." :
"Не удалось отправить код";
toast.error(await apiErrorText(err, fallback));
} finally {
setBusy(false);
}
}
async function verify() {
if (code.length < 4) return;
setBusy(true);
try {
const r = await authVerify(e164, code);
setSession(r.token, r.driver_id);
nav("/");
} catch (err: unknown) {
toast.error(await apiErrorText(err, "Неверный или просроченный код"));
} finally {
setBusy(false);
}
}
return (
<div className="pt-16">
<h1 className="text-xl font-extrabold mb-1">Премиум Водитель</h1>
<p className="text-muted text-sm mb-8">Вход в приложение</p>
{mode === "choose" ? (
<>
{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>
</>
) : (
<>
{step === "phone" ? (
<>
<input
inputMode="numeric"
placeholder="Номер телефона"
value={phone ? formatPhone(phone) : ""}
onChange={(e) => setPhone(digitsOnly(e.target.value).replace(/^7/, "").slice(0, 10))}
className="card w-full px-4 py-3 mb-3 text-base bg-white"
/>
<button className="btn-primary" disabled={busy || phone.length < 10} onClick={requestCode}>
{busy ? <Spinner /> : "Получить код"}
</button>
<p className="text-muted text-xs mt-3">Код придёт в Telegram или MAX.</p>
</>
) : (
<>
<p className="text-sm mb-2">Код отправлен в {channel === "max" ? "MAX" : "Telegram"}.</p>
<input
inputMode="numeric"
placeholder="Код из сообщения"
value={code}
onChange={(e) => setCode(digitsOnly(e.target.value).slice(0, 6))}
className="card w-full px-4 py-3 mb-3 text-base tracking-widest bg-white"
/>
<button className="btn-primary" disabled={busy || code.length < 4} onClick={verify}>
{busy ? <Spinner /> : "Войти"}
</button>
<button className="btn-ghost mt-2" onClick={() => setStep("phone")}>Изменить номер</button>
</>
)}
</>
)}
</div>
);
}