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,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 { 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() {
<Outlet />
</main>
</div>
<ChatWidget />
</div>
)
}

View File

@@ -287,6 +287,34 @@ export const api = {
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: {
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<string, unknown> {
const out: Record<string, unknown> = {}
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 {
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<GuestPoints[]>(MOCK_GUESTS)
function ManualAccrualTab({ slug }: { slug: string }) {
const [searchResults, setSearchResults] = useState<LoyaltyGuestApi[]>([])
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 [amount, setAmount] = useState('')
const [reason, setReason] = useState('')
const [notes, setNotes] = useState('')
const [transactions, setTransactions] = useState<ManualTransaction[]>([])
const [transactions, setTransactions] = useState<LoyaltyTransaction[]>([])
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 (
<div className="grid grid-cols-1 xl:grid-cols-3 gap-5">
@@ -604,9 +596,9 @@ function ManualAccrualTab() {
</p>
</div>
<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">
{g.points.toLocaleString('ru-RU')} б.
{g.loyaltyPoints.toLocaleString('ru-RU')} б.
</span>
</div>
</button>
@@ -635,7 +627,7 @@ function ManualAccrualTab() {
<div className="text-right">
<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">
{updatedGuest.points.toLocaleString('ru-RU')} б.
{updatedGuest.loyaltyPoints.toLocaleString('ru-RU')} б.
</p>
</div>
</div>
@@ -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')} б.
</span>
</p>
)}
@@ -736,8 +728,8 @@ function ManualAccrualTab() {
)}
<button
onClick={handleSubmit}
disabled={!amount || !reason}
onClick={() => { 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')} б.
</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>
@@ -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<PageTab>('overview')
const [levels, setLevels] = useState<LoyaltyLevel[]>(DEFAULT_LEVELS)
const [settings, setSettings] = useState<ProgramSettings>(DEFAULT_SETTINGS)
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) =>
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 (
<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: 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 => (
<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)}>
@@ -881,7 +889,7 @@ export function LoyaltyPage() {
<div className="card p-4">
<div className="flex items-center justify-between mb-4">
<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} /> Изменить</>}
</button>
</div>
@@ -941,7 +949,7 @@ export function LoyaltyPage() {
</tr>
</thead>
<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">
<td className="px-4 py-3">
<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>
</div>
</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">
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{g.points.toLocaleString('ru-RU')}
{g.loyaltyPoints.toLocaleString('ru-RU')}
</span>
</td>
<td className="px-4 py-3 text-right">
@@ -1003,7 +1011,7 @@ export function LoyaltyPage() {
)}
{tab === 'accrual' && <AccrualRulesTab />}
{tab === 'manual' && <ManualAccrualTab />}
{tab === 'manual' && <ManualAccrualTab slug={slug} />}
</div>
)
}