feat: wire up loyalty + chat — register routes, API types, connect LoyaltyPage, add ChatWidget to layout
This commit is contained in:
@@ -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