diff --git a/backend/migrations/038_loyalty_settings.sql b/backend/migrations/038_loyalty_settings.sql
new file mode 100644
index 0000000..d433f18
--- /dev/null
+++ b/backend/migrations/038_loyalty_settings.sql
@@ -0,0 +1,23 @@
+CREATE TABLE IF NOT EXISTS loyalty_settings (
+ hotel_id UUID PRIMARY KEY REFERENCES hotels(id) ON DELETE CASCADE,
+ is_active BOOLEAN NOT NULL DEFAULT true,
+ points_per_ruble NUMERIC(10,4) NOT NULL DEFAULT 0.1,
+ point_value NUMERIC(10,4) NOT NULL DEFAULT 0.01,
+ expiry_months INT NOT NULL DEFAULT 12,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE IF NOT EXISTS loyalty_transactions (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE,
+ guest_id UUID NOT NULL REFERENCES guests(id) ON DELETE CASCADE,
+ amount INT NOT NULL, -- positive = accrual, negative = spend
+ reason VARCHAR(100) NOT NULL DEFAULT 'manual',
+ notes TEXT,
+ staff_id UUID REFERENCES users(id) ON DELETE SET NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+-- Ensure guests have loyalty columns (may already exist)
+ALTER TABLE guests ADD COLUMN IF NOT EXISTS loyalty_tier VARCHAR(20) NOT NULL DEFAULT 'bronze';
+ALTER TABLE guests ADD COLUMN IF NOT EXISTS loyalty_points INT NOT NULL DEFAULT 0;
diff --git a/backend/migrations/039_chat.sql b/backend/migrations/039_chat.sql
new file mode 100644
index 0000000..0161017
--- /dev/null
+++ b/backend/migrations/039_chat.sql
@@ -0,0 +1,30 @@
+CREATE TABLE IF NOT EXISTS chat_rooms (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE,
+ type VARCHAR(10) NOT NULL DEFAULT 'general' CHECK (type IN ('general', 'direct')),
+ name VARCHAR(100),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE IF NOT EXISTS chat_room_members (
+ room_id UUID NOT NULL REFERENCES chat_rooms(id) ON DELETE CASCADE,
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ PRIMARY KEY (room_id, user_id)
+);
+
+CREATE TABLE IF NOT EXISTS chat_messages (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ room_id UUID NOT NULL REFERENCES chat_rooms(id) ON DELETE CASCADE,
+ hotel_id UUID NOT NULL REFERENCES hotels(id) ON DELETE CASCADE,
+ sender_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ text TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+CREATE INDEX IF NOT EXISTS idx_chat_messages_room ON chat_messages(room_id, created_at DESC);
+
+CREATE TABLE IF NOT EXISTS chat_read_status (
+ room_id UUID NOT NULL REFERENCES chat_rooms(id) ON DELETE CASCADE,
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ last_read TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ PRIMARY KEY (room_id, user_id)
+);
diff --git a/backend/src/app.ts b/backend/src/app.ts
index 35f8dcd..96bf411 100644
--- a/backend/src/app.ts
+++ b/backend/src/app.ts
@@ -31,6 +31,8 @@ import uploadRoutes from './routes/upload'
import housekeepingSettingsRoutes from './routes/housekeeping-settings'
import notificationsRoutes from './routes/notifications'
import scheduleRoutes from './routes/schedule'
+import loyaltyRoutes from './routes/loyalty'
+import chatRoutes from './routes/chat'
import { startJobs } from './jobs'
export async function buildApp() {
@@ -107,6 +109,8 @@ export async function buildApp() {
await fastify.register(housekeepingSettingsRoutes)
await fastify.register(notificationsRoutes)
await fastify.register(scheduleRoutes)
+ await fastify.register(loyaltyRoutes)
+ await fastify.register(chatRoutes)
startJobs()
diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts
new file mode 100644
index 0000000..2fc0d3f
--- /dev/null
+++ b/backend/src/routes/chat.ts
@@ -0,0 +1,190 @@
+import { FastifyPluginAsync } from 'fastify'
+import { db } from '../db'
+
+type SlugParam = { Params: { slug: string } }
+type RoomParam = { Params: { slug: string; roomId: string } }
+
+const chatRoutes: FastifyPluginAsync = async (fastify) => {
+ const getHotelId = async (slug: string) => {
+ const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug])
+ return rows[0]?.id as string | undefined
+ }
+
+ const canAccess = (userSlug: string | null, role: string, slug: string) =>
+ role === 'super_admin' || userSlug === slug
+
+ // Ensure general room exists for hotel
+ const ensureGeneralRoom = async (hotelId: string) => {
+ const { rows } = await db.query(
+ `INSERT INTO chat_rooms (hotel_id, type, name)
+ VALUES ($1, 'general', 'Общий чат')
+ ON CONFLICT DO NOTHING
+ RETURNING id`,
+ [hotelId],
+ )
+ if (rows[0]) return rows[0].id as string
+ const { rows: existing } = await db.query(
+ `SELECT id FROM chat_rooms WHERE hotel_id = $1 AND type = 'general'`,
+ [hotelId],
+ )
+ return existing[0]?.id as string
+ }
+
+ // GET /api/hotels/:slug/chat/rooms — list rooms (general + directs for current user)
+ fastify.get(
+ '/api/hotels/:slug/chat/rooms',
+ { onRequest: [fastify.authenticate] },
+ async (request, reply) => {
+ const { slug } = request.params
+ if (!canAccess(request.user.hotelSlug, request.user.role, slug))
+ return reply.code(403).send({ error: 'Forbidden' })
+ const hotelId = await getHotelId(slug)
+ if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
+
+ const generalRoomId = await ensureGeneralRoom(hotelId)
+ const userId = request.user.sub
+
+ // Get all rooms this user belongs to (general + directs)
+ const { rows } = await db.query(
+ `SELECT r.id, r.type, r.name,
+ (SELECT COUNT(*) FROM chat_messages m
+ WHERE m.room_id = r.id
+ AND m.created_at > COALESCE(
+ (SELECT rs.last_read FROM chat_read_status rs WHERE rs.room_id = r.id AND rs.user_id = $2),
+ '1970-01-01'
+ )
+ AND m.sender_id != $2
+ ) AS unread_count,
+ (SELECT m.text FROM chat_messages m WHERE m.room_id = r.id ORDER BY m.created_at DESC LIMIT 1) AS last_message,
+ (SELECT m.created_at FROM chat_messages m WHERE m.room_id = r.id ORDER BY m.created_at DESC LIMIT 1) AS last_message_at,
+ (SELECT u.name FROM chat_messages m JOIN users u ON u.id = m.sender_id WHERE m.room_id = r.id ORDER BY m.created_at DESC LIMIT 1) AS last_sender,
+ -- for direct rooms: get the other user's name
+ (SELECT u.name FROM chat_room_members crm JOIN users u ON u.id = crm.user_id WHERE crm.room_id = r.id AND crm.user_id != $2 LIMIT 1) AS other_user_name,
+ (SELECT crm.user_id FROM chat_room_members crm WHERE crm.room_id = r.id AND crm.user_id != $2 LIMIT 1) AS other_user_id
+ FROM chat_rooms r
+ WHERE r.hotel_id = $1
+ AND (r.type = 'general' OR EXISTS (
+ SELECT 1 FROM chat_room_members crm WHERE crm.room_id = r.id AND crm.user_id = $2
+ ))
+ ORDER BY last_message_at DESC NULLS LAST, r.type = 'general' DESC`,
+ [hotelId, userId],
+ )
+ void generalRoomId
+ return rows
+ },
+ )
+
+ // GET /api/hotels/:slug/chat/rooms/:roomId/messages
+ fastify.get(
+ '/api/hotels/:slug/chat/rooms/:roomId/messages',
+ { onRequest: [fastify.authenticate] },
+ async (request, reply) => {
+ const { slug, roomId } = request.params
+ if (!canAccess(request.user.hotelSlug, request.user.role, slug))
+ return reply.code(403).send({ error: 'Forbidden' })
+ const hotelId = await getHotelId(slug)
+ if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
+
+ const limit = Math.min(Number(request.query.limit ?? 50), 100)
+ const { rows } = await db.query(
+ `SELECT m.id, m.room_id, m.sender_id, m.text, m.created_at,
+ u.name AS sender_name, u.role AS sender_role
+ FROM chat_messages m
+ JOIN users u ON u.id = m.sender_id
+ WHERE m.room_id = $1 AND m.hotel_id = $2
+ ORDER BY m.created_at DESC
+ LIMIT $3`,
+ [roomId, hotelId, limit],
+ )
+ // Update read status
+ await db.query(
+ `INSERT INTO chat_read_status (room_id, user_id, last_read)
+ VALUES ($1, $2, NOW())
+ ON CONFLICT (room_id, user_id) DO UPDATE SET last_read = NOW()`,
+ [roomId, request.user.sub],
+ )
+ return rows.reverse() // chronological order
+ },
+ )
+
+ // POST /api/hotels/:slug/chat/rooms/:roomId/messages
+ fastify.post(
+ '/api/hotels/:slug/chat/rooms/:roomId/messages',
+ { onRequest: [fastify.authenticate] },
+ async (request, reply) => {
+ const { slug, roomId } = request.params
+ if (!canAccess(request.user.hotelSlug, request.user.role, slug))
+ return reply.code(403).send({ error: 'Forbidden' })
+ const hotelId = await getHotelId(slug)
+ if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
+
+ const { text } = request.body
+ if (!text?.trim()) return reply.code(400).send({ error: 'Text required' })
+
+ const { rows } = await db.query(
+ `INSERT INTO chat_messages (room_id, hotel_id, sender_id, text)
+ VALUES ($1, $2, $3, $4)
+ RETURNING id, room_id, sender_id, text, created_at`,
+ [roomId, hotelId, request.user.sub, text.trim()],
+ )
+ const msg = rows[0]
+ // Get sender name
+ const { rows: uRows } = await db.query('SELECT name, role FROM users WHERE id = $1', [request.user.sub])
+ const result = { ...msg, sender_name: uRows[0]?.name, sender_role: uRows[0]?.role }
+ return reply.code(201).send(result)
+ },
+ )
+
+ // POST /api/hotels/:slug/chat/direct/:otherUserId — create/get direct room
+ fastify.post<{ Params: { slug: string; otherUserId: string } }>(
+ '/api/hotels/:slug/chat/direct/:otherUserId',
+ { onRequest: [fastify.authenticate] },
+ async (request, reply) => {
+ const { slug, otherUserId } = request.params
+ if (!canAccess(request.user.hotelSlug, request.user.role, slug))
+ return reply.code(403).send({ error: 'Forbidden' })
+ const hotelId = await getHotelId(slug)
+ if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
+
+ const userId = request.user.sub
+ // Check if direct room already exists between these two users
+ const { rows: existing } = await db.query(
+ `SELECT r.id FROM chat_rooms r
+ JOIN chat_room_members m1 ON m1.room_id = r.id AND m1.user_id = $1
+ JOIN chat_room_members m2 ON m2.room_id = r.id AND m2.user_id = $2
+ WHERE r.hotel_id = $3 AND r.type = 'direct'
+ LIMIT 1`,
+ [userId, otherUserId, hotelId],
+ )
+ if (existing[0]) return { room_id: existing[0].id }
+
+ // Create new direct room
+ const { rows: [room] } = await db.query(
+ `INSERT INTO chat_rooms (hotel_id, type) VALUES ($1, 'direct') RETURNING id`,
+ [hotelId],
+ )
+ await db.query(
+ `INSERT INTO chat_room_members (room_id, user_id) VALUES ($1, $2), ($1, $3)`,
+ [room.id, userId, otherUserId],
+ )
+ return reply.code(201).send({ room_id: room.id })
+ },
+ )
+
+ // PATCH /api/hotels/:slug/chat/rooms/:roomId/read — mark as read
+ fastify.patch(
+ '/api/hotels/:slug/chat/rooms/:roomId/read',
+ { onRequest: [fastify.authenticate] },
+ async (request, reply) => {
+ const { roomId } = request.params
+ await db.query(
+ `INSERT INTO chat_read_status (room_id, user_id, last_read) VALUES ($1, $2, NOW())
+ ON CONFLICT (room_id, user_id) DO UPDATE SET last_read = NOW()`,
+ [roomId, request.user.sub],
+ )
+ return { ok: true }
+ },
+ )
+}
+
+export default chatRoutes
diff --git a/backend/src/routes/loyalty.ts b/backend/src/routes/loyalty.ts
new file mode 100644
index 0000000..ba392e1
--- /dev/null
+++ b/backend/src/routes/loyalty.ts
@@ -0,0 +1,271 @@
+import { FastifyPluginAsync } from 'fastify'
+import { db } from '../db'
+
+type SlugParam = { Params: { slug: string } }
+type SlugGuestParam = { Params: { slug: string; guestId: string } }
+
+interface LoyaltySettingsRow {
+ is_active: boolean
+ points_per_ruble: string
+ point_value: string
+ expiry_months: number
+}
+
+interface LoyaltySettingsBody {
+ isActive?: boolean
+ pointsPerRuble?: number
+ pointValue?: number
+ expiryMonths?: number
+}
+
+interface AddPointsBody {
+ amount: number
+ reason: string
+ notes?: string
+}
+
+const DEFAULT_SETTINGS: LoyaltySettingsRow = {
+ is_active: true,
+ points_per_ruble: '0.1',
+ point_value: '0.01',
+ expiry_months: 12,
+}
+
+function calcTier(points: number): string {
+ if (points >= 15000) return 'platinum'
+ if (points >= 5000) return 'gold'
+ if (points >= 1000) return 'silver'
+ return 'bronze'
+}
+
+function formatSettings(row: LoyaltySettingsRow) {
+ return {
+ isActive: row.is_active,
+ pointsPerRuble: parseFloat(row.points_per_ruble),
+ pointValue: parseFloat(row.point_value),
+ expiryMonths: row.expiry_months,
+ }
+}
+
+const loyaltyRoutes: FastifyPluginAsync = async (fastify) => {
+ const getHotelId = async (slug: string): Promise => {
+ const { rows } = await db.query('SELECT id FROM hotels WHERE slug = $1', [slug])
+ return rows[0]?.id ?? null
+ }
+
+ const canAccess = (userSlug: string | null, role: string, slug: string) =>
+ role === 'super_admin' || userSlug === slug
+
+ // GET /api/hotels/:slug/loyalty/settings
+ fastify.get(
+ '/api/hotels/:slug/loyalty/settings',
+ { onRequest: [fastify.authenticate] },
+ async (request, reply) => {
+ const { slug } = request.params
+ if (!canAccess(request.user.hotelSlug, request.user.role, slug))
+ return reply.code(403).send({ error: 'Forbidden' })
+ const hotelId = await getHotelId(slug)
+ if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
+
+ const { rows } = await db.query(
+ 'SELECT is_active, points_per_ruble, point_value, expiry_months FROM loyalty_settings WHERE hotel_id = $1',
+ [hotelId],
+ )
+ return formatSettings(rows[0] ?? DEFAULT_SETTINGS)
+ },
+ )
+
+ // PATCH /api/hotels/:slug/loyalty/settings
+ fastify.patch(
+ '/api/hotels/:slug/loyalty/settings',
+ { onRequest: [fastify.authenticate] },
+ async (request, reply) => {
+ const { slug } = request.params
+ if (!canAccess(request.user.hotelSlug, request.user.role, slug))
+ return reply.code(403).send({ error: 'Forbidden' })
+ const hotelId = await getHotelId(slug)
+ if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
+
+ const { isActive, pointsPerRuble, pointValue, expiryMonths } = request.body
+
+ // Fetch existing first
+ const { rows: existing } = await db.query(
+ 'SELECT is_active, points_per_ruble, point_value, expiry_months FROM loyalty_settings WHERE hotel_id = $1',
+ [hotelId],
+ )
+ const cur = existing[0] ?? DEFAULT_SETTINGS
+
+ const newIsActive = isActive !== undefined ? isActive : cur.is_active
+ const newPointsPerRuble = pointsPerRuble !== undefined ? pointsPerRuble : parseFloat(cur.points_per_ruble)
+ const newPointValue = pointValue !== undefined ? pointValue : parseFloat(cur.point_value)
+ const newExpiryMonths = expiryMonths !== undefined ? expiryMonths : cur.expiry_months
+
+ await db.query(
+ `INSERT INTO loyalty_settings (hotel_id, is_active, points_per_ruble, point_value, expiry_months, updated_at)
+ VALUES ($1, $2, $3, $4, $5, NOW())
+ ON CONFLICT (hotel_id) DO UPDATE SET
+ is_active = EXCLUDED.is_active,
+ points_per_ruble = EXCLUDED.points_per_ruble,
+ point_value = EXCLUDED.point_value,
+ expiry_months = EXCLUDED.expiry_months,
+ updated_at = NOW()`,
+ [hotelId, newIsActive, newPointsPerRuble, newPointValue, newExpiryMonths],
+ )
+
+ const { rows } = await db.query(
+ 'SELECT is_active, points_per_ruble, point_value, expiry_months FROM loyalty_settings WHERE hotel_id = $1',
+ [hotelId],
+ )
+ return formatSettings(rows[0])
+ },
+ )
+
+ // GET /api/hotels/:slug/loyalty/guests
+ fastify.get(
+ '/api/hotels/:slug/loyalty/guests',
+ { onRequest: [fastify.authenticate] },
+ async (request, reply) => {
+ const { slug } = request.params
+ if (!canAccess(request.user.hotelSlug, request.user.role, slug))
+ return reply.code(403).send({ error: 'Forbidden' })
+ const hotelId = await getHotelId(slug)
+ if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
+
+ const { q } = request.query
+ let query: string
+ let params: unknown[]
+
+ if (q && q.trim()) {
+ query = `
+ SELECT id, first_name, last_name, middle_name, email, phone, loyalty_points, loyalty_tier, total_spent
+ FROM guests
+ WHERE hotel_id = $1
+ AND (
+ lower(first_name || ' ' || last_name) LIKE lower($2)
+ OR lower(email) LIKE lower($2)
+ OR regexp_replace(phone, '[^0-9]', '', 'g') LIKE $3
+ )
+ ORDER BY loyalty_points DESC
+ LIMIT 100`
+ const likeQ = `%${q.trim()}%`
+ const digitsQ = `%${q.replace(/\D/g, '')}%`
+ params = [hotelId, likeQ, digitsQ]
+ } else {
+ query = `
+ SELECT id, first_name, last_name, middle_name, email, phone, loyalty_points, loyalty_tier, total_spent
+ FROM guests
+ WHERE hotel_id = $1
+ ORDER BY loyalty_points DESC
+ LIMIT 100`
+ params = [hotelId]
+ }
+
+ const { rows } = await db.query(query, params)
+ return rows.map((g: Record) => ({
+ id: g.id,
+ name: [g.last_name, g.first_name, g.middle_name].filter(Boolean).join(' '),
+ email: g.email ?? null,
+ phone: g.phone ?? null,
+ loyaltyPoints: Number(g.loyalty_points) || 0,
+ loyaltyTier: g.loyalty_tier ?? 'bronze',
+ totalSpent: parseFloat(String(g.total_spent)) || 0,
+ }))
+ },
+ )
+
+ // POST /api/hotels/:slug/loyalty/guests/:guestId/points
+ fastify.post(
+ '/api/hotels/:slug/loyalty/guests/:guestId/points',
+ { onRequest: [fastify.authenticate] },
+ async (request, reply) => {
+ const { slug, guestId } = request.params
+ if (!canAccess(request.user.hotelSlug, request.user.role, slug))
+ return reply.code(403).send({ error: 'Forbidden' })
+ const hotelId = await getHotelId(slug)
+ if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
+
+ const { amount, reason, notes } = request.body
+ if (typeof amount !== 'number' || !reason)
+ return reply.code(400).send({ error: 'amount and reason are required' })
+
+ // Get current guest
+ const { rows: gRows } = await db.query(
+ 'SELECT id, first_name, last_name, middle_name, email, phone, loyalty_points, loyalty_tier, total_spent FROM guests WHERE id = $1 AND hotel_id = $2',
+ [guestId, hotelId],
+ )
+ if (!gRows[0]) return reply.code(404).send({ error: 'Guest not found' })
+ const guest = gRows[0]
+
+ const newPoints = Math.max(0, (Number(guest.loyalty_points) || 0) + amount)
+ const newTier = calcTier(newPoints)
+
+ await db.query(
+ 'UPDATE guests SET loyalty_points = $1, loyalty_tier = $2 WHERE id = $3',
+ [newPoints, newTier, guestId],
+ )
+
+ await db.query(
+ `INSERT INTO loyalty_transactions (hotel_id, guest_id, amount, reason, notes, staff_id)
+ VALUES ($1, $2, $3, $4, $5, $6)`,
+ [hotelId, guestId, amount, reason, notes ?? null, request.user.userId ?? null],
+ )
+
+ return {
+ id: guest.id,
+ name: [guest.last_name, guest.first_name, guest.middle_name].filter(Boolean).join(' '),
+ email: guest.email ?? null,
+ phone: guest.phone ?? null,
+ loyaltyPoints: newPoints,
+ loyaltyTier: newTier,
+ totalSpent: parseFloat(String(guest.total_spent)) || 0,
+ }
+ },
+ )
+
+ // GET /api/hotels/:slug/loyalty/transactions
+ fastify.get(
+ '/api/hotels/:slug/loyalty/transactions',
+ { onRequest: [fastify.authenticate] },
+ async (request, reply) => {
+ const { slug } = request.params
+ if (!canAccess(request.user.hotelSlug, request.user.role, slug))
+ return reply.code(403).send({ error: 'Forbidden' })
+ const hotelId = await getHotelId(slug)
+ if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
+
+ const limit = Math.min(parseInt(request.query.limit ?? '50') || 50, 200)
+
+ const { rows } = await db.query(
+ `SELECT
+ lt.id,
+ lt.guest_id,
+ (g.last_name || ' ' || g.first_name) AS guest_name,
+ lt.amount,
+ lt.reason,
+ lt.notes,
+ u.name AS staff_name,
+ lt.created_at
+ FROM loyalty_transactions lt
+ JOIN guests g ON g.id = lt.guest_id
+ LEFT JOIN users u ON u.id = lt.staff_id
+ WHERE lt.hotel_id = $1
+ ORDER BY lt.created_at DESC
+ LIMIT $2`,
+ [hotelId, limit],
+ )
+
+ return rows.map((r: Record) => ({
+ id: r.id,
+ guestId: r.guest_id,
+ guestName: r.guest_name,
+ amount: Number(r.amount),
+ reason: r.reason,
+ notes: r.notes ?? null,
+ staffName: r.staff_name ?? null,
+ createdAt: r.created_at,
+ }))
+ },
+ )
+}
+
+export default loyaltyRoutes
diff --git a/src/components/chat/ChatWidget.tsx b/src/components/chat/ChatWidget.tsx
new file mode 100644
index 0000000..c150f70
--- /dev/null
+++ b/src/components/chat/ChatWidget.tsx
@@ -0,0 +1,294 @@
+import { useState, useEffect, useRef, useCallback } from 'react'
+import { MessageSquare, X, ChevronLeft, Send, Users, Loader2 } from 'lucide-react'
+import { api, type ChatRoom, type ChatMessage } from '../../lib/api'
+import { useAuth } from '../../contexts/AuthContext'
+import { cn } from '../../lib/utils'
+
+// Color for avatar based on name
+function avatarColor(name: string) {
+ const colors = ['#4F46E5','#059669','#2563EB','#7C3AED','#DC2626','#D97706','#DB2777','#0891B2']
+ let h = 0
+ for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) % colors.length
+ return colors[h]
+}
+
+function initials(name: string) {
+ return name.split(' ').map(p => p[0]).join('').toUpperCase().slice(0, 2)
+}
+
+function fmtTime(iso: string) {
+ return new Date(iso).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })
+}
+
+function Avatar({ name, size = 28 }: { name: string; size?: number }) {
+ return (
+
+ {initials(name)}
+
+ )
+}
+
+export function ChatWidget() {
+ const { user } = useAuth()
+ const slug = user?.hotelSlug ?? ''
+ const [open, setOpen] = useState(false)
+ const [view, setView] = useState<'rooms' | 'messages'>('rooms')
+ const [rooms, setRooms] = useState([])
+ const [activeRoom, setActiveRoom] = useState(null)
+ const [messages, setMessages] = useState([])
+ const [text, setText] = useState('')
+ const [loadingRooms, setLoadingRooms] = useState(false)
+ const [loadingMsgs, setLoadingMsgs] = useState(false)
+ const [sending, setSending] = useState(false)
+ const messagesEndRef = useRef(null)
+ const pollRef = useRef | null>(null)
+
+ const totalUnread = rooms.reduce((s, r) => s + (Number(r.unreadCount) || 0), 0)
+
+ const loadRooms = useCallback(async () => {
+ if (!slug) return
+ try {
+ const data = await api.chat.listRooms(slug)
+ setRooms(data)
+ } catch { /* ignore */ }
+ }, [slug])
+
+ // Poll for new messages every 5s when open
+ useEffect(() => {
+ if (!open || !slug) return
+ setLoadingRooms(true)
+ loadRooms().finally(() => setLoadingRooms(false))
+ pollRef.current = setInterval(loadRooms, 5000)
+ return () => { if (pollRef.current) clearInterval(pollRef.current) }
+ }, [open, slug, loadRooms])
+
+ const openRoom = async (room: ChatRoom) => {
+ setActiveRoom(room)
+ setView('messages')
+ setLoadingMsgs(true)
+ try {
+ const msgs = await api.chat.getMessages(slug, room.id)
+ setMessages(msgs)
+ await api.chat.markRead(slug, room.id)
+ setRooms(prev => prev.map(r => r.id === room.id ? { ...r, unreadCount: 0 } : r))
+ } catch { /* ignore */ }
+ finally { setLoadingMsgs(false) }
+ }
+
+ // Auto-scroll to bottom on new messages
+ useEffect(() => {
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
+ }, [messages])
+
+ // Poll messages when in messages view
+ useEffect(() => {
+ if (view !== 'messages' || !activeRoom) return
+ const interval = setInterval(async () => {
+ try {
+ const msgs = await api.chat.getMessages(slug, activeRoom.id)
+ setMessages(msgs)
+ } catch { /* ignore */ }
+ }, 3000)
+ return () => clearInterval(interval)
+ }, [view, activeRoom, slug])
+
+ const sendMessage = async () => {
+ if (!text.trim() || !activeRoom || sending) return
+ const t = text.trim()
+ setText('')
+ setSending(true)
+ try {
+ const msg = await api.chat.sendMessage(slug, activeRoom.id, t)
+ setMessages(prev => [...prev, msg])
+ } catch {
+ setText(t)
+ } finally {
+ setSending(false)
+ }
+ }
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault()
+ void sendMessage()
+ }
+ }
+
+ if (!slug) return null
+
+ return (
+ <>
+ {/* Floating button */}
+ setOpen(v => !v)}
+ className={cn(
+ 'fixed bottom-6 right-6 z-50 w-14 h-14 rounded-full shadow-lg',
+ 'bg-brand-600 hover:bg-brand-700 text-white transition-all',
+ 'flex items-center justify-center',
+ open && 'scale-90',
+ )}
+ >
+ {open ? : }
+ {!open && totalUnread > 0 && (
+
+ {totalUnread > 9 ? '9+' : totalUnread}
+
+ )}
+
+
+ {/* Chat panel */}
+ {open && (
+
+
+ {/* Header */}
+
+ {view === 'messages' && (
+
{ setView('rooms'); setActiveRoom(null) }} className="p-1 hover:bg-white/20 rounded-lg transition-colors">
+
+
+ )}
+
+
+ {view === 'rooms' ? 'Чат сотрудников' : (activeRoom?.type === 'general' ? 'Общий чат' : activeRoom?.otherUserName ?? 'Чат')}
+
+ {view === 'rooms' && (
+
{rooms.length} чатов
+ )}
+
+
+
+
+ {/* Rooms list */}
+ {view === 'rooms' && (
+
+ {loadingRooms ? (
+
+
+
+ ) : rooms.length === 0 ? (
+
+ Нет чатов. Начните общение!
+
+ ) : (
+ rooms.map(room => (
+
openRoom(room)}
+ className="w-full flex items-center gap-3 px-4 py-3 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors border-b border-slate-100 dark:border-slate-700/50 text-left"
+ >
+
+ {room.type === 'general' ? (
+
+
+
+ ) : (
+
+ )}
+ {Number(room.unreadCount) > 0 && (
+
+ {room.unreadCount}
+
+ )}
+
+
+
+
0 ? 'text-slate-900 dark:text-slate-100' : 'text-slate-700 dark:text-slate-300')}>
+ {room.type === 'general' ? 'Общий чат' : room.otherUserName}
+
+ {room.lastMessageAt && (
+
+ {fmtTime(room.lastMessageAt)}
+
+ )}
+
+ {room.lastMessage && (
+
0 ? 'text-slate-600 dark:text-slate-300 font-medium' : 'text-slate-400 dark:text-slate-500')}>
+ {room.lastSender && room.type === 'general' ? `${room.lastSender.split(' ')[0]}: ` : ''}{room.lastMessage}
+
+ )}
+
+
+ ))
+ )}
+
+ )}
+
+ {/* Messages view */}
+ {view === 'messages' && (
+ <>
+
+ {loadingMsgs ? (
+
+
+
+ ) : messages.length === 0 ? (
+
+ Нет сообщений. Напишите первым!
+
+ ) : (
+ messages.map(msg => {
+ const isOwn = msg.senderId === user?.id
+ return (
+
+ {!isOwn &&
}
+
+ {!isOwn && (
+
{msg.senderName.split(' ')[0]}
+ )}
+
+ {msg.text}
+
+
{fmtTime(msg.createdAt)}
+
+
+ )
+ })
+ )}
+
+
+
+ {/* Input */}
+
+
+
+
Enter — отправить, Shift+Enter — перенос
+
+ >
+ )}
+
+ )}
+ >
+ )
+}
diff --git a/src/layouts/AppLayout.tsx b/src/layouts/AppLayout.tsx
index ab15788..17f2cbc 100644
--- a/src/layouts/AppLayout.tsx
+++ b/src/layouts/AppLayout.tsx
@@ -2,6 +2,7 @@ import { Outlet, Navigate } from 'react-router-dom'
import { useState } from 'react'
import { Sidebar } from '../components/layout/Sidebar'
import { Topbar } from '../components/layout/Topbar'
+import { ChatWidget } from '../components/chat/ChatWidget'
import { useAuth } from '../contexts/AuthContext'
export function AppLayout() {
@@ -20,6 +21,8 @@ export function AppLayout() {
+
+
)
}
diff --git a/src/lib/api.ts b/src/lib/api.ts
index deb2837..4783d06 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -287,6 +287,34 @@ export const api = {
req('DELETE', `/api/hotels/${slug}/schedule/${userId}/${date}`),
},
+ // ── Loyalty ───────────────────────────────────────────────────────────────
+ loyalty: {
+ getSettings: (slug: string) =>
+ req('GET', `/api/hotels/${slug}/loyalty/settings`),
+ saveSettings: (slug: string, data: Partial) =>
+ req('PATCH', `/api/hotels/${slug}/loyalty/settings`, data),
+ listGuests: (slug: string, q?: string) =>
+ req('GET', `/api/hotels/${slug}/loyalty/guests${q ? `?q=${encodeURIComponent(q)}` : ''}`),
+ addPoints: (slug: string, guestId: string, data: { amount: number; reason: string; notes?: string }) =>
+ req('POST', `/api/hotels/${slug}/loyalty/guests/${guestId}/points`, data),
+ listTransactions: (slug: string, limit = 50) =>
+ req('GET', `/api/hotels/${slug}/loyalty/transactions?limit=${limit}`),
+ },
+
+ // ── Chat ──────────────────────────────────────────────────────────────────
+ chat: {
+ listRooms: (slug: string) =>
+ req('GET', `/api/hotels/${slug}/chat/rooms`),
+ getMessages: (slug: string, roomId: string, limit = 50) =>
+ req('GET', `/api/hotels/${slug}/chat/rooms/${roomId}/messages?limit=${limit}`),
+ sendMessage: (slug: string, roomId: string, text: string) =>
+ req('POST', `/api/hotels/${slug}/chat/rooms/${roomId}/messages`, { text }),
+ markRead: (slug: string, roomId: string) =>
+ req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/chat/rooms/${roomId}/read`),
+ openDirect: (slug: string, otherUserId: string) =>
+ req<{ room_id: string }>('POST', `/api/hotels/${slug}/chat/direct/${otherUserId}`),
+ },
+
// ── Hotels ────────────────────────────────────────────────────────────────
hotels: {
get: (slug: string) =>
@@ -867,6 +895,56 @@ export interface RatePeriodPayload {
days_of_week?: number[] | null
}
+export interface LoyaltySettings {
+ isActive: boolean
+ pointsPerRuble: number
+ pointValue: number
+ expiryMonths: number
+}
+
+export interface LoyaltyGuestApi {
+ id: string
+ name: string
+ email: string | null
+ phone: string | null
+ loyaltyPoints: number
+ loyaltyTier: string
+ totalSpent: number
+}
+
+export interface LoyaltyTransaction {
+ id: string
+ guestId: string
+ guestName: string
+ amount: number
+ reason: string
+ notes: string | null
+ staffName: string | null
+ createdAt: string
+}
+
+export interface ChatRoom {
+ id: string
+ type: 'general' | 'direct'
+ name: string | null
+ unreadCount: number
+ lastMessage: string | null
+ lastMessageAt: string | null
+ lastSender: string | null
+ otherUserName: string | null
+ otherUserId: string | null
+}
+
+export interface ChatMessage {
+ id: string
+ roomId: string
+ senderId: string
+ senderName: string
+ senderRole: string
+ text: string
+ createdAt: string
+}
+
function toHotelPayload(h: HotelPayload): Record {
const out: Record = {}
if (h.name !== undefined) out.name = h.name
diff --git a/src/pages/LoyaltyPage.tsx b/src/pages/LoyaltyPage.tsx
index feca94e..4d439ed 100644
--- a/src/pages/LoyaltyPage.tsx
+++ b/src/pages/LoyaltyPage.tsx
@@ -1,10 +1,12 @@
-import { useState } from 'react'
+import { useState, useEffect, useCallback } from 'react'
import {
Award, Star, Gift, Percent, CreditCard, Edit2, Save, Info,
Crown, Shield, User, ChevronRight, Smartphone, Mail, QrCode,
TrendingUp, Search, Plus, Minus, Check, AlertCircle, Phone,
} from 'lucide-react'
import { cn } from '../lib/utils'
+import { api, type LoyaltyGuestApi, type LoyaltyTransaction } from '../lib/api'
+import { useAuth } from '../contexts/AuthContext'
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -509,65 +511,55 @@ function AccrualRulesTab() {
// ─── Manual Accrual Tab ───────────────────────────────────────────────────────
-function ManualAccrualTab() {
- const [guests, setGuests] = useState(MOCK_GUESTS)
+function ManualAccrualTab({ slug }: { slug: string }) {
+ const [searchResults, setSearchResults] = useState([])
const [search, setSearch] = useState('')
- const [selectedGuest, setSelectedGuest] = useState(null)
+ const [selectedGuest, setSelectedGuest] = useState(null)
const [mode, setMode] = useState<'add' | 'subtract'>('add')
const [amount, setAmount] = useState('')
const [reason, setReason] = useState('')
const [notes, setNotes] = useState('')
- const [transactions, setTransactions] = useState([])
+ const [transactions, setTransactions] = useState([])
const [success, setSuccess] = useState(false)
+ const [submitting, setSubmitting] = useState(false)
- const searchResults = search.trim()
- ? guests.filter(g =>
- g.name.toLowerCase().includes(search.toLowerCase()) ||
- g.email.toLowerCase().includes(search.toLowerCase()) ||
- (g.phone ?? '').replace(/\D/g, '').includes(search.replace(/\D/g, ''))
- )
- : []
+ const doSearch = useCallback(async (q: string) => {
+ if (!q.trim() || !slug) { setSearchResults([]); return }
+ try {
+ const data = await api.loyalty.listGuests(slug, q)
+ setSearchResults(data)
+ } catch { setSearchResults([]) }
+ }, [slug])
- const handleSubmit = () => {
- if (!selectedGuest || !amount || !reason) return
+ useEffect(() => {
+ const t = setTimeout(() => doSearch(search), 300)
+ return () => clearTimeout(t)
+ }, [search, doSearch])
+
+ useEffect(() => {
+ if (!slug) return
+ api.loyalty.listTransactions(slug, 20).then(setTransactions).catch(() => {})
+ }, [slug, success])
+
+ const handleSubmit = async () => {
+ if (!selectedGuest || !amount || !reason || submitting) return
const pts = parseInt(amount)
if (isNaN(pts) || pts <= 0) return
-
const delta = mode === 'add' ? pts : -pts
- setGuests(prev => prev.map(g =>
- g.id === selectedGuest.id
- ? { ...g, points: Math.max(0, g.points + delta) }
- : g
- ))
-
- const tx: ManualTransaction = {
- id: `mt-${Date.now()}`,
- guestId: selectedGuest.id,
- guestName: selectedGuest.name,
- amount: delta,
- reason,
- notes,
- date: new Date().toLocaleDateString('ru-RU'),
- staff: 'Менеджер',
- }
- setTransactions(prev => [tx, ...prev])
-
- // Update selected guest preview
- setSelectedGuest(prev => prev
- ? { ...prev, points: Math.max(0, prev.points + delta) }
- : null
- )
-
- setAmount('')
- setNotes('')
- setReason('')
- setSuccess(true)
- setTimeout(() => setSuccess(false), 3000)
+ setSubmitting(true)
+ try {
+ const updated = await api.loyalty.addPoints(slug, selectedGuest.id, { amount: delta, reason, notes: notes || undefined })
+ setSelectedGuest(updated)
+ setAmount('')
+ setNotes('')
+ setReason('')
+ setSuccess(true)
+ setTimeout(() => setSuccess(false), 3000)
+ } catch { /* ignore */ }
+ finally { setSubmitting(false) }
}
const updatedGuest = selectedGuest
- ? guests.find(g => g.id === selectedGuest.id) ?? selectedGuest
- : null
return (
@@ -604,9 +596,9 @@ function ManualAccrualTab() {
- {levelBadge(g.level)}
+ {levelBadge(g.loyaltyTier as LevelId)}
- {g.points.toLocaleString('ru-RU')} б.
+ {g.loyaltyPoints.toLocaleString('ru-RU')} б.
@@ -635,7 +627,7 @@ function ManualAccrualTab() {
Текущий баланс
- {updatedGuest.points.toLocaleString('ru-RU')} б.
+ {updatedGuest.loyaltyPoints.toLocaleString('ru-RU')} б.
@@ -697,7 +689,7 @@ function ManualAccrualTab() {
'font-semibold',
mode === 'add' ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400',
)}>
- {Math.max(0, (updatedGuest?.points ?? 0) + (mode === 'add' ? 1 : -1) * (parseInt(amount) || 0)).toLocaleString('ru-RU')} б.
+ {Math.max(0, (updatedGuest?.loyaltyPoints ?? 0) + (mode === 'add' ? 1 : -1) * (parseInt(amount) || 0)).toLocaleString('ru-RU')} б.
)}
@@ -736,8 +728,8 @@ function ManualAccrualTab() {
)}
{ void handleSubmit() }}
+ disabled={!amount || !reason || submitting}
className={cn(
'w-full py-2.5 rounded-xl font-medium text-sm transition-colors',
mode === 'add'
@@ -779,7 +771,7 @@ function ManualAccrualTab() {
)}>
{tx.amount > 0 ? '+' : ''}{tx.amount.toLocaleString('ru-RU')} б.
- {tx.date} · {tx.staff}
+ {new Date(tx.createdAt).toLocaleDateString('ru-RU')}{tx.staffName ? ` · ${tx.staffName}` : ''}
@@ -802,17 +794,33 @@ const PAGE_TABS = [
type PageTab = typeof PAGE_TABS[number]['id']
export function LoyaltyPage() {
+ const { user } = useAuth()
+ const slug = user?.hotelSlug ?? ''
const [tab, setTab] = useState('overview')
const [levels, setLevels] = useState(DEFAULT_LEVELS)
const [settings, setSettings] = useState(DEFAULT_SETTINGS)
const [editSettings, setEditSettings] = useState(false)
+ const [guests, setGuests] = useState([])
+
+ useEffect(() => {
+ if (!slug) return
+ api.loyalty.getSettings(slug).then(s => setSettings(s)).catch(() => {})
+ api.loyalty.listGuests(slug).then(g => setGuests(g)).catch(() => {})
+ }, [slug])
+
+ const saveSettings = async () => {
+ if (!slug) return
+ const saved = await api.loyalty.saveSettings(slug, settings).catch(() => null)
+ if (saved) setSettings(saved)
+ setEditSettings(false)
+ }
const updateLevel = (updated: LoyaltyLevel) =>
setLevels(prev => prev.map(l => l.id === updated.id ? updated : l))
- const totalGuests = MOCK_GUESTS.length
- const activeGuests = MOCK_GUESTS.filter(g => g.points > 0).length
- const totalPoints = MOCK_GUESTS.reduce((s, g) => s + g.points, 0)
+ const totalGuests = guests.length
+ const activeGuests = guests.filter(g => g.loyaltyPoints > 0).length
+ const totalPoints = guests.reduce((s, g) => s + g.loyaltyPoints, 0)
return (
@@ -841,8 +849,8 @@ export function LoyaltyPage() {
{[
{ label: 'Участников программы', value: activeGuests, icon: User, color: 'text-brand-600 dark:text-brand-400', bg: 'bg-brand-50 dark:bg-brand-900/20' },
{ label: 'Всего баллов в обороте', value: totalPoints.toLocaleString('ru-RU'), icon: TrendingUp, color: 'text-amber-600 dark:text-amber-400', bg: 'bg-amber-50 dark:bg-amber-900/20' },
- { label: 'Платиновых участников', value: MOCK_GUESTS.filter(g => g.level === 'platinum').length, icon: Crown, color: 'text-violet-600 dark:text-violet-400', bg: 'bg-violet-50 dark:bg-violet-900/20' },
- { label: 'Средний балл / гость', value: Math.round(totalPoints / totalGuests).toLocaleString('ru-RU'), icon: Star, color: 'text-yellow-600 dark:text-yellow-400', bg: 'bg-yellow-50 dark:bg-yellow-900/20' },
+ { label: 'Платиновых участников', value: guests.filter(g => g.loyaltyTier === 'platinum').length, icon: Crown, color: 'text-violet-600 dark:text-violet-400', bg: 'bg-violet-50 dark:bg-violet-900/20' },
+ { label: 'Средний балл / гость', value: totalGuests > 0 ? Math.round(totalPoints / totalGuests).toLocaleString('ru-RU') : '0', icon: Star, color: 'text-yellow-600 dark:text-yellow-400', bg: 'bg-yellow-50 dark:bg-yellow-900/20' },
].map(s => (
@@ -881,7 +889,7 @@ export function LoyaltyPage() {
Настройки программы
- setEditSettings(!editSettings)} className="btn-secondary flex items-center gap-1.5 text-sm">
+ editSettings ? saveSettings() : setEditSettings(true)} className="btn-secondary flex items-center gap-1.5 text-sm">
{editSettings ? <> Сохранить> : <> Изменить>}
@@ -941,7 +949,7 @@ export function LoyaltyPage() {
- {MOCK_GUESTS.sort((a, b) => b.points - a.points).map(g => (
+ {guests.slice(0, 10).map(g => (
@@ -953,10 +961,10 @@ export function LoyaltyPage() {
{g.name}
- {levelBadge(g.level)}
+ {levelBadge(g.loyaltyTier as LevelId)}
- {g.points.toLocaleString('ru-RU')}
+ {g.loyaltyPoints.toLocaleString('ru-RU')}
@@ -1003,7 +1011,7 @@ export function LoyaltyPage() {
)}
{tab === 'accrual' && }
- {tab === 'manual' && }
+ {tab === 'manual' && }
)
}