Лента уведомлений: колокольчик, экран и честная отметка «прочитано»

Пуш живёт до первого смахивания, сообщение в боте тонет в переписке — и
разговор «мне ничего не приходило» упирался в слово против слова. Теперь у
водителя есть место, куда посмотреть, а у парка — отметка о прочтении, которая
ставится ровно тогда, когда человек открыл ленту или нажал на уведомление.

Отмечаем ПОСЛЕ показа списка, а не при загрузке: иначе счётчик гаснет у того,
кто мазнул по экрану и ушёл. Время первого прочтения бэкенд не переписывает.

Нажатие по пушу: воркер сам отметить не может (токен лежит в хранилище
страницы, ему недоступном), поэтому номер уведомления приезжает в пуше, воркер
добавляет его к адресу параметром, а страница отмечает и параметр убирает.

Колокольчик по умолчанию выключен в общей шапке и включён только на главной:
шапка стоит на каждом экране, и незачем ходить за счётчиком с оплаты.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 23:24:55 +10:00
co-authored by Claude Opus 5
parent 9aa71d6ca7
commit 00006ed51b
9 changed files with 247 additions and 6 deletions
+30 -1
View File
@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { useEffect } from "react";
import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "@/store/auth";
import { useStaff } from "@/store/staff";
import { ImpersonationBar } from "@/components/ImpersonationBar";
@@ -13,6 +14,8 @@ import { DevLoginPage } from "@/pages/DevLoginPage";
import { DebugPage } from "@/pages/DebugPage";
import { MockPayPage } from "@/pages/MockPayPage";
import { FineDetailPage } from "@/pages/FineDetailPage";
import { NotificationsPage } from "@/pages/NotificationsPage";
import { markNotificationsRead } from "@/api/driver";
function RequireAuth({ children }: { children: ReactNode }) {
const token = useAuth((s) => s.token);
@@ -28,12 +31,37 @@ function RequireStaff({ children }: { children: ReactNode }) {
return impersonated || staffToken ? <>{children}</> : <Navigate to="/" replace />;
}
/**
* Нажатие по пушу — единственный честный признак, что уведомление увидели.
* Сам воркер отметить его не может: токен водителя лежит в хранилище страницы,
* ему недоступном. Поэтому воркер добавляет к адресу `?n=<номер>`, а отмечает
* страница — и тут же убирает параметр, чтобы он не остался в истории.
*/
function MarkPushRead() {
const loc = useLocation();
const nav = useNavigate();
useEffect(() => {
const params = new URLSearchParams(loc.search);
const raw = params.get("n");
if (!raw) return;
const id = Number(raw);
if (Number.isFinite(id) && id > 0) void markNotificationsRead([id], "push");
params.delete("n");
const rest = params.toString();
nav(loc.pathname + (rest ? `?${rest}` : ""), { replace: true });
}, [loc.search, loc.pathname, nav]);
return null;
}
export default function App() {
return (
<div className="mx-auto max-w-md min-h-full px-4 pt-3 pb-8">
{/* Плашка живёт над маршрутами: чей экран открыт, видно на каждом из них,
а не только там, где про неё вспомнили. Сама решает, показываться ли. */}
<ImpersonationBar />
<MarkPushRead />
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/dev" element={<DevLoginPage />} />
@@ -44,6 +72,7 @@ export default function App() {
<Route path="/pay/:orderId" element={<RequireAuth><PaymentPage /></RequireAuth>} />
<Route path="/mock-pay/:orderId" element={<RequireAuth><MockPayPage /></RequireAuth>} />
<Route path="/fines/:fineId" element={<RequireAuth><FineDetailPage /></RequireAuth>} />
<Route path="/notifications" element={<RequireAuth><NotificationsPage /></RequireAuth>} />
<Route path="/debug" element={<RequireAuth><RequireStaff><DebugPage /></RequireStaff></RequireAuth>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>