diff --git a/backend/migrations/078_push_subscriptions.sql b/backend/migrations/078_push_subscriptions.sql new file mode 100644 index 0000000..1d93800 --- /dev/null +++ b/backend/migrations/078_push_subscriptions.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS push_subscriptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + endpoint TEXT NOT NULL UNIQUE, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user ON push_subscriptions(user_id); diff --git a/backend/package-lock.json b/backend/package-lock.json index 9a3aec7..9c1f5f3 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -16,11 +16,13 @@ "@fastify/rate-limit": "^9.1.0", "@fastify/static": "^7.0.4", "@fastify/websocket": "^8.3.1", + "@types/web-push": "^3.6.4", "bcryptjs": "^2.4.3", "fastify": "^4.28.1", "ioredis": "^5.3.2", "nodemailer": "^8.0.2", - "pg": "^8.12.0" + "pg": "^8.12.0", + "web-push": "^3.6.7" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", @@ -720,7 +722,6 @@ "version": "20.19.37", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -748,6 +749,15 @@ "pg-types": "^2.2.0" } }, + "node_modules/@types/web-push": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", + "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -764,6 +774,15 @@ "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", "license": "MIT" }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", @@ -895,6 +914,12 @@ "balanced-match": "^1.0.0" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/cluster-key-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", @@ -1328,6 +1353,15 @@ "node": ">=16.0.0" } }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -1344,6 +1378,19 @@ "node": ">= 0.8" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -1428,6 +1475,27 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/light-my-request": { "version": "5.14.0", "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz", @@ -1490,6 +1558,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -2172,9 +2249,27 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index f56c86e..434bca5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,11 +17,13 @@ "@fastify/rate-limit": "^9.1.0", "@fastify/static": "^7.0.4", "@fastify/websocket": "^8.3.1", + "@types/web-push": "^3.6.4", "bcryptjs": "^2.4.3", "fastify": "^4.28.1", "ioredis": "^5.3.2", "nodemailer": "^8.0.2", - "pg": "^8.12.0" + "pg": "^8.12.0", + "web-push": "^3.6.7" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", diff --git a/backend/src/app.ts b/backend/src/app.ts index b3f0f49..e5d3738 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -34,6 +34,7 @@ import notificationsRoutes from './routes/notifications' import scheduleRoutes from './routes/schedule' import loyaltyRoutes from './routes/loyalty' import chatRoutes from './routes/chat' +import pushRoutes from './routes/push' import workstationRoutes from './routes/workstations' import agentReleaseRoutes from './routes/agent-release' import wifiSettingsRoutes, { wifiAuthVerify } from './routes/wifi-settings' @@ -48,6 +49,7 @@ import publicWidgetRoutes from './routes/publicWidget' import yookassaWebhookRoutes from './routes/yookassaWebhook' import { setupAgentWsRoute } from './agent-ws' import { startJobs } from './jobs' +import { initWebPush } from './push' export async function buildApp() { const fastify = Fastify({ @@ -131,6 +133,7 @@ export async function buildApp() { await fastify.register(scheduleRoutes) await fastify.register(loyaltyRoutes) await fastify.register(chatRoutes) + await fastify.register(pushRoutes) await fastify.register(workstationRoutes) await fastify.register(agentReleaseRoutes) await fastify.register(wifiSettingsRoutes) @@ -147,6 +150,7 @@ export async function buildApp() { await fastify.register(setupAgentWsRoute) startJobs() + initWebPush() return fastify } diff --git a/backend/src/push.ts b/backend/src/push.ts new file mode 100644 index 0000000..b4d63ee --- /dev/null +++ b/backend/src/push.ts @@ -0,0 +1,67 @@ +import webpush from 'web-push' +import { db } from './db' + +export function initWebPush() { + webpush.setVapidDetails( + 'mailto:noreply@hotelsync.ru', + process.env.VAPID_PUBLIC_KEY!, + process.env.VAPID_PRIVATE_KEY!, + ) +} + +export interface PushPayload { + title: string + body: string + url?: string + tag?: string + icon?: string +} + +export async function sendPushToUser(userId: string, payload: PushPayload): Promise { + if (!process.env.VAPID_PUBLIC_KEY || !process.env.VAPID_PRIVATE_KEY) return + try { + const { rows } = await db.query( + 'SELECT endpoint, p256dh, auth FROM push_subscriptions WHERE user_id = $1', + [userId], + ) + for (const sub of rows) { + try { + await webpush.sendNotification( + { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } }, + JSON.stringify(payload), + ) + } catch (err) { + const statusCode = (err as { statusCode?: number }).statusCode + if (statusCode === 410 || statusCode === 404) { + await db.query('DELETE FROM push_subscriptions WHERE endpoint = $1', [sub.endpoint]) + } + } + } + } catch { /**/ } +} + +export async function sendPushToRoom(roomId: string, senderUserId: string, payload: PushPayload): Promise { + if (!process.env.VAPID_PUBLIC_KEY) return + try { + const { rows } = await db.query( + `SELECT DISTINCT ps.user_id, ps.endpoint, ps.p256dh, ps.auth + FROM push_subscriptions ps + JOIN chat_room_members crm ON crm.user_id = ps.user_id + WHERE crm.room_id = $1 AND ps.user_id != $2`, + [roomId, senderUserId], + ) + for (const sub of rows) { + try { + await webpush.sendNotification( + { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } }, + JSON.stringify(payload), + ) + } catch (err) { + const statusCode = (err as { statusCode?: number }).statusCode + if (statusCode === 410 || statusCode === 404) { + await db.query('DELETE FROM push_subscriptions WHERE endpoint = $1', [sub.endpoint]) + } + } + } + } catch { /**/ } +} diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index 349ae76..1cb3fd8 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -1,5 +1,6 @@ import { FastifyPluginAsync } from 'fastify' import { db } from '../db' +import { sendPushToRoom } from '../push' type SlugParam = { Params: { slug: string } } type RoomParam = { Params: { slug: string; roomId: string } } @@ -183,19 +184,39 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => { const hotelId = await getHotelId(slug) if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) - const { rows: roomRows } = await db.query('SELECT type FROM chat_rooms WHERE id = $1', [roomId]) + const { rows: roomRows } = await db.query('SELECT type, name FROM chat_rooms WHERE id = $1', [roomId]) if (roomRows[0]?.type === 'notifications') return reply.code(403).send({ error: 'Cannot post to notifications room' }) + const activeRoom = roomRows[0] as { type: string; name: string | null } | undefined const { text, attachment_url } = request.body if (!text?.trim() && !attachment_url) return reply.code(400).send({ error: 'Text or attachment required' }) + const userId = request.user.sub const { rows } = await db.query( `INSERT INTO chat_messages (room_id, hotel_id, sender_id, text, attachment_url) VALUES ($1, $2, $3, $4, $5) RETURNING id`, - [roomId, hotelId, request.user.sub, text?.trim() ?? '', attachment_url ?? null], + [roomId, hotelId, userId, text?.trim() ?? '', attachment_url ?? null], ) - const msg = await fetchMessage(rows[0].id, request.user.sub) + const msg = await fetchMessage(rows[0].id, userId) + + // Send push notifications to room members (non-blocking, only for direct/group rooms) + if (activeRoom?.type === 'direct' || activeRoom?.type === 'group') { + void (async () => { + try { + const senderName = (msg as { senderName?: string }).senderName ?? 'Сотрудник' + const roomName = activeRoom?.name ?? (activeRoom?.type === 'group' ? 'Групповой чат' : 'Чат') + void sendPushToRoom(roomId, userId, { + title: roomName ?? senderName, + body: `${senderName.split(' ')[0]}: ${(msg as { text?: string }).text?.slice(0, 100) ?? ''}`, + url: `/${slug}`, + tag: roomId, + icon: '/icons/icon-192.png', + }) + } catch { /**/ } + })() + } + return reply.code(201).send(msg) }, ) diff --git a/backend/src/routes/push.ts b/backend/src/routes/push.ts new file mode 100644 index 0000000..cc4f8eb --- /dev/null +++ b/backend/src/routes/push.ts @@ -0,0 +1,39 @@ +import { FastifyPluginAsync } from 'fastify' +import { db } from '../db' + +const pushRoutes: FastifyPluginAsync = async (fastify) => { + // GET VAPID public key + fastify.get('/api/push/vapid-key', async () => { + return { publicKey: process.env.VAPID_PUBLIC_KEY ?? null } + }) + + // Subscribe + fastify.post<{ Body: { endpoint: string; p256dh: string; auth: string } }>( + '/api/push/subscribe', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { endpoint, p256dh, auth } = request.body + if (!endpoint || !p256dh || !auth) return reply.code(400).send({ error: 'Missing fields' }) + await db.query( + `INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth) + VALUES ($1, $2, $3, $4) + ON CONFLICT (endpoint) DO UPDATE SET user_id = $1, p256dh = $3, auth = $4`, + [request.user.sub, endpoint, p256dh, auth], + ) + return { ok: true } + }, + ) + + // Unsubscribe + fastify.delete<{ Body: { endpoint: string } }>( + '/api/push/unsubscribe', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + const { endpoint } = request.body + await db.query('DELETE FROM push_subscriptions WHERE endpoint = $1 AND user_id = $2', [endpoint, request.user.sub]) + return { ok: true } + }, + ) +} + +export default pushRoutes diff --git a/index.html b/index.html index 1074a2e..8555ea5 100644 --- a/index.html +++ b/index.html @@ -7,6 +7,13 @@ HotelSync + + + + + + + diff --git a/package-lock.json b/package-lock.json index aca87a0..59ed990 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@vitejs/plugin-react": "^4.3.1", "autoprefixer": "^10.4.20", "postcss": "^8.4.45", + "sharp": "^0.34.5", "tailwindcss": "^3.4.10", "typescript": "^5.5.3", "vite": "^5.4.1" @@ -324,6 +325,17 @@ "node": ">=6.9.0" } }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -715,6 +727,496 @@ "node": ">=12" } }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1594,6 +2096,16 @@ } } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -2505,6 +3017,64 @@ "semver": "bin/semver.js" } }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2702,6 +3272,14 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index 67cce95..0591071 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@vitejs/plugin-react": "^4.3.1", "autoprefixer": "^10.4.20", "postcss": "^8.4.45", + "sharp": "^0.34.5", "tailwindcss": "^3.4.10", "typescript": "^5.5.3", "vite": "^5.4.1" diff --git a/public/icons/icon-128.png b/public/icons/icon-128.png new file mode 100644 index 0000000..812ae5f Binary files /dev/null and b/public/icons/icon-128.png differ diff --git a/public/icons/icon-144.png b/public/icons/icon-144.png new file mode 100644 index 0000000..bab0247 Binary files /dev/null and b/public/icons/icon-144.png differ diff --git a/public/icons/icon-152.png b/public/icons/icon-152.png new file mode 100644 index 0000000..984447a Binary files /dev/null and b/public/icons/icon-152.png differ diff --git a/public/icons/icon-180.png b/public/icons/icon-180.png new file mode 100644 index 0000000..5e9b25c Binary files /dev/null and b/public/icons/icon-180.png differ diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000..0b3f224 Binary files /dev/null and b/public/icons/icon-192.png differ diff --git a/public/icons/icon-384.png b/public/icons/icon-384.png new file mode 100644 index 0000000..2c21cf3 Binary files /dev/null and b/public/icons/icon-384.png differ diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000..0895f97 Binary files /dev/null and b/public/icons/icon-512.png differ diff --git a/public/icons/icon-72.png b/public/icons/icon-72.png new file mode 100644 index 0000000..4b744d7 Binary files /dev/null and b/public/icons/icon-72.png differ diff --git a/public/icons/icon-96.png b/public/icons/icon-96.png new file mode 100644 index 0000000..9dc3c20 Binary files /dev/null and b/public/icons/icon-96.png differ diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..e010865 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,20 @@ +{ + "name": "HotelSync PMS", + "short_name": "HotelSync", + "description": "Система управления отелем HotelSync", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#4F46E5", + "orientation": "portrait-primary", + "icons": [ + { "src": "/icons/icon-72.png", "sizes": "72x72", "type": "image/png" }, + { "src": "/icons/icon-96.png", "sizes": "96x96", "type": "image/png" }, + { "src": "/icons/icon-128.png", "sizes": "128x128", "type": "image/png" }, + { "src": "/icons/icon-144.png", "sizes": "144x144", "type": "image/png" }, + { "src": "/icons/icon-152.png", "sizes": "152x152", "type": "image/png" }, + { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" }, + { "src": "/icons/icon-384.png", "sizes": "384x384", "type": "image/png" }, + { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..1ee7c5a --- /dev/null +++ b/public/sw.js @@ -0,0 +1,35 @@ +const CACHE_NAME = 'hotelsync-v1' + +self.addEventListener('install', () => self.skipWaiting()) +self.addEventListener('activate', event => event.waitUntil(self.clients.claim())) + +self.addEventListener('push', event => { + if (!event.data) return + let data + try { data = event.data.json() } catch { data = { title: 'HotelSync', body: event.data.text() } } + + const title = data.title ?? 'HotelSync' + const options = { + body: data.body ?? '', + icon: data.icon ?? '/icons/icon-192.png', + badge: '/icons/icon-72.png', + tag: data.tag, + renotify: !!data.tag, + data: { url: data.url ?? '/' }, + vibrate: [200, 100, 200], + } + + event.waitUntil(self.registration.showNotification(title, options)) +}) + +self.addEventListener('notificationclick', event => { + event.notification.close() + const url = event.notification.data?.url ?? '/' + event.waitUntil( + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clients => { + const existing = clients.find(c => c.url.includes(self.location.origin)) + if (existing) { existing.focus(); existing.navigate(url) } + else self.clients.openWindow(url) + }) + ) +}) diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs new file mode 100644 index 0000000..ecb3c90 --- /dev/null +++ b/scripts/generate-icons.mjs @@ -0,0 +1,22 @@ +import sharp from 'sharp' +import { mkdirSync } from 'fs' + +mkdirSync('./public/icons', { recursive: true }) + +const sizes = [72, 96, 128, 144, 152, 180, 192, 384, 512] + +const createSvg = (size) => { + const r = Math.round(size * 0.22) + const fontSize = Math.round(size * 0.36) + return ` + + HS +` +} + +for (const size of sizes) { + const svg = Buffer.from(createSvg(size)) + await sharp(svg).png().toFile(`./public/icons/icon-${size}.png`) + console.log(`✓ icon-${size}.png`) +} +console.log('Done!') diff --git a/src/components/chat/ChatWidget.tsx b/src/components/chat/ChatWidget.tsx index 80c68bd..52318fb 100644 --- a/src/components/chat/ChatWidget.tsx +++ b/src/components/chat/ChatWidget.tsx @@ -7,6 +7,7 @@ import { api, type ChatRoom, type ChatMessage, type ChatSearchResult, type ChatR import type { User } from '../../types' import { useAuth } from '../../contexts/AuthContext' import { cn } from '../../lib/utils' +import { subscribeToPush, isPushSupported } from '../../lib/push' // ── Helpers ──────────────────────────────────────────────────────────────── @@ -788,6 +789,9 @@ export function ChatWidget() { label="Показывать текст в уведомлении" checked={settings.notifShowText !== false} onChange={v => updateSettings({ notifShowText: v })} /> + + +

Правый клик на сообщении — реакции, редактирование, удаление.
@@ -1358,6 +1362,59 @@ function ToggleRow({ icon, label, checked, onChange }: { ) } +// ── PushPermissionRow ───────────────────────────────────────────────────── + +function PushPermissionRow() { + const [permission, setPermission] = useState( + typeof Notification !== 'undefined' ? Notification.permission : 'default' + ) + const [loading, setLoading] = useState(false) + + if (!isPushSupported()) { + return ( +

+ Ваш браузер не поддерживает push-уведомления. + {typeof navigator !== 'undefined' && navigator.userAgent.includes('iPhone') ? ' Добавьте сайт на домашний экран (iOS 16.4+).' : ''} +

+ ) + } + + const enable = async () => { + setLoading(true) + try { + const result = await Notification.requestPermission() + setPermission(result) + if (result === 'granted') { + const { publicKey } = await api.push.getVapidKey() + if (!publicKey) return + const sub = await subscribeToPush(publicKey) + if (!sub) return + 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) } + } + + return ( +
+ + + {permission === 'granted' ? 'Push включены' : permission === 'denied' ? 'Заблокированы в браузере' : 'Push-уведомления'} + + {permission === 'default' && ( + + )} + {permission === 'granted' && } +
+ ) +} + // ── GroupInfoView ────────────────────────────────────────────────────────── function GroupInfoView({ room, allMembers, currentUserId, saving, editName, onEditName, onAvatarClick, onSave }: { diff --git a/src/hooks/usePushSubscription.ts b/src/hooks/usePushSubscription.ts new file mode 100644 index 0000000..60c1921 --- /dev/null +++ b/src/hooks/usePushSubscription.ts @@ -0,0 +1,37 @@ +import { useEffect } from 'react' +import { useAuth } from '../contexts/AuthContext' +import { registerSW, subscribeToPush, isPushSupported } from '../lib/push' +import { api } from '../lib/api' + +export function usePushSubscription() { + const { user } = useAuth() + + useEffect(() => { + if (!user || !isPushSupported()) return + if (Notification.permission === 'denied') return + + const setup = async () => { + const sw = await registerSW() + if (!sw) return + + const { publicKey } = await api.push.getVapidKey() + if (!publicKey) return + + if (Notification.permission === 'default') return // wait for explicit request + + const sub = await subscribeToPush(publicKey) + if (!sub) return + + const json = sub.toJSON() + if (!json.endpoint || !json.keys?.p256dh || !json.keys?.auth) return + + await api.push.subscribe({ + endpoint: json.endpoint, + p256dh: json.keys.p256dh, + auth: json.keys.auth, + }) + } + + setup().catch(() => { /**/ }) + }, [user]) +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 333d153..a363b30 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -897,6 +897,15 @@ export const api = { req<{ ok: boolean }>('DELETE', `/api/hotels/${slug}/payment-gateways/${id}`), }, + // ── Push notifications ──────────────────────────────────────────────────── + push: { + getVapidKey: () => req<{ publicKey: string | null }>('GET', '/api/push/vapid-key'), + subscribe: (sub: { endpoint: string; p256dh: string; auth: string }) => + req<{ ok: boolean }>('POST', '/api/push/subscribe', sub), + unsubscribe: (endpoint: string) => + req<{ ok: boolean }>('DELETE', '/api/push/unsubscribe', { endpoint }), + }, + // Public widget API (no auth) widget: { getConfig: (slug: string) => diff --git a/src/lib/push.ts b/src/lib/push.ts new file mode 100644 index 0000000..ddb07fc --- /dev/null +++ b/src/lib/push.ts @@ -0,0 +1,42 @@ +function urlBase64ToUint8Array(base64String: string): ArrayBuffer { + 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 +} + +export async function registerSW(): Promise { + if (!('serviceWorker' in navigator)) return null + try { + const reg = await navigator.serviceWorker.register('/sw.js', { scope: '/' }) + return reg + } catch { return null } +} + +export async function subscribeToPush(vapidPublicKey: string): Promise { + if (!('PushManager' in window)) return null + try { + const reg = await navigator.serviceWorker.ready + const existing = await reg.pushManager.getSubscription() + if (existing) return existing + return await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), + }) + } catch { return null } +} + +export async function unsubscribeFromPush(): Promise { + if (!('serviceWorker' in navigator)) return false + try { + const reg = await navigator.serviceWorker.ready + const sub = await reg.pushManager.getSubscription() + if (sub) return sub.unsubscribe() + return false + } catch { return false } +} + +export function isPushSupported(): boolean { + return 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window +} diff --git a/src/main.tsx b/src/main.tsx index db032b7..121ccb6 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,9 +2,13 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App' +import { registerSW } from './lib/push' createRoot(document.getElementById('root')!).render( , ) + +// Register service worker for PWA and push notifications +registerSW().catch(() => { /**/ })