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 = { 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 ( {level.name} ) } // ─── 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 = { 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 (
{level.name}
{editing ? (
setForm(p => ({ ...p, minPoints: parseInt(e.target.value) || 0 }))} />
setForm(p => ({ ...p, discount: parseInt(e.target.value) || 0 }))} />
setForm(p => ({ ...p, bonusPercent: parseInt(e.target.value) || 0 }))} />
) : (
Порог баллов {level.minPoints.toLocaleString('ru-RU')} {level.maxPoints ? ` — ${level.maxPoints.toLocaleString('ru-RU')}` : '+'}
Скидка на проживание {level.discount}%
Начисление баллов {level.bonusPercent}% от суммы
{level.perks.map((perk, i) => (
{perk}
))}
)}
) } // ─── 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 (

Личный кабинет гостя (превью)

Добро пожаловать,

{guest.name.split(' ')[0]}

Уровень

{level.name}

Накопленные баллы

{guest.points.toLocaleString('ru-RU')}

≈ {Math.round(guest.points * settings.pointValue).toLocaleString('ru-RU')} ₽ к оплате

{nextLevel && (
До {nextLevel.name} {(nextLevel.minPoints - guest.points).toLocaleString('ru-RU')} баллов
)}
{[ { icon: Percent, label: `${level.discount}% скидка` }, { icon: Gift, label: 'Комплимент' }, { icon: CreditCard, label: 'Оплата баллами' }, ].map(p => (

{p.label}

))}

Гость получает ссылку на личный кабинет по email после первого бронирования. Доступ — без пароля, по magic link.

) } // ─── Accrual Rules Tab ──────────────────────────────────────────────────────── function AccrualRulesTab() { const [rules, setRules] = useState(INITIAL_ACCRUAL_RULES) const [editingId, setEditingId] = useState(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 (

Правила определяют, сколько баллов начисляется гостям за различные действия. Баллы начисляются автоматически при совершении соответствующей операции.

{rules.map(rule => (
{/* Toggle */} {/* Info */}

{rule.label}

{rule.description}

{/* Value editor */}
{editingId === rule.id ? ( <> setEditValue(parseFloat(e.target.value) || 0)} autoFocus /> {rule.unit} ) : ( <> {rule.value} {rule.unit} )}
{/* Type badge */}
{rule.type === 'per_ruble' ? 'За сумму' : rule.type === 'per_night' ? 'За ночь' : 'Разовый'}
))}
) } // ─── Manual Accrual Tab ─────────────────────────────────────────────────────── function ManualAccrualTab({ slug }: { slug: string }) { const [searchResults, setSearchResults] = useState([]) const [search, setSearch] = useState('') const [selectedGuest, setSelectedGuest] = useState(null) const [mode, setMode] = useState<'add' | 'subtract'>('add') const [amount, setAmount] = useState('') const [reason, setReason] = useState('') const [notes, setNotes] = useState('') const [transactions, setTransactions] = useState([]) 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 (
{/* Left: search + form */}
{/* Guest search */}

Поиск гостя

{ setSearch(e.target.value); setSelectedGuest(null) }} />
{/* Search results dropdown */} {searchResults.length > 0 && !selectedGuest && (
{searchResults.map(g => ( ))}
)} {search.trim() && searchResults.length === 0 && (

Гости не найдены

)} {/* Selected guest card */} {selectedGuest && updatedGuest && (
{selectedGuest.name.split(' ').map(p => p[0]).join('').slice(0, 2)}

{selectedGuest.name}

{selectedGuest.email || selectedGuest.phone || '—'}

Текущий баланс

{updatedGuest.loyaltyPoints.toLocaleString('ru-RU')} б.

)}
{/* Operation form */} {selectedGuest && (

Операция с баллами

{/* Add / Subtract toggle */}
{/* Amount */}
setAmount(e.target.value)} /> {amount && !isNaN(parseInt(amount)) && selectedGuest && (

Баланс после операции:{' '} {Math.max(0, (updatedGuest?.loyaltyPoints ?? 0) + (mode === 'add' ? 1 : -1) * (parseInt(amount) || 0)).toLocaleString('ru-RU')} б.

)}
{/* Reason */}
{/* Notes */}