diff --git a/driver-pwa/frontend/package-lock.json b/driver-pwa/frontend/package-lock.json index 049b705..a80d33b 100644 --- a/driver-pwa/frontend/package-lock.json +++ b/driver-pwa/frontend/package-lock.json @@ -33,7 +33,9 @@ "typescript": "^5.5.4", "vite": "^5.4.2", "vite-plugin-pwa": "^0.20.5", - "vitest": "^2.0.5" + "vitest": "^2.0.5", + "workbox-core": "^7.4.1", + "workbox-precaching": "^7.4.1" } }, "node_modules/@adobe/css-tools": { diff --git a/driver-pwa/frontend/package.json b/driver-pwa/frontend/package.json index 73a19d7..c06dbf4 100644 --- a/driver-pwa/frontend/package.json +++ b/driver-pwa/frontend/package.json @@ -37,6 +37,8 @@ "typescript": "^5.5.4", "vite": "^5.4.2", "vite-plugin-pwa": "^0.20.5", - "vitest": "^2.0.5" + "vitest": "^2.0.5", + "workbox-core": "^7.4.1", + "workbox-precaching": "^7.4.1" } } diff --git a/driver-pwa/frontend/src/api/driver.ts b/driver-pwa/frontend/src/api/driver.ts index 3187c5c..9babf3f 100644 --- a/driver-pwa/frontend/src/api/driver.ts +++ b/driver-pwa/frontend/src/api/driver.ts @@ -183,3 +183,17 @@ export const getFines = async (status: "all" | "open") => export const getFine = async (id: number) => FineDetailSchema.parse(await api.get(`driver/fines/${id}`).json()); + +export const PushKeySchema = z.object({ key: z.string(), enabled: z.boolean() }); + +export const getPushKey = async () => + PushKeySchema.parse(await api.get("driver/push/key").json()); + +export const subscribePush = async (sub: { endpoint: string; p256dh: string; auth: string }) => + api.post("driver/push/subscribe", { json: sub }).json(); + +export const unsubscribePush = async (endpoint: string) => + api.post("driver/push/unsubscribe", { json: { endpoint } }).json(); + +/** Пробное уведомление себе. Доступно только при просмотре сотрудником. */ +export const sendTestPush = async () => api.post("driver/push/test").json(); diff --git a/driver-pwa/frontend/src/components/NotificationsRow.tsx b/driver-pwa/frontend/src/components/NotificationsRow.tsx new file mode 100644 index 0000000..a9161a2 --- /dev/null +++ b/driver-pwa/frontend/src/components/NotificationsRow.tsx @@ -0,0 +1,62 @@ +import { useEffect, useState } from "react"; +import { Bell, BellOff } from "lucide-react"; +import { toast } from "sonner"; +import { currentState, disable, enable, type PushState } from "@/lib/push"; + +/** + * Включение уведомлений — одной строкой на главном экране. + * + * Не всплывающим окном на входе: разрешение спрашивается один раз за установку, + * и потраченная впустую попытка закрывает канал навсегда. Здесь водитель жмёт + * сам, увидев, ради чего. + */ +export function NotificationsRow() { + const [state, setState] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + let alive = true; + currentState().then((s) => { if (alive) setState(s); }); + return () => { alive = false; }; + }, []); + + if (state === null || state === "unsupported") return null; + + async function toggle() { + setBusy(true); + try { + setState(state === "on" ? await disable() : await enable()); + } catch { + toast.error("Не удалось изменить настройку уведомлений"); + } finally { + setBusy(false); + } + } + + if (state === "needs-install") { + return ( +

+ Чтобы получать уведомления о штрафах и платежах, добавьте приложение на домашний экран. +

+ ); + } + + if (state === "denied") { + return ( +

+ Уведомления запрещены в настройках браузера — включить их можно только там. +

+ ); + } + + return ( + + ); +} diff --git a/driver-pwa/frontend/src/lib/push.test.ts b/driver-pwa/frontend/src/lib/push.test.ts new file mode 100644 index 0000000..f4fe3d0 --- /dev/null +++ b/driver-pwa/frontend/src/lib/push.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("@/api/driver", () => ({ + getPushKey: vi.fn(async () => ({ key: "BIIh_RELLY", enabled: true })), + subscribePush: vi.fn(async () => ({ ok: true })), + unsubscribePush: vi.fn(async () => ({ ok: true })), +})); + +import { currentState, enable, supported } from "./push"; +import * as api from "@/api/driver"; + +const SUB = { + endpoint: "https://fcm.googleapis.com/fcm/send/abc", + toJSON: () => ({ + endpoint: "https://fcm.googleapis.com/fcm/send/abc", + keys: { p256dh: "p256", auth: "auth" }, + }), + unsubscribe: vi.fn(async () => true), +}; + +function stubBrowser(opts: { + permission?: NotificationPermission; + existing?: unknown; + ios?: boolean; + standalone?: boolean; +} = {}) { + const perm = opts.permission ?? "default"; + const registration = { + pushManager: { + getSubscription: vi.fn(async () => opts.existing ?? null), + subscribe: vi.fn(async () => SUB), + }, + }; + vi.stubGlobal("navigator", { + serviceWorker: { + getRegistration: vi.fn(async () => registration), + ready: Promise.resolve(registration), + }, + userAgent: opts.ios ? "iPhone" : "Android Chrome", + standalone: opts.standalone, + }); + const notification = { + permission: perm, + requestPermission: vi.fn(async () => (opts.permission === "denied" ? "denied" : "granted")), + }; + // Notification обязан лежать И в window: supported() проверяет именно его — + // так же, как это делает браузер. + vi.stubGlobal("window", { + PushManager: function () {}, + Notification: notification, + matchMedia: () => ({ matches: !!opts.standalone }), + }); + vi.stubGlobal("Notification", notification); + return registration; +} + +describe("уведомления в приложении", () => { + beforeEach(() => vi.clearAllMocks()); + afterEach(() => vi.unstubAllGlobals()); + + it("iPhone без установки на экран: объясняем, а не молчим", async () => { + vi.stubGlobal("navigator", { userAgent: "iPhone", standalone: false }); + vi.stubGlobal("window", { matchMedia: () => ({ matches: false }) }); + expect(supported()).toBe(false); + expect(await currentState()).toBe("needs-install"); + }); + + it("разрешение отклонено — переспросить нечем", async () => { + stubBrowser({ permission: "denied" }); + expect(await currentState()).toBe("denied"); + }); + + it("подписка уходит на сервер с ключами из браузера", async () => { + stubBrowser(); + expect(await enable()).toBe("on"); + expect(api.subscribePush).toHaveBeenCalledWith({ + endpoint: "https://fcm.googleapis.com/fcm/send/abc", + p256dh: "p256", + auth: "auth", + }); + }); + + it("сервер не настроен — разрешение НЕ спрашиваем", async () => { + // Единственная попытка на установку: потратить её впустую нельзя. + vi.mocked(api.getPushKey).mockResolvedValueOnce({ key: "", enabled: false }); + stubBrowser(); + expect(await enable()).toBe("off"); + expect(Notification.requestPermission).not.toHaveBeenCalled(); + expect(api.subscribePush).not.toHaveBeenCalled(); + }); +}); diff --git a/driver-pwa/frontend/src/lib/push.ts b/driver-pwa/frontend/src/lib/push.ts new file mode 100644 index 0000000..452c32b --- /dev/null +++ b/driver-pwa/frontend/src/lib/push.ts @@ -0,0 +1,87 @@ +import { getPushKey, subscribePush, unsubscribePush } from "@/api/driver"; + +/** + * Включение уведомлений в приложении. + * + * Главное правило экрана вокруг этого кода: разрешение спрашивается ОДИН раз за + * всю жизнь установки. Отказ браузер запоминает, переспросить нельзя — только + * руками в настройках сайта, куда водитель не пойдёт. Поэтому запрос идёт + * строго по нажатию и только когда сервер подтвердил, что уведомления настроены. + */ +export type PushState = + | "unsupported" // браузер не умеет (или iOS без установки на домашний экран) + | "needs-install" // iOS: умеет, но только когда приложение добавлено на экран + | "denied" // разрешение отклонено — переспросить нечем + | "off" // можно включить + | "on"; // подписка есть + +function isStandalone(): boolean { + return window.matchMedia("(display-mode: standalone)").matches || + // iOS до 17 не поддерживает display-mode: standalone в matchMedia + (navigator as { standalone?: boolean }).standalone === true; +} + +function isIos(): boolean { + return /iPad|iPhone|iPod/.test(navigator.userAgent); +} + +export function supported(): boolean { + return "serviceWorker" in navigator && "PushManager" in window && "Notification" in window; +} + +export async function currentState(): Promise { + if (!supported()) return isIos() && !isStandalone() ? "needs-install" : "unsupported"; + if (Notification.permission === "denied") return "denied"; + const reg = await navigator.serviceWorker.getRegistration(); + const sub = await reg?.pushManager.getSubscription(); + return sub ? "on" : "off"; +} + +/** base64url → байты: ключ VAPID браузер принимает только так. + * Возвращаем ArrayBuffer, а не Uint8Array: типы DOM ждут BufferSource. */ +function keyToBytes(base64: string): ArrayBuffer { + const padded = base64.replace(/-/g, "+").replace(/_/g, "/") + + "=".repeat((4 - (base64.length % 4)) % 4); + const raw = atob(padded); + const bytes = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i); + return bytes.buffer; +} + +/** Включить уведомления. Возвращает новое состояние. */ +export async function enable(): Promise { + if (!supported()) return currentState(); + const { key, enabled } = await getPushKey(); + // Ключа нет — сервер уведомления не отправляет. Разрешение не спрашиваем: + // потратить единственную попытку впустую нельзя. + if (!enabled || !key) return "off"; + + const permission = await Notification.requestPermission(); + if (permission !== "granted") return permission === "denied" ? "denied" : "off"; + + const reg = await navigator.serviceWorker.ready; + const sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: keyToBytes(key), + }); + const json = sub.toJSON() as { endpoint?: string; keys?: { p256dh?: string; auth?: string } }; + await subscribePush({ + endpoint: json.endpoint ?? sub.endpoint, + p256dh: json.keys?.p256dh ?? "", + auth: json.keys?.auth ?? "", + }); + return "on"; +} + +/** Выключить: снимаем подписку и в браузере, и на сервере. */ +export async function disable(): Promise { + const reg = await navigator.serviceWorker.getRegistration(); + const sub = await reg?.pushManager.getSubscription(); + if (sub) { + // Сначала сервер: не сумеем достучаться — подписка останется живой, и + // уведомления продолжат приходить, а это лучше «выключил, а они идут». + await unsubscribePush(sub.endpoint); + await sub.unsubscribe(); + } + return "off"; +} diff --git a/driver-pwa/frontend/src/pages/BalancePage.tsx b/driver-pwa/frontend/src/pages/BalancePage.tsx index 6226a0f..e999721 100644 --- a/driver-pwa/frontend/src/pages/BalancePage.tsx +++ b/driver-pwa/frontend/src/pages/BalancePage.tsx @@ -6,6 +6,7 @@ import { getBalance, type StatementKind } from "@/api/driver"; import { formatMoney, isoDaysAgo, isoToday } from "@/lib/format"; import { useAuth } from "@/store/auth"; import { AppHeader } from "@/components/AppHeader"; +import { NotificationsRow } from "@/components/NotificationsRow"; import { Sheet } from "@/components/Sheet"; import { Spinner } from "@/components/Spinner"; import { Statement } from "@/components/Statement"; @@ -109,6 +110,8 @@ export function BalancePage() { + +
{/* Отдельного раздела «Мои пополнения» нет намеренно: пополнения видны в выписке выше по фильтру, а дублирующий пустой экран сбивал с толку. */} diff --git a/driver-pwa/frontend/src/sw.ts b/driver-pwa/frontend/src/sw.ts new file mode 100644 index 0000000..ee606ec --- /dev/null +++ b/driver-pwa/frontend/src/sw.ts @@ -0,0 +1,69 @@ +/// +import { cleanupOutdatedCaches, precacheAndRoute } from "workbox-precaching"; +import { clientsClaim } from "workbox-core"; + +/** + * Сервис-воркер приложения. + * + * Свой, а не сгенерированный: `generateSW` умеет только кэш, а нам нужны + * обработчики `push` и `notificationclick` — уведомления показывает именно + * воркер, страница для этого может быть закрыта. + * + * Кэш остаётся прежним (precache от workbox), поведение обновления тоже: + * `skipWaiting` + `clientsClaim`, как было при registerType: "autoUpdate". + */ +declare const self: ServiceWorkerGlobalScope; + +precacheAndRoute(self.__WB_MANIFEST); +cleanupOutdatedCaches(); +self.skipWaiting(); +clientsClaim(); + +interface PushPayload { + title?: string; + body?: string; + url?: string; + tag?: string; +} + +self.addEventListener("push", (event: PushEvent) => { + let data: PushPayload = {}; + try { + data = event.data ? (event.data.json() as PushPayload) : {}; + } catch { + // Пуш без разбираемой нагрузки: показать что-то всё равно надо — молчание + // выглядит как «уведомления не работают». + data = {}; + } + const title = data.title || "Премиум Водитель"; + event.waitUntil( + self.registration.showNotification(title, { + body: data.body || "", + icon: "/icons/icon-192.png", + badge: "/icons/icon-192.png", + // tag схлопывает однотипные: пять штрафов подряд не завалят шторку. + tag: data.tag, + data: { url: data.url || "/" }, + }), + ); +}); + +self.addEventListener("notificationclick", (event: NotificationEvent) => { + event.notification.close(); + const target = (event.notification.data?.url as string) || "/"; + event.waitUntil( + (async () => { + const all = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); + // Уже открытое окно приложения переиспользуем: второй экземпляр рядом с + // первым сбивает с толку, и водитель теряет то, что было на экране. + for (const client of all) { + if ("focus" in client) { + await client.focus(); + if ("navigate" in client) await client.navigate(target); + return; + } + } + await self.clients.openWindow(target); + })(), + ); +}); diff --git a/driver-pwa/frontend/tsconfig.app.json b/driver-pwa/frontend/tsconfig.app.json index 69ac113..e26cf47 100644 --- a/driver-pwa/frontend/tsconfig.app.json +++ b/driver-pwa/frontend/tsconfig.app.json @@ -1,11 +1,11 @@ { "compilerOptions": { - "target": "ES2021", "useDefineForClassFields": true, "lib": ["ES2021", "DOM", "DOM.Iterable"], + "target": "ES2021", "useDefineForClassFields": true, "lib": ["ES2021", "DOM", "DOM.Iterable", "WebWorker"], "module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - "baseUrl": ".", "paths": { "@/*": ["src/*"] }, "types": ["vitest/globals", "@testing-library/jest-dom"] + "baseUrl": ".", "paths": { "@/*": ["src/*"] }, "types": ["vite-plugin-pwa/client", "vitest/globals", "@testing-library/jest-dom"] }, "include": ["src"] } diff --git a/driver-pwa/frontend/vite.config.ts b/driver-pwa/frontend/vite.config.ts index 3922680..b367a84 100644 --- a/driver-pwa/frontend/vite.config.ts +++ b/driver-pwa/frontend/vite.config.ts @@ -54,7 +54,17 @@ export default defineConfig(({ command, mode }) => { }, plugins: [ react(), - VitePWA({ registerType: "autoUpdate", manifest: false, includeAssets: ["icons/*.png"] }), + // injectManifest, а не generateSW: воркер свой, потому что уведомления + // показывает он (см. src/sw.ts), а сгенерированный умеет только кэш. + VitePWA({ + strategies: "injectManifest", + srcDir: "src", + filename: "sw.ts", + registerType: "autoUpdate", + manifest: false, + includeAssets: ["icons/*.png"], + injectManifest: { globPatterns: ["**/*.{js,css,html,png,svg,webmanifest}"] }, + }), ], resolve: { alias: { "@": path.resolve(__dirname, "src") } }, server: {