Лента уведомлений: колокольчик, экран и честная отметка «прочитано»
Пуш живёт до первого смахивания, сообщение в боте тонет в переписке — и разговор «мне ничего не приходило» упирался в слово против слова. Теперь у водителя есть место, куда посмотреть, а у парка — отметка о прочтении, которая ставится ровно тогда, когда человек открыл ленту или нажал на уведомление. Отмечаем ПОСЛЕ показа списка, а не при загрузке: иначе счётчик гаснет у того, кто мазнул по экрану и ушёл. Время первого прочтения бэкенд не переписывает. Нажатие по пушу: воркер сам отметить не может (токен лежит в хранилище страницы, ему недоступном), поэтому номер уведомления приезжает в пуше, воркер добавляет его к адресу параметром, а страница отмечает и параметр убирает. Колокольчик по умолчанию выключен в общей шапке и включён только на главной: шапка стоит на каждом экране, и незачем ходить за счётчиком с оплаты. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -187,6 +187,29 @@ export const getFines = async (status: "all" | "open") =>
|
||||
export const getFine = async (id: number) =>
|
||||
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 getPushKey = async () =>
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useRef } from "react";
|
||||
|
||||
import { NotificationsBell } from "@/pages/NotificationsPage";
|
||||
|
||||
/** Долгое нажатие, после которого открывается вход для сотрудника. */
|
||||
const LONG_PRESS_MS = 1500;
|
||||
|
||||
export function AppHeader() {
|
||||
/** Колокольчик по умолчанию выключен: он тянет счётчик уведомлений, а шапка
|
||||
* стоит на каждом экране — незачем ходить за ним с оплаты или из штрафа.
|
||||
* Включается там, откуда водитель и правда пойдёт читать: на главной. */
|
||||
export function AppHeader({ bell = false }: { bell?: boolean } = {}) {
|
||||
const nav = useNavigate();
|
||||
const timer = useRef<number | null>(null);
|
||||
|
||||
@@ -20,7 +25,7 @@ export function AppHeader() {
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="mb-4">
|
||||
<header className="mb-4 flex items-center justify-between">
|
||||
<span
|
||||
className="text-sm font-bold select-none"
|
||||
onPointerDown={start}
|
||||
@@ -30,6 +35,7 @@ export function AppHeader() {
|
||||
>
|
||||
Премиум Водитель
|
||||
</span>
|
||||
{bell && <NotificationsBell />}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
// Ответ как у настоящего водителя: статьи 1С, итог отдельным числом.
|
||||
vi.mock("@/api/driver", () => ({
|
||||
// Колокольчик в шапке главного экрана спрашивает счётчик непрочитанного.
|
||||
getNotifications: vi.fn(async () => ({ items: [], unread: 0 })),
|
||||
markNotificationsRead: vi.fn(async () => ({ marked: 0 })),
|
||||
getBalance: vi.fn(async () => ({
|
||||
accounts: [
|
||||
{ bucket: "Долг аренда", balance: -3050, payable: true },
|
||||
|
||||
@@ -50,7 +50,7 @@ export function BalancePage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AppHeader />
|
||||
<AppHeader bell />
|
||||
|
||||
<NotificationsPrompt />
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ vi.mock("@/api/staff", () => ({
|
||||
vi.mock("@/api/driver", () => ({
|
||||
apiErrorText: vi.fn(async (_e: unknown, f: string) => f),
|
||||
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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,8 @@ interface PushPayload {
|
||||
body?: string;
|
||||
url?: string;
|
||||
tag?: string;
|
||||
/** Номер уведомления в журнале — по нему отмечается прочтение. */
|
||||
id?: number;
|
||||
}
|
||||
|
||||
self.addEventListener("push", (event: PushEvent) => {
|
||||
@@ -43,14 +45,19 @@ self.addEventListener("push", (event: PushEvent) => {
|
||||
badge: "/icons/icon-192.png",
|
||||
// tag схлопывает однотипные: пять штрафов подряд не завалят шторку.
|
||||
tag: data.tag,
|
||||
data: { url: data.url || "/" },
|
||||
data: { url: data.url || "/", id: data.id },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (event: NotificationEvent) => {
|
||||
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(
|
||||
(async () => {
|
||||
const all = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||
|
||||
Reference in New Issue
Block a user