Files
hotelsync/src/pages/LoyaltyPage.tsx
HotelSync 5128957d7c fix: loyalty tier crash, chat duplicate rooms, sidebar defaults
- LoyaltyPage: levelBadge() now falls back to bronze for unknown tiers
  (DB had loyalty_tier='standard' from old seed data)
- migration 040: deduplicate general chat rooms + add unique index,
  normalize loyalty_tier to bronze for non-standard values
- chat.ts: ON CONFLICT clause now targets the new partial unique index
- Sidebar: DEFAULT_GROUPS only opens 'basics' by default; bump
  localStorage key to sidebarGroups_v2 to clear stale stored state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 10:58:47 +03:00

1019 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ────────────────────────────────────────────────────────────────────
type LevelId = 'bronze' | 'silver' | 'gold' | 'platinum'
interface LoyaltyLevel {
id: LevelId
name: string
minPoints: number
maxPoints: number | null
discount: number
bonusPercent: number
perks: string[]
color: string
icon: React.ElementType
}
interface ProgramSettings {
pointsPerRuble: number
pointValue: number
expiryMonths: number
isActive: boolean
}
interface AccrualRule {
id: string
label: string
description: string
type: 'per_ruble' | 'per_night' | 'flat'
value: number
unit: string
enabled: boolean
}
interface GuestPoints {
id: string
name: string
email: string
phone?: string
points: number
totalSpent: number
level: LevelId
}
interface ManualTransaction {
id: string
guestId: string
guestName: string
amount: number
reason: string
notes: string
date: string
staff: string
}
// ─── Mock Data ────────────────────────────────────────────────────────────────
const DEFAULT_LEVELS: LoyaltyLevel[] = [
{
id: 'bronze',
name: 'Бронза',
minPoints: 0,
maxPoints: 999,
discount: 0,
bonusPercent: 3,
perks: ['Накопление бонусных баллов 3%', 'Приоритет на бесплатный Wi-Fi', 'Поздний выезд по запросу'],
color: 'text-amber-700 dark:text-amber-500',
icon: User,
},
{
id: 'silver',
name: 'Серебро',
minPoints: 1000,
maxPoints: 4999,
discount: 5,
bonusPercent: 5,
perks: ['Скидка 5% на проживание', 'Накопление 5%', 'Бесплатный ранний заезд', 'Комплимент от отеля'],
color: 'text-slate-500 dark:text-slate-400',
icon: Shield,
},
{
id: 'gold',
name: 'Золото',
minPoints: 5000,
maxPoints: 14999,
discount: 10,
bonusPercent: 8,
perks: ['Скидка 10% на проживание', 'Накопление 8%', 'Гарантированный апгрейд номера', 'Приветственный напиток'],
color: 'text-yellow-500 dark:text-yellow-400',
icon: Award,
},
{
id: 'platinum',
name: 'Платина',
minPoints: 15000,
maxPoints: null,
discount: 15,
bonusPercent: 12,
perks: ['Скидка 15% на проживание', 'Накопление 12%', 'VIP-заезд/выезд', 'Персональный менеджер', 'Ежегодный бонус 500 баллов'],
color: 'text-violet-500 dark:text-violet-400',
icon: Crown,
},
]
const DEFAULT_SETTINGS: ProgramSettings = {
pointsPerRuble: 1,
pointValue: 0.5,
expiryMonths: 24,
isActive: true,
}
const INITIAL_ACCRUAL_RULES: AccrualRule[] = [
{
id: 'stay',
label: 'Проживание',
description: 'Баллы за каждую потраченную сумму на проживание',
type: 'per_ruble',
value: 1,
unit: 'балл / 1 ₽',
enabled: true,
},
{
id: 'night',
label: 'Ночи проживания',
description: 'Фиксированный бонус за каждую ночь',
type: 'per_night',
value: 50,
unit: 'баллов / ночь',
enabled: false,
},
{
id: 'room_service',
label: 'Сервис в номер',
description: 'Баллы за заказы еды и услуг в номер',
type: 'per_ruble',
value: 1,
unit: 'балл / 1 ₽',
enabled: true,
},
{
id: 'pos',
label: 'Ресторан и кафе (POS)',
description: 'Баллы за покупки через кассовый терминал',
type: 'per_ruble',
value: 1,
unit: 'балл / 1 ₽',
enabled: true,
},
{
id: 'rental',
label: 'Аренда объектов',
description: 'Баллы за аренду корта, бани, конференц-зала',
type: 'per_ruble',
value: 0.5,
unit: 'балла / 1 ₽',
enabled: true,
},
{
id: 'birthday',
label: 'День рождения',
description: 'Единоразовый бонус в день рождения гостя',
type: 'flat',
value: 200,
unit: 'баллов',
enabled: true,
},
{
id: 'direct',
label: 'Прямое бронирование',
description: 'Бонус за бронирование напрямую (не через OTA)',
type: 'flat',
value: 100,
unit: 'баллов',
enabled: true,
},
{
id: 'early',
label: 'Ранее бронирование (30+ дней)',
description: 'Бонус за бронирование заранее за 30 и более дней',
type: 'flat',
value: 150,
unit: 'баллов',
enabled: false,
},
]
const MOCK_GUESTS: GuestPoints[] = [
{ id: 'g-1', name: 'Александр Петров', email: 'a.petrov@gmail.com', phone: '+7 999 123 45 67', points: 16250, totalSpent: 287400, level: 'platinum' },
{ id: 'g-4', name: 'Елена Новикова', email: 'e.novikova@gmail.com', phone: '+7 912 000 11 22', points: 8100, totalSpent: 76500, level: 'gold' },
{ id: 'g-6', name: 'Анна Лебедева', email: 'anna.l@mail.ru', phone: '+7 926 333 44 55', points: 3950, totalSpent: 132000, level: 'silver' },
{ id: 'g-2', name: 'Мария Сидорова', email: 'm.sidorova@mail.ru', phone: '+7 903 555 66 77', points: 2200, totalSpent: 98200, level: 'silver' },
{ id: 'g-5', name: 'Игорь Волков', email: 'i.volkov@corp.ru', phone: '+7 977 888 99 00', points: 780, totalSpent: 44000, level: 'bronze' },
{ id: 'g-3', name: 'Дмитрий Козлов', email: 'dkozlov@yandex.ru', points: 340, totalSpent: 18600, level: 'bronze' },
{ id: 'g-7', name: 'Сергей Морозов', email: '', phone: '+7 916 100 20 30', points: 120, totalSpent: 8400, level: 'bronze' },
]
const MANUAL_REASONS = [
'Компенсация (жалоба гостя)',
'Бонус за отзыв',
'Ошибка начисления (корректировка)',
'Акция / спецпредложение',
'День рождения',
'VIP-бонус',
'Снятие (нарушение правил)',
'Другое',
]
function levelBadge(levelId: string) {
const level = DEFAULT_LEVELS.find(l => l.id === levelId) ?? DEFAULT_LEVELS[0]
const safeId = (DEFAULT_LEVELS.find(l => l.id === levelId)?.id ?? 'bronze') as LevelId
const bgs: Record<LevelId, string> = {
bronze: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
silver: 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300',
gold: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
platinum: 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400',
}
return (
<span className={cn('inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold', bgs[safeId])}>
<level.icon size={10} />
{level.name}
</span>
)
}
// ─── Level Editor ─────────────────────────────────────────────────────────────
function LevelCard({ level, onEdit }: { level: LoyaltyLevel; onEdit: (l: LoyaltyLevel) => void }) {
const [editing, setEditing] = useState(false)
const [form, setForm] = useState(level)
const save = () => { onEdit(form); setEditing(false) }
const levelBg: Record<LevelId, string> = {
bronze: 'from-amber-50 to-orange-50 border-amber-200 dark:from-amber-900/10 dark:to-orange-900/10 dark:border-amber-800',
silver: 'from-slate-50 to-slate-100 border-slate-200 dark:from-slate-800/50 dark:to-slate-700/50 dark:border-slate-600',
gold: 'from-yellow-50 to-amber-50 border-yellow-200 dark:from-yellow-900/10 dark:to-amber-900/10 dark:border-yellow-800',
platinum: 'from-violet-50 to-purple-50 border-violet-200 dark:from-violet-900/10 dark:to-purple-900/10 dark:border-violet-800',
}
return (
<div className={cn('rounded-2xl border bg-gradient-to-br p-4', levelBg[level.id])}>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<level.icon size={20} className={level.color} />
<span className={cn('font-bold text-base', level.color)}>{level.name}</span>
</div>
<button
onClick={() => setEditing(!editing)}
className="p-1.5 rounded-lg text-slate-400 hover:text-brand-600 hover:bg-white/60 transition-colors"
>
{editing ? <Save size={15} /> : <Edit2 size={15} />}
</button>
</div>
{editing ? (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs text-slate-500 mb-1">Мин. баллов</label>
<input type="number" className="input text-sm" value={form.minPoints}
onChange={e => setForm(p => ({ ...p, minPoints: parseInt(e.target.value) || 0 }))} />
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">Скидка %</label>
<input type="number" min={0} max={50} className="input text-sm" value={form.discount}
onChange={e => setForm(p => ({ ...p, discount: parseInt(e.target.value) || 0 }))} />
</div>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1">Начисление баллов %</label>
<input type="number" min={0} max={20} className="input text-sm" value={form.bonusPercent}
onChange={e => setForm(p => ({ ...p, bonusPercent: parseInt(e.target.value) || 0 }))} />
</div>
<button onClick={save} className="btn-primary w-full text-sm py-1.5">Сохранить</button>
</div>
) : (
<div className="space-y-1.5">
<div className="flex justify-between text-xs">
<span className="text-slate-500">Порог баллов</span>
<span className="font-semibold text-slate-700 dark:text-slate-300">
{level.minPoints.toLocaleString('ru-RU')}
{level.maxPoints ? `${level.maxPoints.toLocaleString('ru-RU')}` : '+'}
</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-slate-500">Скидка на проживание</span>
<span className={cn('font-bold', level.color)}>{level.discount}%</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-slate-500">Начисление баллов</span>
<span className="font-semibold text-slate-700 dark:text-slate-300">{level.bonusPercent}% от суммы</span>
</div>
<div className="mt-2 pt-2 border-t border-white/50 dark:border-slate-600/50 space-y-1">
{level.perks.map((perk, i) => (
<div key={i} className="flex items-start gap-1.5 text-xs text-slate-600 dark:text-slate-300">
<span className="text-emerald-500 mt-0.5 shrink-0"></span>
{perk}
</div>
))}
</div>
</div>
)}
</div>
)
}
// ─── Guest Portal Preview ─────────────────────────────────────────────────────
function GuestPortalPreview({ settings }: { settings: ProgramSettings }) {
const guest = MOCK_GUESTS[0]
const level = DEFAULT_LEVELS.find(l => l.id === guest.level)!
const nextLevel = DEFAULT_LEVELS.find(l => l.minPoints > guest.points)
const progressToNext = nextLevel
? ((guest.points - level.minPoints) / (nextLevel.minPoints - level.minPoints)) * 100
: 100
return (
<div className="rounded-2xl border-2 border-dashed border-slate-300 dark:border-slate-600 p-4">
<div className="flex items-center gap-2 mb-3">
<Smartphone size={14} className="text-slate-400" />
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wide">
Личный кабинет гостя (превью)
</p>
</div>
<div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl p-4 text-white space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-slate-400">Добро пожаловать,</p>
<p className="font-bold text-sm">{guest.name.split(' ')[0]}</p>
</div>
<div className="text-right">
<p className="text-xs text-slate-400">Уровень</p>
<div className="flex items-center gap-1">
<level.icon size={12} className={level.color} />
<p className={cn('text-sm font-bold', level.color)}>{level.name}</p>
</div>
</div>
</div>
<div className="bg-white/10 rounded-xl p-3">
<p className="text-xs text-slate-400 mb-0.5">Накопленные баллы</p>
<p className="text-3xl font-bold">{guest.points.toLocaleString('ru-RU')}</p>
<p className="text-xs text-slate-400 mt-0.5">
{Math.round(guest.points * settings.pointValue).toLocaleString('ru-RU')} к оплате
</p>
</div>
{nextLevel && (
<div>
<div className="flex justify-between text-xs text-slate-400 mb-1">
<span>До {nextLevel.name}</span>
<span>{(nextLevel.minPoints - guest.points).toLocaleString('ru-RU')} баллов</span>
</div>
<div className="h-2 rounded-full bg-white/20">
<div
className="h-2 rounded-full bg-gradient-to-r from-brand-400 to-brand-600"
style={{ width: `${Math.min(100, progressToNext)}%` }}
/>
</div>
</div>
)}
<div className="grid grid-cols-3 gap-2">
{[
{ icon: Percent, label: `${level.discount}% скидка` },
{ icon: Gift, label: 'Комплимент' },
{ icon: CreditCard, label: 'Оплата баллами' },
].map(p => (
<div key={p.label} className="bg-white/5 rounded-lg p-2 text-center">
<p.icon size={14} className="mx-auto text-brand-400 mb-1" />
<p className="text-[10px] text-slate-300">{p.label}</p>
</div>
))}
</div>
</div>
<div className="flex items-center gap-3 mt-3 pt-3 border-t border-slate-200 dark:border-slate-700">
<QrCode size={14} className="text-slate-400 shrink-0" />
<p className="text-xs text-slate-500 dark:text-slate-400">
Гость получает ссылку на личный кабинет по email после первого бронирования.
Доступ без пароля, по magic link.
</p>
</div>
</div>
)
}
// ─── Accrual Rules Tab ────────────────────────────────────────────────────────
function AccrualRulesTab() {
const [rules, setRules] = useState<AccrualRule[]>(INITIAL_ACCRUAL_RULES)
const [editingId, setEditingId] = useState<string | null>(null)
const [editValue, setEditValue] = useState(0)
const toggleRule = (id: string) => {
setRules(prev => prev.map(r => r.id === id ? { ...r, enabled: !r.enabled } : r))
}
const startEdit = (rule: AccrualRule) => {
setEditingId(rule.id)
setEditValue(rule.value)
}
const saveEdit = (id: string) => {
setRules(prev => prev.map(r => r.id === id ? { ...r, value: editValue } : r))
setEditingId(null)
}
return (
<div className="space-y-4">
<div className="flex items-start gap-3 p-3 rounded-xl bg-slate-50 dark:bg-slate-700/40 border border-slate-200 dark:border-slate-700">
<Info size={15} className="text-slate-500 dark:text-slate-400 mt-0.5 shrink-0" />
<p className="text-sm text-slate-600 dark:text-slate-400">
Правила определяют, сколько баллов начисляется гостям за различные действия. Баллы начисляются автоматически при совершении соответствующей операции.
</p>
</div>
<div className="card overflow-hidden divide-y divide-slate-100 dark:divide-slate-700">
{rules.map(rule => (
<div key={rule.id} className={cn('p-4 transition-colors', !rule.enabled && 'opacity-60')}>
<div className="flex items-start gap-4">
{/* Toggle */}
<button
type="button"
onClick={() => toggleRule(rule.id)}
className={cn(
'relative w-10 h-5 rounded-full transition-colors shrink-0 mt-0.5',
rule.enabled ? 'bg-brand-600' : 'bg-slate-200 dark:bg-slate-600',
)}
>
<div className={cn(
'absolute top-0.5 w-4 h-4 rounded-full bg-white shadow-sm transition-transform',
rule.enabled ? 'left-[22px]' : 'left-0.5',
)} />
</button>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{rule.label}</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{rule.description}</p>
</div>
{/* Value editor */}
<div className="flex items-center gap-2 shrink-0">
{editingId === rule.id ? (
<>
<input
type="number"
step="0.5"
min={0}
className="input text-sm w-24 py-1"
value={editValue}
onChange={e => setEditValue(parseFloat(e.target.value) || 0)}
autoFocus
/>
<span className="text-xs text-slate-500 whitespace-nowrap">{rule.unit}</span>
<button
onClick={() => saveEdit(rule.id)}
className="p-1.5 rounded-lg bg-brand-600 text-white hover:bg-brand-700 transition-colors"
>
<Check size={13} />
</button>
</>
) : (
<>
<span className="text-sm font-semibold text-brand-700 dark:text-brand-300 whitespace-nowrap">
{rule.value} {rule.unit}
</span>
<button
onClick={() => startEdit(rule)}
className="p-1.5 rounded-lg text-slate-400 hover:text-brand-600 hover:bg-brand-50 dark:hover:bg-brand-900/20 transition-colors"
disabled={!rule.enabled}
>
<Edit2 size={13} />
</button>
</>
)}
</div>
</div>
{/* Type badge */}
<div className="mt-1.5">
<span className={cn(
'text-[10px] font-medium px-1.5 py-0.5 rounded-full',
rule.type === 'per_ruble' ? 'bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-400' :
rule.type === 'per_night' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' :
'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400',
)}>
{rule.type === 'per_ruble' ? 'За сумму' : rule.type === 'per_night' ? 'За ночь' : 'Разовый'}
</span>
</div>
</div>
</div>
</div>
))}
</div>
</div>
)
}
// ─── Manual Accrual Tab ───────────────────────────────────────────────────────
function ManualAccrualTab({ slug }: { slug: string }) {
const [searchResults, setSearchResults] = useState<LoyaltyGuestApi[]>([])
const [search, setSearch] = useState('')
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<LoyaltyTransaction[]>([])
const [success, setSuccess] = useState(false)
const [submitting, setSubmitting] = useState(false)
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])
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
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
return (
<div className="grid grid-cols-1 xl:grid-cols-3 gap-5">
{/* Left: search + form */}
<div className="xl:col-span-2 space-y-4">
{/* Guest search */}
<div className="card p-4">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-3">Поиск гостя</h3>
<div className="relative">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
type="text"
className="input pl-9"
placeholder="Имя, телефон или email..."
value={search}
onChange={e => { setSearch(e.target.value); setSelectedGuest(null) }}
/>
</div>
{/* Search results dropdown */}
{searchResults.length > 0 && !selectedGuest && (
<div className="mt-2 border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden divide-y divide-slate-100 dark:divide-slate-700">
{searchResults.map(g => (
<button
key={g.id}
type="button"
onClick={() => { setSelectedGuest(g); setSearch(g.name) }}
className="w-full flex items-center justify-between px-3 py-2.5 hover:bg-slate-50 dark:hover:bg-slate-700/40 transition-colors text-left"
>
<div>
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{g.name}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
{g.phone ?? g.email ?? '—'}
</p>
</div>
<div className="flex items-center gap-2">
{levelBadge(g.loyaltyTier as LevelId)}
<span className="text-xs font-semibold text-slate-600 dark:text-slate-300">
{g.loyaltyPoints.toLocaleString('ru-RU')} б.
</span>
</div>
</button>
))}
</div>
)}
{search.trim() && searchResults.length === 0 && (
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">Гости не найдены</p>
)}
{/* Selected guest card */}
{selectedGuest && updatedGuest && (
<div className="mt-3 p-3 rounded-xl bg-brand-50 dark:bg-brand-900/20 border border-brand-200 dark:border-brand-800 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-brand-200 dark:bg-brand-800 flex items-center justify-center shrink-0">
<span className="text-sm font-bold text-brand-700 dark:text-brand-300">
{selectedGuest.name.split(' ').map(p => p[0]).join('').slice(0, 2)}
</span>
</div>
<div>
<p className="text-sm font-semibold text-brand-900 dark:text-brand-100">{selectedGuest.name}</p>
<p className="text-xs text-brand-600 dark:text-brand-400">{selectedGuest.email || selectedGuest.phone || '—'}</p>
</div>
</div>
<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.loyaltyPoints.toLocaleString('ru-RU')} б.
</p>
</div>
</div>
)}
</div>
{/* Operation form */}
{selectedGuest && (
<div className="card p-4 space-y-4">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300">Операция с баллами</h3>
{/* Add / Subtract toggle */}
<div className="flex gap-2">
<button
type="button"
onClick={() => setMode('add')}
className={cn(
'flex-1 flex items-center justify-center gap-2 py-2.5 rounded-xl border font-medium text-sm transition-colors',
mode === 'add'
? 'bg-emerald-600 border-emerald-600 text-white'
: 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300',
)}
>
<Plus size={15} />
Начислить баллы
</button>
<button
type="button"
onClick={() => setMode('subtract')}
className={cn(
'flex-1 flex items-center justify-center gap-2 py-2.5 rounded-xl border font-medium text-sm transition-colors',
mode === 'subtract'
? 'bg-red-600 border-red-600 text-white'
: 'bg-white dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300',
)}
>
<Minus size={15} />
Снять баллы
</button>
</div>
{/* Amount */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Количество баллов
</label>
<input
type="number"
min={1}
className="input"
placeholder="Например: 200"
value={amount}
onChange={e => setAmount(e.target.value)}
/>
{amount && !isNaN(parseInt(amount)) && selectedGuest && (
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
Баланс после операции:{' '}
<span className={cn(
'font-semibold',
mode === 'add' ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400',
)}>
{Math.max(0, (updatedGuest?.loyaltyPoints ?? 0) + (mode === 'add' ? 1 : -1) * (parseInt(amount) || 0)).toLocaleString('ru-RU')} б.
</span>
</p>
)}
</div>
{/* Reason */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Причина *
</label>
<select className="input" value={reason} onChange={e => setReason(e.target.value)}>
<option value="">Выберите причину...</option>
{MANUAL_REASONS.map(r => <option key={r} value={r}>{r}</option>)}
</select>
</div>
{/* Notes */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Комментарий
</label>
<textarea
className="input resize-none"
rows={2}
placeholder="Дополнительные сведения (необязательно)"
value={notes}
onChange={e => setNotes(e.target.value)}
/>
</div>
{success && (
<div className="flex items-center gap-2 p-3 rounded-xl bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800">
<Check size={15} className="text-emerald-600 dark:text-emerald-400 shrink-0" />
<p className="text-sm text-emerald-700 dark:text-emerald-300 font-medium">Операция выполнена успешно</p>
</div>
)}
<button
onClick={() => { void handleSubmit() }}
disabled={!amount || !reason || submitting}
className={cn(
'w-full py-2.5 rounded-xl font-medium text-sm transition-colors',
mode === 'add'
? 'bg-emerald-600 hover:bg-emerald-700 text-white disabled:bg-slate-200 disabled:text-slate-400'
: 'bg-red-600 hover:bg-red-700 text-white disabled:bg-slate-200 disabled:text-slate-400',
)}
>
{mode === 'add' ? 'Начислить баллы' : 'Снять баллы'}
</button>
</div>
)}
</div>
{/* Right: recent transactions */}
<div>
<div className="card overflow-hidden">
<div className="px-4 py-3 border-b border-slate-200 dark:border-slate-700">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300">История ручных операций</h3>
</div>
{transactions.length === 0 ? (
<div className="py-10 text-center">
<AlertCircle size={28} className="mx-auto text-slate-300 dark:text-slate-600 mb-2" />
<p className="text-sm text-slate-500 dark:text-slate-400">Операций пока нет</p>
</div>
) : (
<div className="divide-y divide-slate-100 dark:divide-slate-700">
{transactions.map(tx => (
<div key={tx.id} className="px-4 py-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">{tx.guestName}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">{tx.reason}</p>
{tx.notes && <p className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{tx.notes}</p>}
</div>
<div className="text-right shrink-0">
<p className={cn(
'text-sm font-bold',
tx.amount > 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400',
)}>
{tx.amount > 0 ? '+' : ''}{tx.amount.toLocaleString('ru-RU')} б.
</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>
</div>
)
}
// ─── Main Page ────────────────────────────────────────────────────────────────
const PAGE_TABS = [
{ id: 'overview', label: 'Обзор' },
{ id: 'accrual', label: 'Правила начисления' },
{ id: 'manual', label: 'Ручное начисление' },
] as const
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 = 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">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Программа лояльности</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
Уровни, бонусные баллы, личный кабинет гостя
</p>
</div>
<div className="flex items-center gap-3">
<span className={cn(
'px-3 py-1.5 rounded-xl text-sm font-medium border',
settings.isActive
? 'bg-emerald-50 text-emerald-700 border-emerald-300 dark:bg-emerald-900/20 dark:text-emerald-400 dark:border-emerald-700'
: 'bg-slate-100 text-slate-500 border-slate-200 dark:bg-slate-700 dark:text-slate-400 dark:border-slate-600',
)}>
{settings.isActive ? '● Программа активна' : '○ Программа отключена'}
</span>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{[
{ 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: 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)}>
<s.icon size={18} className={s.color} />
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400 leading-tight">{s.label}</p>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100 mt-0.5">{s.value}</p>
</div>
</div>
))}
</div>
{/* Tabs */}
<div className="flex gap-1 border-b border-slate-200 dark:border-slate-700">
{PAGE_TABS.map(t => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={cn(
'px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors',
tab === t.id
? 'border-brand-600 text-brand-600 dark:text-brand-400 dark:border-brand-400'
: 'border-transparent text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300',
)}
>
{t.label}
</button>
))}
</div>
{tab === 'overview' && (
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div className="xl:col-span-2 space-y-5">
{/* Program settings */}
<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={() => editSettings ? saveSettings() : setEditSettings(true)} className="btn-secondary flex items-center gap-1.5 text-sm">
{editSettings ? <><Save size={13} /> Сохранить</> : <><Edit2 size={13} /> Изменить</>}
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{[
{ label: 'Начисление', desc: `${settings.pointsPerRuble} балл за 1 ₽`, field: 'pointsPerRuble' as const, unit: 'балл/₽' },
{ label: 'Ценность', desc: `1 балл = ${settings.pointValue}`, field: 'pointValue' as const, unit: '₽' },
{ label: 'Срок действия', desc: `${settings.expiryMonths} месяцев`, field: 'expiryMonths' as const, unit: 'мес.' },
].map(item => (
<div key={item.field}>
<p className="text-xs text-slate-500 dark:text-slate-400">{item.label}</p>
{editSettings ? (
<div className="flex items-center gap-1 mt-1">
<input
type="number" step="0.1" min={0}
className="input text-sm"
value={settings[item.field]}
onChange={e => setSettings(prev => ({
...prev,
[item.field]: parseFloat(e.target.value) || 0,
}))}
/>
<span className="text-xs text-slate-400 shrink-0">{item.unit}</span>
</div>
) : (
<p className="text-base font-semibold text-slate-900 dark:text-slate-100 mt-0.5">{item.desc}</p>
)}
</div>
))}
</div>
</div>
{/* Level cards */}
<div>
<h2 className="text-sm font-semibold text-slate-600 dark:text-slate-400 uppercase tracking-wide mb-3">
Уровни программы
</h2>
<div className="grid grid-cols-2 gap-3">
{levels.map(level => (
<LevelCard key={level.id} level={level} onEdit={updateLevel} />
))}
</div>
</div>
{/* Top guests */}
<div className="card overflow-hidden">
<div className="px-4 py-3 border-b border-slate-200 dark:border-slate-700">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300">Топ участников</h3>
</div>
<table className="w-full">
<thead>
<tr className="bg-slate-50 dark:bg-slate-700/30">
<th className="px-4 py-2 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">Гость</th>
<th className="px-4 py-2 text-left text-xs font-semibold text-slate-500 uppercase tracking-wide">Уровень</th>
<th className="px-4 py-2 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">Баллы</th>
<th className="px-4 py-2 text-right text-xs font-semibold text-slate-500 uppercase tracking-wide">Потрачено</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-700/50">
{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">
<div className="w-7 h-7 rounded-full bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center shrink-0">
<span className="text-xs font-bold text-brand-600 dark:text-brand-400">
{g.name.split(' ').map(p => p[0]).join('').slice(0, 2)}
</span>
</div>
<span className="text-sm text-slate-700 dark:text-slate-300">{g.name}</span>
</div>
</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.loyaltyPoints.toLocaleString('ru-RU')}
</span>
</td>
<td className="px-4 py-3 text-right">
<span className="text-sm text-slate-600 dark:text-slate-400">
{g.totalSpent.toLocaleString('ru-RU')}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Right column */}
<div className="space-y-4">
<GuestPortalPreview settings={settings} />
<div className="card p-4 space-y-3">
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300">Как работает программа</h3>
{[
{ icon: CreditCard, text: 'При каждом бронировании гостю начисляются баллы пропорционально потраченной сумме' },
{ icon: Percent, text: 'Накопленные баллы можно списать при следующем бронировании' },
{ icon: Mail, text: 'После первого визита гость получает email с ссылкой на личный кабинет' },
{ icon: Award, text: 'При достижении нового уровня приходит уведомление с перечнем привилегий' },
].map((item, i) => (
<div key={i} className="flex items-start gap-2.5">
<div className="w-6 h-6 rounded-lg bg-brand-50 dark:bg-brand-900/20 flex items-center justify-center shrink-0 mt-0.5">
<item.icon size={12} className="text-brand-600 dark:text-brand-400" />
</div>
<p className="text-xs text-slate-600 dark:text-slate-400">{item.text}</p>
</div>
))}
</div>
<div className="flex items-start gap-3 p-3 rounded-xl bg-sky-50 dark:bg-sky-900/10 border border-sky-200 dark:border-sky-800">
<Info size={14} className="text-sky-600 dark:text-sky-400 mt-0.5 shrink-0" />
<p className="text-xs text-sky-700 dark:text-sky-400">
Программа лояльности работает в рамках вашего отеля. Баллы гостей хранятся в HotelSync и привязаны к конкретному объекту размещения.
</p>
</div>
</div>
</div>
)}
{tab === 'accrual' && <AccrualRulesTab />}
{tab === 'manual' && <ManualAccrualTab slug={slug} />}
</div>
)
}