feat: wire up loyalty + chat — register routes, API types, connect LoyaltyPage, add ChatWidget to layout
This commit is contained in:
294
src/components/chat/ChatWidget.tsx
Normal file
294
src/components/chat/ChatWidget.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user