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

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

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

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

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

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 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 { useAuth } from "@/store/auth";
import { useStaff } from "@/store/staff"; import { useStaff } from "@/store/staff";
import { ImpersonationBar } from "@/components/ImpersonationBar"; import { ImpersonationBar } from "@/components/ImpersonationBar";
@@ -13,6 +14,8 @@ import { DevLoginPage } from "@/pages/DevLoginPage";
import { DebugPage } from "@/pages/DebugPage"; import { DebugPage } from "@/pages/DebugPage";
import { MockPayPage } from "@/pages/MockPayPage"; import { MockPayPage } from "@/pages/MockPayPage";
import { FineDetailPage } from "@/pages/FineDetailPage"; import { FineDetailPage } from "@/pages/FineDetailPage";
import { NotificationsPage } from "@/pages/NotificationsPage";
import { markNotificationsRead } from "@/api/driver";
function RequireAuth({ children }: { children: ReactNode }) { function RequireAuth({ children }: { children: ReactNode }) {
const token = useAuth((s) => s.token); const token = useAuth((s) => s.token);
@@ -28,12 +31,37 @@ function RequireStaff({ children }: { children: ReactNode }) {
return impersonated || staffToken ? <>{children}</> : <Navigate to="/" replace />; 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() { export default function App() {
return ( return (
<div className="mx-auto max-w-md min-h-full px-4 pt-3 pb-8"> <div className="mx-auto max-w-md min-h-full px-4 pt-3 pb-8">
{/* Плашка живёт над маршрутами: чей экран открыт, видно на каждом из них, {/* Плашка живёт над маршрутами: чей экран открыт, видно на каждом из них,
а не только там, где про неё вспомнили. Сама решает, показываться ли. */} а не только там, где про неё вспомнили. Сама решает, показываться ли. */}
<ImpersonationBar /> <ImpersonationBar />
<MarkPushRead />
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/dev" element={<DevLoginPage />} /> <Route path="/dev" element={<DevLoginPage />} />
@@ -44,6 +72,7 @@ export default function App() {
<Route path="/pay/:orderId" element={<RequireAuth><PaymentPage /></RequireAuth>} /> <Route path="/pay/:orderId" element={<RequireAuth><PaymentPage /></RequireAuth>} />
<Route path="/mock-pay/:orderId" element={<RequireAuth><MockPayPage /></RequireAuth>} /> <Route path="/mock-pay/:orderId" element={<RequireAuth><MockPayPage /></RequireAuth>} />
<Route path="/fines/:fineId" element={<RequireAuth><FineDetailPage /></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="/debug" element={<RequireAuth><RequireStaff><DebugPage /></RequireStaff></RequireAuth>} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
+23
View File
@@ -187,6 +187,29 @@ export const getFines = async (status: "all" | "open") =>
export const getFine = async (id: number) => export const getFine = async (id: number) =>
FineDetailSchema.parse(await api.get(`driver/fines/${id}`).json()); FineDetailSchema.parse(await api.get(`driver/fines/${id}`).json());
export const NotificationSchema = z.object({
id: z.number(),
kind: z.string(),
title: z.string(),
body: z.string(),
url: z.string().nullable().default(null),
created_at: z.string().nullable().default(null),
read_at: z.string().nullable().default(null),
});
export type DriverNotification = z.infer<typeof NotificationSchema>;
export const NotificationsSchema = z.object({
items: z.array(NotificationSchema),
unread: z.number(),
});
export const getNotifications = async () =>
NotificationsSchema.parse(await api.get("driver/notifications").json());
/** Отметить прочитанным. Без списка — «всё»: так отмечает открытие ленты. */
export const markNotificationsRead = async (ids?: number[], source: "app" | "push" = "app") =>
api.post("driver/notifications/read", { json: { ids: ids ?? null, source } }).json();
export const PushKeySchema = z.object({ key: z.string(), enabled: z.boolean() }); export const PushKeySchema = z.object({ key: z.string(), enabled: z.boolean() });
export const getPushKey = async () => export const getPushKey = async () =>
@@ -1,10 +1,15 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useRef } from "react"; import { useRef } from "react";
import { NotificationsBell } from "@/pages/NotificationsPage";
/** Долгое нажатие, после которого открывается вход для сотрудника. */ /** Долгое нажатие, после которого открывается вход для сотрудника. */
const LONG_PRESS_MS = 1500; const LONG_PRESS_MS = 1500;
export function AppHeader() { /** Колокольчик по умолчанию выключен: он тянет счётчик уведомлений, а шапка
* стоит на каждом экране — незачем ходить за ним с оплаты или из штрафа.
* Включается там, откуда водитель и правда пойдёт читать: на главной. */
export function AppHeader({ bell = false }: { bell?: boolean } = {}) {
const nav = useNavigate(); const nav = useNavigate();
const timer = useRef<number | null>(null); const timer = useRef<number | null>(null);
@@ -20,7 +25,7 @@ export function AppHeader() {
}; };
return ( return (
<header className="mb-4"> <header className="mb-4 flex items-center justify-between">
<span <span
className="text-sm font-bold select-none" className="text-sm font-bold select-none"
onPointerDown={start} onPointerDown={start}
@@ -30,6 +35,7 @@ export function AppHeader() {
> >
Премиум Водитель Премиум Водитель
</span> </span>
{bell && <NotificationsBell />}
</header> </header>
); );
} }
@@ -6,6 +6,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// Ответ как у настоящего водителя: статьи 1С, итог отдельным числом. // Ответ как у настоящего водителя: статьи 1С, итог отдельным числом.
vi.mock("@/api/driver", () => ({ vi.mock("@/api/driver", () => ({
// Колокольчик в шапке главного экрана спрашивает счётчик непрочитанного.
getNotifications: vi.fn(async () => ({ items: [], unread: 0 })),
markNotificationsRead: vi.fn(async () => ({ marked: 0 })),
getBalance: vi.fn(async () => ({ getBalance: vi.fn(async () => ({
accounts: [ accounts: [
{ bucket: "Долг аренда", balance: -3050, payable: true }, { bucket: "Долг аренда", balance: -3050, payable: true },
@@ -50,7 +50,7 @@ export function BalancePage() {
return ( return (
<div> <div>
<AppHeader /> <AppHeader bell />
<NotificationsPrompt /> <NotificationsPrompt />
@@ -11,6 +11,9 @@ vi.mock("@/api/staff", () => ({
vi.mock("@/api/driver", () => ({ vi.mock("@/api/driver", () => ({
apiErrorText: vi.fn(async (_e: unknown, f: string) => f), apiErrorText: vi.fn(async (_e: unknown, f: string) => f),
getBalance: vi.fn(async () => ({ accounts: [], pending_topup: 0 })), getBalance: vi.fn(async () => ({ accounts: [], pending_topup: 0 })),
// Тесты доходят до главного экрана, а у него в шапке колокольчик.
getNotifications: vi.fn(async () => ({ items: [], unread: 0 })),
markNotificationsRead: vi.fn(async () => ({ marked: 0 })),
})); }));
import App from "@/App"; import App from "@/App";
@@ -0,0 +1,48 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
vi.mock("@/api/driver", () => ({
getNotifications: vi.fn(async () => ({
items: [
{ id: 2, kind: "fine", title: "Новый штраф", body: "Постановление от 18.08.2026.",
url: "/fines/12", created_at: "2026-08-18T10:15:00+10:00", read_at: null },
{ id: 1, kind: "topup", title: "Платёж получен", body: "Пополнение принято.",
url: "/", created_at: "2026-08-17T09:00:00+10:00", read_at: "2026-08-17T09:05:00+10:00" },
],
unread: 1,
})),
markNotificationsRead: vi.fn(async () => ({ marked: 1 })),
}));
import { NotificationsPage } from "./NotificationsPage";
import * as api from "@/api/driver";
const show = () => render(<MemoryRouter><NotificationsPage /></MemoryRouter>);
describe("лента уведомлений", () => {
beforeEach(() => vi.clearAllMocks());
it("показывает, что мы писали водителю", async () => {
show();
expect(await screen.findByText("Новый штраф")).toBeInTheDocument();
expect(screen.getByText("Платёж получен")).toBeInTheDocument();
// Дата человеку — ДД.ММ.ГГГГ, не ISO (текст постановления содержит её же).
expect(screen.getByText("18.08.2026 10:15")).toBeInTheDocument();
});
it("открытие ленты отмечает непрочитанное", async () => {
show();
await screen.findByText("Новый штраф");
// Это единственный честный источник отметки: пуш живёт до смахивания, а
// сообщение в боте тонет в переписке — там прочтения не видно вовсе.
expect(api.markNotificationsRead).toHaveBeenCalled();
});
it("без непрочитанного лишний запрос не делаем", async () => {
vi.mocked(api.getNotifications).mockResolvedValueOnce({ items: [], unread: 0 });
show();
expect(await screen.findByText(/Пока ничего не приходило/)).toBeInTheDocument();
expect(api.markNotificationsRead).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,122 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Bell } from "lucide-react";
import { getNotifications, markNotificationsRead, type DriverNotification } from "@/api/driver";
import { AppHeader } from "@/components/AppHeader";
import { Spinner } from "@/components/Spinner";
import { OperationIcon } from "@/components/operationIcon";
// Значок берём тем же подбором, что и в выписке: событие приложения переводим
// в тег 1С, чтобы штраф в ленте выглядел так же, как штраф в списке операций.
const ICON_BY_KIND: Record<string, string> = {
fine: "Штраф ГИБДД",
topup: "Пополнение баланса",
};
/**
* Лента уведомлений водителя.
*
* Нужна не только водителю. Пуш живёт до первого смахивания, сообщение в боте
* тонет в переписке — и разговор «мне ничего не приходило» упирался в слово
* против слова. Здесь у водителя есть место, куда посмотреть, а у парка —
* честная отметка «прочитано»: она ставится ровно тогда, когда человек открыл
* этот экран или нажал на само уведомление, и ниоткуда больше.
*
* Отмечаем прочитанным ПОСЛЕ показа, а не при загрузке: иначе счётчик гаснет у
* того, кто просто мазнул по экрану и ушёл.
*/
export function NotificationsPage() {
const nav = useNavigate();
const [items, setItems] = useState<DriverNotification[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let alive = true;
getNotifications()
.then((r) => {
if (!alive) return;
setItems(r.items);
if (r.unread > 0) void markNotificationsRead();
})
.catch(() => alive && setError("Не удалось загрузить уведомления"));
return () => {
alive = false;
};
}, []);
return (
<div className="p-4">
<AppHeader />
<h1 className="text-lg font-bold mb-3">Уведомления</h1>
{error && <div className="text-sm text-muted">{error}</div>}
{!items && !error && <Spinner />}
{items && items.length === 0 && (
<div className="text-sm text-muted">
Пока ничего не приходило. Здесь будут появляться сообщения о штрафах и платежах.
</div>
)}
<div className="flex flex-col gap-2">
{items?.map((n) => (
<button
key={n.id}
type="button"
className="flex gap-3 items-start text-left bg-surface border border-line rounded-xl p-3"
onClick={() => n.url && nav(n.url)}
>
<OperationIcon title={ICON_BY_KIND[n.kind] ?? ""} />
<span className="flex-1 min-w-0">
<span className="flex items-baseline gap-2">
<span className="font-semibold text-sm">{n.title}</span>
{!n.read_at && <span className="w-2 h-2 rounded-full bg-accent shrink-0" />}
</span>
<span className="block text-sm text-muted mt-0.5">{n.body}</span>
<span className="block text-xs text-muted mt-1">{when(n.created_at)}</span>
</span>
</button>
))}
</div>
</div>
);
}
/** Дата человеку — ДД.ММ.ГГГГ и время, без ISO. */
function when(ts: string | null): string {
if (!ts) return "";
const d = new Date(ts);
const p = (n: number) => String(n).padStart(2, "0");
return `${p(d.getDate())}.${p(d.getMonth() + 1)}.${d.getFullYear()} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
/** Колокольчик со счётчиком непрочитанного — для шапки главного экрана. */
export function NotificationsBell() {
const nav = useNavigate();
const [unread, setUnread] = useState(0);
useEffect(() => {
let alive = true;
getNotifications()
.then((r) => alive && setUnread(r.unread))
// Молча: колокольчик — не тот элемент, ради которого стоит показывать ошибку.
.catch(() => undefined);
return () => {
alive = false;
};
}, []);
return (
<button type="button" className="relative p-1 -m-1" aria-label="Уведомления"
onClick={() => nav("/notifications")}>
<Bell size={22} strokeWidth={1.5} />
{unread > 0 && (
<span className="absolute -top-0.5 -right-0.5 min-w-4 h-4 px-1 rounded-full bg-accent
text-white text-[10px] leading-4 text-center">
{unread > 9 ? "9+" : unread}
</span>
)}
</button>
);
}
+9 -2
View File
@@ -24,6 +24,8 @@ interface PushPayload {
body?: string; body?: string;
url?: string; url?: string;
tag?: string; tag?: string;
/** Номер уведомления в журнале — по нему отмечается прочтение. */
id?: number;
} }
self.addEventListener("push", (event: PushEvent) => { self.addEventListener("push", (event: PushEvent) => {
@@ -43,14 +45,19 @@ self.addEventListener("push", (event: PushEvent) => {
badge: "/icons/icon-192.png", badge: "/icons/icon-192.png",
// tag схлопывает однотипные: пять штрафов подряд не завалят шторку. // tag схлопывает однотипные: пять штрафов подряд не завалят шторку.
tag: data.tag, tag: data.tag,
data: { url: data.url || "/" }, data: { url: data.url || "/", id: data.id },
}), }),
); );
}); });
self.addEventListener("notificationclick", (event: NotificationEvent) => { self.addEventListener("notificationclick", (event: NotificationEvent) => {
event.notification.close(); event.notification.close();
const target = (event.notification.data?.url as string) || "/"; const url = (event.notification.data?.url as string) || "/";
const id = event.notification.data?.id as number | undefined;
// Нажатие — единственный честный признак, что уведомление увидели. Отметить
// его прямо отсюда нельзя: токен водителя лежит в хранилище страницы, а
// воркеру оно недоступно. Поэтому номер едет параметром, а отмечает страница.
const target = id ? `${url}${url.includes("?") ? "&" : "?"}n=${id}` : url;
event.waitUntil( event.waitUntil(
(async () => { (async () => {
const all = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); const all = await self.clients.matchAll({ type: "window", includeUncontrolled: true });