feat: PWA push notifications — service worker, VAPID, subscription management
- Add web-push VAPID backend with push_subscriptions table (migration 078) - Backend push module with sendPushToUser/sendPushToRoom helpers - Push routes: GET vapid-key, POST subscribe, DELETE unsubscribe - Trigger push on new chat messages in direct/group rooms - PWA manifest, service worker, and icons (72–512px) - Frontend push lib: SW registration, subscribe/unsubscribe helpers - Push API in api.ts, SW registered in main.tsx - usePushSubscription hook for auto-subscribe on permission grant - PushPermissionRow component in ChatWidget settings view Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
9
backend/migrations/078_push_subscriptions.sql
Normal file
@@ -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);
|
||||||
101
backend/package-lock.json
generated
@@ -16,11 +16,13 @@
|
|||||||
"@fastify/rate-limit": "^9.1.0",
|
"@fastify/rate-limit": "^9.1.0",
|
||||||
"@fastify/static": "^7.0.4",
|
"@fastify/static": "^7.0.4",
|
||||||
"@fastify/websocket": "^8.3.1",
|
"@fastify/websocket": "^8.3.1",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"nodemailer": "^8.0.2",
|
"nodemailer": "^8.0.2",
|
||||||
"pg": "^8.12.0"
|
"pg": "^8.12.0",
|
||||||
|
"web-push": "^3.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
@@ -720,7 +722,6 @@
|
|||||||
"version": "20.19.37",
|
"version": "20.19.37",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
|
||||||
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
|
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
@@ -748,6 +749,15 @@
|
|||||||
"pg-types": "^2.2.0"
|
"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": {
|
"node_modules/@types/ws": {
|
||||||
"version": "8.18.1",
|
"version": "8.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
@@ -764,6 +774,15 @@
|
|||||||
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
|
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/ajv": {
|
||||||
"version": "8.18.0",
|
"version": "8.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||||
@@ -895,6 +914,12 @@
|
|||||||
"balanced-match": "^1.0.0"
|
"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": {
|
"node_modules/cluster-key-slot": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
||||||
@@ -1328,6 +1353,15 @@
|
|||||||
"node": ">=16.0.0"
|
"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": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||||
@@ -1344,6 +1378,19 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
@@ -1428,6 +1475,27 @@
|
|||||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/light-my-request": {
|
||||||
"version": "5.14.0",
|
"version": "5.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz",
|
"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"
|
"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": {
|
"node_modules/minipass": {
|
||||||
"version": "7.1.3",
|
"version": "7.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||||
@@ -2172,9 +2249,27 @@
|
|||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
@@ -17,11 +17,13 @@
|
|||||||
"@fastify/rate-limit": "^9.1.0",
|
"@fastify/rate-limit": "^9.1.0",
|
||||||
"@fastify/static": "^7.0.4",
|
"@fastify/static": "^7.0.4",
|
||||||
"@fastify/websocket": "^8.3.1",
|
"@fastify/websocket": "^8.3.1",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
"ioredis": "^5.3.2",
|
"ioredis": "^5.3.2",
|
||||||
"nodemailer": "^8.0.2",
|
"nodemailer": "^8.0.2",
|
||||||
"pg": "^8.12.0"
|
"pg": "^8.12.0",
|
||||||
|
"web-push": "^3.6.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import notificationsRoutes from './routes/notifications'
|
|||||||
import scheduleRoutes from './routes/schedule'
|
import scheduleRoutes from './routes/schedule'
|
||||||
import loyaltyRoutes from './routes/loyalty'
|
import loyaltyRoutes from './routes/loyalty'
|
||||||
import chatRoutes from './routes/chat'
|
import chatRoutes from './routes/chat'
|
||||||
|
import pushRoutes from './routes/push'
|
||||||
import workstationRoutes from './routes/workstations'
|
import workstationRoutes from './routes/workstations'
|
||||||
import agentReleaseRoutes from './routes/agent-release'
|
import agentReleaseRoutes from './routes/agent-release'
|
||||||
import wifiSettingsRoutes, { wifiAuthVerify } from './routes/wifi-settings'
|
import wifiSettingsRoutes, { wifiAuthVerify } from './routes/wifi-settings'
|
||||||
@@ -48,6 +49,7 @@ import publicWidgetRoutes from './routes/publicWidget'
|
|||||||
import yookassaWebhookRoutes from './routes/yookassaWebhook'
|
import yookassaWebhookRoutes from './routes/yookassaWebhook'
|
||||||
import { setupAgentWsRoute } from './agent-ws'
|
import { setupAgentWsRoute } from './agent-ws'
|
||||||
import { startJobs } from './jobs'
|
import { startJobs } from './jobs'
|
||||||
|
import { initWebPush } from './push'
|
||||||
|
|
||||||
export async function buildApp() {
|
export async function buildApp() {
|
||||||
const fastify = Fastify({
|
const fastify = Fastify({
|
||||||
@@ -131,6 +133,7 @@ export async function buildApp() {
|
|||||||
await fastify.register(scheduleRoutes)
|
await fastify.register(scheduleRoutes)
|
||||||
await fastify.register(loyaltyRoutes)
|
await fastify.register(loyaltyRoutes)
|
||||||
await fastify.register(chatRoutes)
|
await fastify.register(chatRoutes)
|
||||||
|
await fastify.register(pushRoutes)
|
||||||
await fastify.register(workstationRoutes)
|
await fastify.register(workstationRoutes)
|
||||||
await fastify.register(agentReleaseRoutes)
|
await fastify.register(agentReleaseRoutes)
|
||||||
await fastify.register(wifiSettingsRoutes)
|
await fastify.register(wifiSettingsRoutes)
|
||||||
@@ -147,6 +150,7 @@ export async function buildApp() {
|
|||||||
await fastify.register(setupAgentWsRoute)
|
await fastify.register(setupAgentWsRoute)
|
||||||
|
|
||||||
startJobs()
|
startJobs()
|
||||||
|
initWebPush()
|
||||||
|
|
||||||
return fastify
|
return fastify
|
||||||
}
|
}
|
||||||
|
|||||||
67
backend/src/push.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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 { /**/ }
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { FastifyPluginAsync } from 'fastify'
|
import { FastifyPluginAsync } from 'fastify'
|
||||||
import { db } from '../db'
|
import { db } from '../db'
|
||||||
|
import { sendPushToRoom } from '../push'
|
||||||
|
|
||||||
type SlugParam = { Params: { slug: string } }
|
type SlugParam = { Params: { slug: string } }
|
||||||
type RoomParam = { Params: { slug: string; roomId: string } }
|
type RoomParam = { Params: { slug: string; roomId: string } }
|
||||||
@@ -183,19 +184,39 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
const hotelId = await getHotelId(slug)
|
const hotelId = await getHotelId(slug)
|
||||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
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')
|
if (roomRows[0]?.type === 'notifications')
|
||||||
return reply.code(403).send({ error: 'Cannot post to notifications room' })
|
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
|
const { text, attachment_url } = request.body
|
||||||
if (!text?.trim() && !attachment_url) return reply.code(400).send({ error: 'Text or attachment required' })
|
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(
|
const { rows } = await db.query(
|
||||||
`INSERT INTO chat_messages (room_id, hotel_id, sender_id, text, attachment_url)
|
`INSERT INTO chat_messages (room_id, hotel_id, sender_id, text, attachment_url)
|
||||||
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
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)
|
return reply.code(201).send(msg)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
39
backend/src/routes/push.ts
Normal file
@@ -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
|
||||||
@@ -7,6 +7,13 @@
|
|||||||
<meta name="description" content="HotelSync — современная PMS система для отелей" />
|
<meta name="description" content="HotelSync — современная PMS система для отелей" />
|
||||||
<meta name="robots" content="noindex, nofollow" />
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
<title>HotelSync</title>
|
<title>HotelSync</title>
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<meta name="theme-color" content="#4F46E5" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="HotelSync" />
|
||||||
|
<link rel="apple-touch-icon" href="/icons/icon-180.png" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
|||||||
578
package-lock.json
generated
@@ -24,6 +24,7 @@
|
|||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"postcss": "^8.4.45",
|
"postcss": "^8.4.45",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"tailwindcss": "^3.4.10",
|
"tailwindcss": "^3.4.10",
|
||||||
"typescript": "^5.5.3",
|
"typescript": "^5.5.3",
|
||||||
"vite": "^5.4.1"
|
"vite": "^5.4.1"
|
||||||
@@ -324,6 +325,17 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||||
@@ -715,6 +727,496 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"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": {
|
"node_modules/didyoumean": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||||
@@ -2505,6 +3017,64 @@
|
|||||||
"semver": "bin/semver.js"
|
"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": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -2702,6 +3272,14 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0"
|
"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": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"postcss": "^8.4.45",
|
"postcss": "^8.4.45",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
"tailwindcss": "^3.4.10",
|
"tailwindcss": "^3.4.10",
|
||||||
"typescript": "^5.5.3",
|
"typescript": "^5.5.3",
|
||||||
"vite": "^5.4.1"
|
"vite": "^5.4.1"
|
||||||
|
|||||||
BIN
public/icons/icon-128.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
public/icons/icon-144.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
public/icons/icon-152.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
public/icons/icon-180.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
public/icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
public/icons/icon-384.png
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
public/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/icons/icon-72.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
public/icons/icon-96.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
20
public/manifest.json
Normal file
@@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
35
public/sw.js
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
22
scripts/generate-icons.mjs
Normal file
@@ -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 `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
|
||||||
|
<rect width="${size}" height="${size}" rx="${r}" fill="#4F46E5"/>
|
||||||
|
<text x="${size/2}" y="${size/2 + fontSize*0.36}" font-family="Arial,sans-serif" font-weight="bold" font-size="${fontSize}" fill="white" text-anchor="middle">HS</text>
|
||||||
|
</svg>`
|
||||||
|
}
|
||||||
|
|
||||||
|
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!')
|
||||||
@@ -7,6 +7,7 @@ import { api, type ChatRoom, type ChatMessage, type ChatSearchResult, type ChatR
|
|||||||
import type { User } from '../../types'
|
import type { User } from '../../types'
|
||||||
import { useAuth } from '../../contexts/AuthContext'
|
import { useAuth } from '../../contexts/AuthContext'
|
||||||
import { cn } from '../../lib/utils'
|
import { cn } from '../../lib/utils'
|
||||||
|
import { subscribeToPush, isPushSupported } from '../../lib/push'
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -788,6 +789,9 @@ export function ChatWidget() {
|
|||||||
label="Показывать текст в уведомлении" checked={settings.notifShowText !== false}
|
label="Показывать текст в уведомлении" checked={settings.notifShowText !== false}
|
||||||
onChange={v => updateSettings({ notifShowText: v })} />
|
onChange={v => updateSettings({ notifShowText: v })} />
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
<SettingsSection title="Push-уведомления">
|
||||||
|
<PushPermissionRow />
|
||||||
|
</SettingsSection>
|
||||||
<SettingsSection title="Подсказка">
|
<SettingsSection title="Подсказка">
|
||||||
<p className="text-xs text-slate-500 leading-relaxed">
|
<p className="text-xs text-slate-500 leading-relaxed">
|
||||||
Правый клик на сообщении — реакции, редактирование, удаление.<br />
|
Правый клик на сообщении — реакции, редактирование, удаление.<br />
|
||||||
@@ -1358,6 +1362,59 @@ function ToggleRow({ icon, label, checked, onChange }: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── PushPermissionRow ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function PushPermissionRow() {
|
||||||
|
const [permission, setPermission] = useState<NotificationPermission>(
|
||||||
|
typeof Notification !== 'undefined' ? Notification.permission : 'default'
|
||||||
|
)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
if (!isPushSupported()) {
|
||||||
|
return (
|
||||||
|
<p className="text-xs text-slate-400 py-2">
|
||||||
|
Ваш браузер не поддерживает push-уведомления.
|
||||||
|
{typeof navigator !== 'undefined' && navigator.userAgent.includes('iPhone') ? ' Добавьте сайт на домашний экран (iOS 16.4+).' : ''}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center gap-3 py-2">
|
||||||
|
<span className="text-slate-500 shrink-0"><Bell size={15} /></span>
|
||||||
|
<span className="flex-1 text-sm text-slate-700 dark:text-slate-300 leading-tight">
|
||||||
|
{permission === 'granted' ? 'Push включены' : permission === 'denied' ? 'Заблокированы в браузере' : 'Push-уведомления'}
|
||||||
|
</span>
|
||||||
|
{permission === 'default' && (
|
||||||
|
<button onClick={enable} disabled={loading}
|
||||||
|
className="text-xs px-2.5 py-1 rounded-lg bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 transition-colors shrink-0">
|
||||||
|
{loading ? '...' : 'Включить'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{permission === 'granted' && <span className="text-xs text-green-500 shrink-0">✓</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── GroupInfoView ──────────────────────────────────────────────────────────
|
// ── GroupInfoView ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function GroupInfoView({ room, allMembers, currentUserId, saving, editName, onEditName, onAvatarClick, onSave }: {
|
function GroupInfoView({ room, allMembers, currentUserId, saving, editName, onEditName, onAvatarClick, onSave }: {
|
||||||
|
|||||||
37
src/hooks/usePushSubscription.ts
Normal file
@@ -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])
|
||||||
|
}
|
||||||
@@ -897,6 +897,15 @@ export const api = {
|
|||||||
req<{ ok: boolean }>('DELETE', `/api/hotels/${slug}/payment-gateways/${id}`),
|
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)
|
// Public widget API (no auth)
|
||||||
widget: {
|
widget: {
|
||||||
getConfig: (slug: string) =>
|
getConfig: (slug: string) =>
|
||||||
|
|||||||
42
src/lib/push.ts
Normal file
@@ -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<ServiceWorkerRegistration | null> {
|
||||||
|
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<PushSubscription | null> {
|
||||||
|
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<boolean> {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -2,9 +2,13 @@ import { StrictMode } from 'react'
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
|
import { registerSW } from './lib/push'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Register service worker for PWA and push notifications
|
||||||
|
registerSW().catch(() => { /**/ })
|
||||||
|
|||||||