From e6d84e6fd7d400dc8d1b847fb587498c9ec29301 Mon Sep 17 00:00:00 2001 From: HotelSync Date: Wed, 15 Apr 2026 12:24:18 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20push=20notifications=20=E2=80=94=20urlBa?= =?UTF-8?q?se64ToUint8Array=20bug,=20add=20PushBanner=20prompt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/PushBanner.tsx | 73 +++++++++++++++++++++++++++++++++++ src/layouts/AppLayout.tsx | 2 + src/lib/push.ts | 7 ++-- 3 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 src/components/PushBanner.tsx diff --git a/src/components/PushBanner.tsx b/src/components/PushBanner.tsx new file mode 100644 index 0000000..03ad80a --- /dev/null +++ b/src/components/PushBanner.tsx @@ -0,0 +1,73 @@ +import { useState, useEffect } from 'react' +import { Bell, X } from 'lucide-react' +import { isPushSupported, subscribeToPush } from '../lib/push' +import { api } from '../lib/api' + +const DISMISSED_KEY = 'hotelsync-push-banner-dismissed' + +export function PushBanner() { + const [visible, setVisible] = useState(false) + const [loading, setLoading] = useState(false) + + useEffect(() => { + if (!isPushSupported()) return + if (typeof Notification === 'undefined') return + if (Notification.permission !== 'default') return + if (localStorage.getItem(DISMISSED_KEY)) return + // Show after 3s so the UI settles + const t = setTimeout(() => setVisible(true), 3000) + return () => clearTimeout(t) + }, []) + + if (!visible) return null + + const dismiss = () => { + setVisible(false) + localStorage.setItem(DISMISSED_KEY, '1') + } + + const enable = async () => { + setLoading(true) + try { + const result = await Notification.requestPermission() + if (result === 'granted') { + const { publicKey } = await api.push.getVapidKey() + if (publicKey) { + const sub = await subscribeToPush(publicKey) + if (sub) { + const json = sub.toJSON() + if (json.endpoint && json.keys?.p256dh && json.keys?.auth) { + await api.push.subscribe({ endpoint: json.endpoint, p256dh: json.keys.p256dh, auth: json.keys.auth }) + } + } + } + } + } catch { /**/ } + finally { + setLoading(false) + dismiss() + } + } + + return ( +
+
+
+ +
+

+ Включите уведомления, чтобы не пропускать сообщения +

+
+ + +
+
+
+ ) +} diff --git a/src/layouts/AppLayout.tsx b/src/layouts/AppLayout.tsx index 1a630b0..c805987 100644 --- a/src/layouts/AppLayout.tsx +++ b/src/layouts/AppLayout.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from 'react' import { Sidebar } from '../components/layout/Sidebar' import { Topbar } from '../components/layout/Topbar' import { ChatWidget } from '../components/chat/ChatWidget' +import { PushBanner } from '../components/PushBanner' import { useAuth } from '../contexts/AuthContext' export function AppLayout() { @@ -34,6 +35,7 @@ export function AppLayout() { {!chatHidden && } + ) } diff --git a/src/lib/push.ts b/src/lib/push.ts index ddb07fc..21c4785 100644 --- a/src/lib/push.ts +++ b/src/lib/push.ts @@ -1,9 +1,8 @@ -function urlBase64ToUint8Array(base64String: string): ArrayBuffer { +function urlBase64ToUint8Array(base64String: string): Uint8Array { const padding = '='.repeat((4 - (base64String.length % 4)) % 4) const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/') const rawData = window.atob(base64) - const arr = new Uint8Array([...rawData].map(char => char.charCodeAt(0))) - return arr.buffer as ArrayBuffer + return new Uint8Array([...rawData].map(char => char.charCodeAt(0))) } export async function registerSW(): Promise { @@ -22,7 +21,7 @@ export async function subscribeToPush(vapidPublicKey: string): Promise