Files
hotelsync/src/pages/LoyaltyPage.tsx
HotelSync 78788f68b0 Add pricing, tariffs, loyalty, maintenance, discounts features
- TariffsPage (/tariffs): rate plan builder — meal plans (RO/BB/HB/FB/AI),
  inclusions picker, price modifier (% or ₽), min nights, cancellation policy
- DynamicPricingPage (/dynamic-pricing): rule engine for weekends, holidays,
  seasons, custom date ranges, weather; interactive price calendar preview
  with colour-coded effective rates per day
- LoyaltyPage (/loyalty): program settings (points/₽, point value, expiry),
  4-level structure (Bronze/Silver/Gold/Platinum) with editable perks,
  guest portal preview mockup, top-guests leaderboard
- MaintenancePage (/maintenance): scheduled room & service downtime records;
  status flow (scheduled → in_progress → done); time range support for services
- DiscountsPage (/discounts): discount catalogue — % or ₽, category (all /
  loyalty / corporate / promo), min nights, validity dates, active toggle
- BookingModal: discount now selected from dropdown of active discounts only
  (replaces free-form input); summary shows discount name + saved amount
- Sidebar: new "Цены и тарифы" section (Тарифы, Динамические цены, Скидки);
  Управление extended with Лояльность and Тех. перерывы

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 19:56:17 +03:00

491 lines
24 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 } from 'react'
import {
Award, Star, Gift, Percent, CreditCard, Edit2, Save, Info,
Crown, Shield, User, ChevronRight, Smartphone, Mail, QrCode,
TrendingUp,
} from 'lucide-react'
import { cn } from '../lib/utils'
// ─── Types ────────────────────────────────────────────────────────────────────
type LevelId = 'bronze' | 'silver' | 'gold' | 'platinum'
interface LoyaltyLevel {
id: LevelId
name: string
minPoints: number
maxPoints: number | null
discount: number // % discount on stay
bonusPercent: number // % of spent amount returned as points
perks: string[]
color: string
icon: React.ElementType
}
interface ProgramSettings {
pointsPerRuble: number // points per 1 ₽ spent
pointValue: number // 1 point = N rubles
expiryMonths: number // points expire after N months
isActive: boolean
}
interface GuestPoints {
id: string
name: string
email: string
points: number
totalSpent: number
level: LevelId
}
// ─── 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 MOCK_GUESTS: GuestPoints[] = [
{ id: 'g-1', name: 'Александр Петров', email: 'a.petrov@gmail.com', points: 16250, totalSpent: 287400, level: 'platinum' },
{ id: 'g-4', name: 'Елена Новикова', email: 'e.novikova@gmail.com', points: 8100, totalSpent: 76500, level: 'gold' },
{ id: 'g-6', name: 'Анна Лебедева', email: 'anna.l@mail.ru', points: 3950, totalSpent: 132000, level: 'silver' },
{ id: 'g-2', name: 'Мария Сидорова', email: 'm.sidorova@mail.ru', points: 2200, totalSpent: 98200, level: 'silver' },
{ id: 'g-5', name: 'Игорь Волков', email: 'i.volkov@corp.ru', 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: '', points: 120, totalSpent: 8400, level: 'bronze' },
]
function levelBadge(levelId: LevelId) {
const level = DEFAULT_LEVELS.find(l => l.id === 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[levelId])}>
<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>
{/* Points balance */}
<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>
{/* Progress to next level */}
{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>
)}
{/* Quick perks */}
<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>
)
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export function LoyaltyPage() {
const [levels, setLevels] = useState<LoyaltyLevel[]>(DEFAULT_LEVELS)
const [settings, setSettings] = useState<ProgramSettings>(DEFAULT_SETTINGS)
const [editSettings, setEditSettings] = useState(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)
return (
<div className="p-6 space-y-6">
{/* 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-4 gap-4">
{[
{ 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' },
].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>
<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={() => setEditSettings(!editSettings)} 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-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">
{MOCK_GUESTS.sort((a, b) => b.points - a.points).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.level)}</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')}
</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">
{/* Guest portal preview */}
<GuestPortalPreview settings={settings} />
{/* Redemption info */}
<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-brand-50 dark:bg-brand-900/10 border border-brand-200 dark:border-brand-800">
<Info size={14} className="text-brand-600 dark:text-brand-400 mt-0.5 shrink-0" />
<p className="text-xs text-brand-700 dark:text-brand-400">
В будущих версиях баллы будут работать во всех отелях сети HotelSync единая программа для всего бренда.
</p>
</div>
</div>
</div>
</div>
)
}