feat: wire up loyalty + chat — register routes, API types, connect LoyaltyPage, add ChatWidget to layout

This commit is contained in:
2026-03-26 10:15:29 +03:00
parent 8ede920e5d
commit 113971f854
9 changed files with 964 additions and 63 deletions

View File

@@ -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;

View File

@@ -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)
);

View File

@@ -31,6 +31,8 @@ import uploadRoutes from './routes/upload'
import housekeepingSettingsRoutes from './routes/housekeeping-settings' import housekeepingSettingsRoutes from './routes/housekeeping-settings'
import notificationsRoutes from './routes/notifications' import notificationsRoutes from './routes/notifications'
import scheduleRoutes from './routes/schedule' import scheduleRoutes from './routes/schedule'
import loyaltyRoutes from './routes/loyalty'
import chatRoutes from './routes/chat'
import { startJobs } from './jobs' import { startJobs } from './jobs'
export async function buildApp() { export async function buildApp() {
@@ -107,6 +109,8 @@ export async function buildApp() {
await fastify.register(housekeepingSettingsRoutes) await fastify.register(housekeepingSettingsRoutes)
await fastify.register(notificationsRoutes) await fastify.register(notificationsRoutes)
await fastify.register(scheduleRoutes) await fastify.register(scheduleRoutes)
await fastify.register(loyaltyRoutes)
await fastify.register(chatRoutes)
startJobs() startJobs()

190
backend/src/routes/chat.ts Normal file
View File

@@ -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<SlugParam>(
'/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<RoomParam & { Querystring: { before?: string; limit?: string } }>(
'/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<RoomParam & { Body: { text: string } }>(
'/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<RoomParam>(
'/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

View File

@@ -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<string | null> => {
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<SlugParam>(
'/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<LoyaltySettingsRow>(
'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<SlugParam & { Body: LoyaltySettingsBody }>(
'/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<LoyaltySettingsRow>(
'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<LoyaltySettingsRow>(
'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<SlugParam & { Querystring: { q?: string } }>(
'/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<string, unknown>) => ({
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<SlugGuestParam & { Body: AddPointsBody }>(
'/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<SlugParam & { Querystring: { limit?: string } }>(
'/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<string, unknown>) => ({
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

View File

@@ -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 (
<div style={{ width: size, height: size, background: avatarColor(name), fontSize: size * 0.38 }}
className="rounded-full flex items-center justify-center text-white font-semibold shrink-0">
{initials(name)}
</div>
)
}
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<ChatRoom[]>([])
const [activeRoom, setActiveRoom] = useState<ChatRoom | null>(null)
const [messages, setMessages] = useState<ChatMessage[]>([])
const [text, setText] = useState('')
const [loadingRooms, setLoadingRooms] = useState(false)
const [loadingMsgs, setLoadingMsgs] = useState(false)
const [sending, setSending] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const pollRef = useRef<ReturnType<typeof setInterval> | 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 */}
<button
onClick={() => 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 ? <X size={22} /> : <MessageSquare size={22} />}
{!open && totalUnread > 0 && (
<span className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-red-500 text-white text-[10px] font-bold flex items-center justify-center">
{totalUnread > 9 ? '9+' : totalUnread}
</span>
)}
</button>
{/* Chat panel */}
{open && (
<div className={cn(
'fixed bottom-24 right-6 z-50',
'w-80 bg-white dark:bg-slate-800 rounded-2xl shadow-2xl',
'border border-slate-200 dark:border-slate-700',
'flex flex-col overflow-hidden',
'transition-all',
)} style={{ height: 480 }}>
{/* Header */}
<div className="flex items-center gap-2 px-4 py-3 border-b border-slate-200 dark:border-slate-700 bg-brand-600 text-white rounded-t-2xl shrink-0">
{view === 'messages' && (
<button onClick={() => { setView('rooms'); setActiveRoom(null) }} className="p-1 hover:bg-white/20 rounded-lg transition-colors">
<ChevronLeft size={16} />
</button>
)}
<div className="flex-1 min-w-0">
<p className="font-semibold text-sm truncate">
{view === 'rooms' ? 'Чат сотрудников' : (activeRoom?.type === 'general' ? 'Общий чат' : activeRoom?.otherUserName ?? 'Чат')}
</p>
{view === 'rooms' && (
<p className="text-xs text-white/70">{rooms.length} чатов</p>
)}
</div>
<Users size={16} className="opacity-70" />
</div>
{/* Rooms list */}
{view === 'rooms' && (
<div className="flex-1 overflow-y-auto">
{loadingRooms ? (
<div className="flex items-center justify-center h-32">
<Loader2 size={20} className="animate-spin text-slate-400" />
</div>
) : rooms.length === 0 ? (
<div className="text-center py-10 text-sm text-slate-400 px-4">
Нет чатов. Начните общение!
</div>
) : (
rooms.map(room => (
<button
key={room.id}
onClick={() => 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"
>
<div className="relative shrink-0">
{room.type === 'general' ? (
<div className="w-9 h-9 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center">
<Users size={16} className="text-brand-600 dark:text-brand-400" />
</div>
) : (
<Avatar name={room.otherUserName ?? '?'} size={36} />
)}
{Number(room.unreadCount) > 0 && (
<span className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-red-500 text-white text-[9px] font-bold flex items-center justify-center">
{room.unreadCount}
</span>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<p className={cn('text-sm font-medium truncate', Number(room.unreadCount) > 0 ? 'text-slate-900 dark:text-slate-100' : 'text-slate-700 dark:text-slate-300')}>
{room.type === 'general' ? 'Общий чат' : room.otherUserName}
</p>
{room.lastMessageAt && (
<span className="text-[10px] text-slate-400 shrink-0 ml-1">
{fmtTime(room.lastMessageAt)}
</span>
)}
</div>
{room.lastMessage && (
<p className={cn('text-xs truncate mt-0.5', Number(room.unreadCount) > 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}
</p>
)}
</div>
</button>
))
)}
</div>
)}
{/* Messages view */}
{view === 'messages' && (
<>
<div className="flex-1 overflow-y-auto px-3 py-3 space-y-2">
{loadingMsgs ? (
<div className="flex items-center justify-center h-32">
<Loader2 size={20} className="animate-spin text-slate-400" />
</div>
) : messages.length === 0 ? (
<div className="text-center py-10 text-sm text-slate-400">
Нет сообщений. Напишите первым!
</div>
) : (
messages.map(msg => {
const isOwn = msg.senderId === user?.id
return (
<div key={msg.id} className={cn('flex gap-2', isOwn && 'flex-row-reverse')}>
{!isOwn && <Avatar name={msg.senderName} size={24} />}
<div className={cn('max-w-[75%]', isOwn && 'items-end flex flex-col')}>
{!isOwn && (
<p className="text-[10px] text-slate-400 mb-0.5 ml-1">{msg.senderName.split(' ')[0]}</p>
)}
<div className={cn(
'px-3 py-2 rounded-2xl text-sm',
isOwn
? 'bg-brand-600 text-white rounded-tr-sm'
: 'bg-slate-100 dark:bg-slate-700 text-slate-800 dark:text-slate-200 rounded-tl-sm',
)}>
{msg.text}
</div>
<p className="text-[10px] text-slate-400 mt-0.5 mx-1">{fmtTime(msg.createdAt)}</p>
</div>
</div>
)
})
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="px-3 py-3 border-t border-slate-200 dark:border-slate-700 shrink-0">
<div className="flex items-end gap-2">
<textarea
value={text}
onChange={e => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Сообщение..."
rows={1}
className={cn(
'flex-1 resize-none rounded-xl px-3 py-2 text-sm',
'bg-slate-100 dark:bg-slate-700 text-slate-900 dark:text-slate-100',
'placeholder-slate-400 outline-none',
'max-h-24 overflow-y-auto',
)}
/>
<button
onClick={() => void sendMessage()}
disabled={!text.trim() || sending}
className="p-2.5 rounded-xl bg-brand-600 hover:bg-brand-700 disabled:opacity-40 text-white transition-colors shrink-0"
>
{sending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
</button>
</div>
<p className="text-[10px] text-slate-400 mt-1.5">Enter отправить, Shift+Enter перенос</p>
</div>
</>
)}
</div>
)}
</>
)
}

View File

@@ -2,6 +2,7 @@ import { Outlet, Navigate } from 'react-router-dom'
import { useState } from 'react' import { useState } from 'react'
import { Sidebar } from '../components/layout/Sidebar' import { Sidebar } from '../components/layout/Sidebar'
import { Topbar } from '../components/layout/Topbar' import { Topbar } from '../components/layout/Topbar'
import { ChatWidget } from '../components/chat/ChatWidget'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
export function AppLayout() { export function AppLayout() {
@@ -20,6 +21,8 @@ export function AppLayout() {
<Outlet /> <Outlet />
</main> </main>
</div> </div>
<ChatWidget />
</div> </div>
) )
} }

View File

@@ -287,6 +287,34 @@ export const api = {
req<void>('DELETE', `/api/hotels/${slug}/schedule/${userId}/${date}`), req<void>('DELETE', `/api/hotels/${slug}/schedule/${userId}/${date}`),
}, },
// ── Loyalty ───────────────────────────────────────────────────────────────
loyalty: {
getSettings: (slug: string) =>
req<LoyaltySettings>('GET', `/api/hotels/${slug}/loyalty/settings`),
saveSettings: (slug: string, data: Partial<LoyaltySettings>) =>
req<LoyaltySettings>('PATCH', `/api/hotels/${slug}/loyalty/settings`, data),
listGuests: (slug: string, q?: string) =>
req<LoyaltyGuestApi[]>('GET', `/api/hotels/${slug}/loyalty/guests${q ? `?q=${encodeURIComponent(q)}` : ''}`),
addPoints: (slug: string, guestId: string, data: { amount: number; reason: string; notes?: string }) =>
req<LoyaltyGuestApi>('POST', `/api/hotels/${slug}/loyalty/guests/${guestId}/points`, data),
listTransactions: (slug: string, limit = 50) =>
req<LoyaltyTransaction[]>('GET', `/api/hotels/${slug}/loyalty/transactions?limit=${limit}`),
},
// ── Chat ──────────────────────────────────────────────────────────────────
chat: {
listRooms: (slug: string) =>
req<ChatRoom[]>('GET', `/api/hotels/${slug}/chat/rooms`),
getMessages: (slug: string, roomId: string, limit = 50) =>
req<ChatMessage[]>('GET', `/api/hotels/${slug}/chat/rooms/${roomId}/messages?limit=${limit}`),
sendMessage: (slug: string, roomId: string, text: string) =>
req<ChatMessage>('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 ────────────────────────────────────────────────────────────────
hotels: { hotels: {
get: (slug: string) => get: (slug: string) =>
@@ -867,6 +895,56 @@ export interface RatePeriodPayload {
days_of_week?: number[] | null 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<string, unknown> { function toHotelPayload(h: HotelPayload): Record<string, unknown> {
const out: Record<string, unknown> = {} const out: Record<string, unknown> = {}
if (h.name !== undefined) out.name = h.name if (h.name !== undefined) out.name = h.name

View File

@@ -1,10 +1,12 @@
import { useState } from 'react' import { useState, useEffect, useCallback } from 'react'
import { import {
Award, Star, Gift, Percent, CreditCard, Edit2, Save, Info, Award, Star, Gift, Percent, CreditCard, Edit2, Save, Info,
Crown, Shield, User, ChevronRight, Smartphone, Mail, QrCode, Crown, Shield, User, ChevronRight, Smartphone, Mail, QrCode,
TrendingUp, Search, Plus, Minus, Check, AlertCircle, Phone, TrendingUp, Search, Plus, Minus, Check, AlertCircle, Phone,
} from 'lucide-react' } from 'lucide-react'
import { cn } from '../lib/utils' import { cn } from '../lib/utils'
import { api, type LoyaltyGuestApi, type LoyaltyTransaction } from '../lib/api'
import { useAuth } from '../contexts/AuthContext'
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@@ -509,65 +511,55 @@ function AccrualRulesTab() {
// ─── Manual Accrual Tab ─────────────────────────────────────────────────────── // ─── Manual Accrual Tab ───────────────────────────────────────────────────────
function ManualAccrualTab() { function ManualAccrualTab({ slug }: { slug: string }) {
const [guests, setGuests] = useState<GuestPoints[]>(MOCK_GUESTS) const [searchResults, setSearchResults] = useState<LoyaltyGuestApi[]>([])
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [selectedGuest, setSelectedGuest] = useState<GuestPoints | null>(null) const [selectedGuest, setSelectedGuest] = useState<LoyaltyGuestApi | null>(null)
const [mode, setMode] = useState<'add' | 'subtract'>('add') const [mode, setMode] = useState<'add' | 'subtract'>('add')
const [amount, setAmount] = useState('') const [amount, setAmount] = useState('')
const [reason, setReason] = useState('') const [reason, setReason] = useState('')
const [notes, setNotes] = useState('') const [notes, setNotes] = useState('')
const [transactions, setTransactions] = useState<ManualTransaction[]>([]) const [transactions, setTransactions] = useState<LoyaltyTransaction[]>([])
const [success, setSuccess] = useState(false) const [success, setSuccess] = useState(false)
const [submitting, setSubmitting] = useState(false)
const searchResults = search.trim() const doSearch = useCallback(async (q: string) => {
? guests.filter(g => if (!q.trim() || !slug) { setSearchResults([]); return }
g.name.toLowerCase().includes(search.toLowerCase()) || try {
g.email.toLowerCase().includes(search.toLowerCase()) || const data = await api.loyalty.listGuests(slug, q)
(g.phone ?? '').replace(/\D/g, '').includes(search.replace(/\D/g, '')) setSearchResults(data)
) } catch { setSearchResults([]) }
: [] }, [slug])
const handleSubmit = () => { useEffect(() => {
if (!selectedGuest || !amount || !reason) return 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) const pts = parseInt(amount)
if (isNaN(pts) || pts <= 0) return if (isNaN(pts) || pts <= 0) return
const delta = mode === 'add' ? pts : -pts const delta = mode === 'add' ? pts : -pts
setGuests(prev => prev.map(g => setSubmitting(true)
g.id === selectedGuest.id try {
? { ...g, points: Math.max(0, g.points + delta) } const updated = await api.loyalty.addPoints(slug, selectedGuest.id, { amount: delta, reason, notes: notes || undefined })
: g setSelectedGuest(updated)
))
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('') setAmount('')
setNotes('') setNotes('')
setReason('') setReason('')
setSuccess(true) setSuccess(true)
setTimeout(() => setSuccess(false), 3000) setTimeout(() => setSuccess(false), 3000)
} catch { /* ignore */ }
finally { setSubmitting(false) }
} }
const updatedGuest = selectedGuest const updatedGuest = selectedGuest
? guests.find(g => g.id === selectedGuest.id) ?? selectedGuest
: null
return ( return (
<div className="grid grid-cols-1 xl:grid-cols-3 gap-5"> <div className="grid grid-cols-1 xl:grid-cols-3 gap-5">
@@ -604,9 +596,9 @@ function ManualAccrualTab() {
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{levelBadge(g.level)} {levelBadge(g.loyaltyTier as LevelId)}
<span className="text-xs font-semibold text-slate-600 dark:text-slate-300"> <span className="text-xs font-semibold text-slate-600 dark:text-slate-300">
{g.points.toLocaleString('ru-RU')} б. {g.loyaltyPoints.toLocaleString('ru-RU')} б.
</span> </span>
</div> </div>
</button> </button>
@@ -635,7 +627,7 @@ function ManualAccrualTab() {
<div className="text-right"> <div className="text-right">
<p className="text-xs text-brand-600 dark:text-brand-400">Текущий баланс</p> <p className="text-xs text-brand-600 dark:text-brand-400">Текущий баланс</p>
<p className="text-lg font-bold text-brand-700 dark:text-brand-300"> <p className="text-lg font-bold text-brand-700 dark:text-brand-300">
{updatedGuest.points.toLocaleString('ru-RU')} б. {updatedGuest.loyaltyPoints.toLocaleString('ru-RU')} б.
</p> </p>
</div> </div>
</div> </div>
@@ -697,7 +689,7 @@ function ManualAccrualTab() {
'font-semibold', 'font-semibold',
mode === 'add' ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400', 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')} б.
</span> </span>
</p> </p>
)} )}
@@ -736,8 +728,8 @@ function ManualAccrualTab() {
)} )}
<button <button
onClick={handleSubmit} onClick={() => { void handleSubmit() }}
disabled={!amount || !reason} disabled={!amount || !reason || submitting}
className={cn( className={cn(
'w-full py-2.5 rounded-xl font-medium text-sm transition-colors', 'w-full py-2.5 rounded-xl font-medium text-sm transition-colors',
mode === 'add' mode === 'add'
@@ -779,7 +771,7 @@ function ManualAccrualTab() {
)}> )}>
{tx.amount > 0 ? '+' : ''}{tx.amount.toLocaleString('ru-RU')} б. {tx.amount > 0 ? '+' : ''}{tx.amount.toLocaleString('ru-RU')} б.
</p> </p>
<p className="text-[10px] text-slate-400">{tx.date} · {tx.staff}</p> <p className="text-[10px] text-slate-400">{new Date(tx.createdAt).toLocaleDateString('ru-RU')}{tx.staffName ? ` · ${tx.staffName}` : ''}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -802,17 +794,33 @@ const PAGE_TABS = [
type PageTab = typeof PAGE_TABS[number]['id'] type PageTab = typeof PAGE_TABS[number]['id']
export function LoyaltyPage() { export function LoyaltyPage() {
const { user } = useAuth()
const slug = user?.hotelSlug ?? ''
const [tab, setTab] = useState<PageTab>('overview') const [tab, setTab] = useState<PageTab>('overview')
const [levels, setLevels] = useState<LoyaltyLevel[]>(DEFAULT_LEVELS) const [levels, setLevels] = useState<LoyaltyLevel[]>(DEFAULT_LEVELS)
const [settings, setSettings] = useState<ProgramSettings>(DEFAULT_SETTINGS) const [settings, setSettings] = useState<ProgramSettings>(DEFAULT_SETTINGS)
const [editSettings, setEditSettings] = useState(false) const [editSettings, setEditSettings] = useState(false)
const [guests, setGuests] = useState<LoyaltyGuestApi[]>([])
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) => const updateLevel = (updated: LoyaltyLevel) =>
setLevels(prev => prev.map(l => l.id === updated.id ? updated : l)) setLevels(prev => prev.map(l => l.id === updated.id ? updated : l))
const totalGuests = MOCK_GUESTS.length const totalGuests = guests.length
const activeGuests = MOCK_GUESTS.filter(g => g.points > 0).length const activeGuests = guests.filter(g => g.loyaltyPoints > 0).length
const totalPoints = MOCK_GUESTS.reduce((s, g) => s + g.points, 0) const totalPoints = guests.reduce((s, g) => s + g.loyaltyPoints, 0)
return ( return (
<div className="p-4 md:p-6 space-y-5"> <div className="p-4 md:p-6 space-y-5">
@@ -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: 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: 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: 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: 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: 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 => ( ].map(s => (
<div key={s.label} className="card p-4 flex items-center gap-3"> <div key={s.label} className="card p-4 flex items-center gap-3">
<div className={cn('w-10 h-10 rounded-xl flex items-center justify-center shrink-0', s.bg)}> <div className={cn('w-10 h-10 rounded-xl flex items-center justify-center shrink-0', s.bg)}>
@@ -881,7 +889,7 @@ export function LoyaltyPage() {
<div className="card p-4"> <div className="card p-4">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300">Настройки программы</h2> <h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300">Настройки программы</h2>
<button onClick={() => setEditSettings(!editSettings)} className="btn-secondary flex items-center gap-1.5 text-sm"> <button onClick={() => editSettings ? saveSettings() : setEditSettings(true)} className="btn-secondary flex items-center gap-1.5 text-sm">
{editSettings ? <><Save size={13} /> Сохранить</> : <><Edit2 size={13} /> Изменить</>} {editSettings ? <><Save size={13} /> Сохранить</> : <><Edit2 size={13} /> Изменить</>}
</button> </button>
</div> </div>
@@ -941,7 +949,7 @@ export function LoyaltyPage() {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-700/50"> <tbody className="divide-y divide-slate-100 dark:divide-slate-700/50">
{MOCK_GUESTS.sort((a, b) => b.points - a.points).map(g => ( {guests.slice(0, 10).map(g => (
<tr key={g.id} className="hover:bg-slate-50 dark:hover:bg-slate-700/20 transition-colors"> <tr key={g.id} className="hover:bg-slate-50 dark:hover:bg-slate-700/20 transition-colors">
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -953,10 +961,10 @@ export function LoyaltyPage() {
<span className="text-sm text-slate-700 dark:text-slate-300">{g.name}</span> <span className="text-sm text-slate-700 dark:text-slate-300">{g.name}</span>
</div> </div>
</td> </td>
<td className="px-4 py-3">{levelBadge(g.level)}</td> <td className="px-4 py-3">{levelBadge(g.loyaltyTier as LevelId)}</td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right">
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100"> <span className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{g.points.toLocaleString('ru-RU')} {g.loyaltyPoints.toLocaleString('ru-RU')}
</span> </span>
</td> </td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right">
@@ -1003,7 +1011,7 @@ export function LoyaltyPage() {
)} )}
{tab === 'accrual' && <AccrualRulesTab />} {tab === 'accrual' && <AccrualRulesTab />}
{tab === 'manual' && <ManualAccrualTab />} {tab === 'manual' && <ManualAccrualTab slug={slug} />}
</div> </div>
) )
} }