Files
hotelsync/src/pages/ReviewsPage.tsx
HotelSync e81de90855 Move guest links to guest.hotelsync.ru subdomain
/review/:slug and /room-service/:slug guest pages now link to
guest.hotelsync.ru instead of app.hotelsync.ru

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 20:29:14 +03:00

849 lines
43 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 {
Star, ThumbsDown, Copy, CheckCheck, MessageSquare, QrCode,
ArrowUpRight, Plus, Trash2, ExternalLink, Settings2,
Send, Mail, Smartphone, Clock, Eye, Palette, Globe,
} from 'lucide-react'
import { format } from 'date-fns'
import { ru } from 'date-fns/locale'
import { cn } from '../lib/utils'
import { Badge } from '../components/ui/Badge'
// ── Types ─────────────────────────────────────────────────────────────────────
type ReviewStatus = 'pending' | 'published' | 'rejected'
type BookingSource = 'booking' | 'yandex' | 'google' | 'direct' | 'widget'
interface Review {
id: string
guestName: string
roomNumber: string
rating: number
text: string
status: ReviewStatus
source: 'qr' | 'email' | 'sms'
bookingSource?: BookingSource
createdAt: Date
reply?: string
autoRedirectedTo?: string[]
}
interface ReviewPlatform {
id: string
name: string
url: string
enabled: boolean
}
// ── Default settings ──────────────────────────────────────────────────────────
const DEFAULT_PLATFORMS: ReviewPlatform[] = [
{ id: 'p1', name: 'Booking.com', url: 'https://www.booking.com/hotel/ru/', enabled: true },
{ id: 'p2', name: 'Яндекс Путешествия', url: 'https://travel.yandex.ru/hotels/', enabled: true },
{ id: 'p3', name: 'Google', url: 'https://g.page/r/', enabled: true },
{ id: 'p4', name: '2ГИС', url: 'https://2gis.ru/moscow/firm/', enabled: false },
]
// ── Mock data ─────────────────────────────────────────────────────────────────
const MOCK_REVIEWS: Review[] = [
{
id: 'rv1', guestName: 'Дмитрий Волков', roomNumber: '101',
rating: 5, source: 'email', bookingSource: 'booking',
createdAt: new Date(Date.now() - 3600000 * 5),
text: 'Замечательный отель! Персонал очень вежливый. Номер чистый, завтрак вкусный. Обязательно вернёмся.',
status: 'published',
autoRedirectedTo: ['Booking.com'],
},
{
id: 'rv2', guestName: 'Анна Козлова', roomNumber: '202',
rating: 1, source: 'qr', bookingSource: 'direct',
createdAt: new Date(Date.now() - 3600000 * 12),
text: 'Шум из соседнего номера мешал спать. Кондиционер плохо работал. Разочарована.',
status: 'pending',
},
{
id: 'rv3', guestName: 'Наталья Александрова', roomNumber: '301',
rating: 5, source: 'sms', bookingSource: 'widget',
createdAt: new Date(Date.now() - 3600000 * 36),
text: 'Лучший отель! Вид из окна потрясающий, кровать удобная, всё стильно.',
status: 'published',
autoRedirectedTo: ['Booking.com', 'Яндекс Путешествия', 'Google'],
},
{
id: 'rv4', guestName: 'Игорь Соколов', roomNumber: '201',
rating: 4, source: 'email', bookingSource: 'yandex',
createdAt: new Date(Date.now() - 3600000 * 48),
text: 'В целом хорошо. Немного дорого для такого номера. Расположение отличное.',
status: 'published',
autoRedirectedTo: ['Яндекс Путешествия'],
},
{
id: 'rv5', guestName: 'Виктор Громов', roomNumber: '402',
rating: 2, source: 'qr', bookingSource: 'direct',
createdAt: new Date(Date.now() - 3600000 * 60),
text: 'Долго ждали заселения. Номер убран с опозданием. Разочарованы.',
status: 'pending',
},
{
id: 'rv6', guestName: 'Михаил Орлов', roomNumber: '401',
rating: 4, source: 'email', bookingSource: 'google',
createdAt: new Date(Date.now() - 3600000 * 100),
text: 'Очень доволен. Персонал внимательный, номер чистый.',
status: 'published',
reply: 'Михаил, спасибо за тёплые слова! Ждём вас снова.',
autoRedirectedTo: ['Google'],
},
]
const REVIEW_LINK = 'https://guest.hotelsync.ru/review/grand-palace'
const SOURCE_LABEL: Record<Review['source'], string> = { qr: 'QR-код', email: 'Email', sms: 'SMS' }
const BOOKING_SOURCE_LABEL: Record<BookingSource, string> = {
booking: 'Booking.com',
yandex: 'Яндекс Путешествия',
google: 'Google',
direct: 'Прямое',
widget: 'Виджет',
}
const PREVIEW_COLORS = ['#2563eb', '#16a34a', '#9333ea', '#dc2626']
// ── Helper components ─────────────────────────────────────────────────────────
function StarRating({ rating, size = 14 }: { rating: number; size?: number }) {
return (
<div className="flex items-center gap-0.5">
{Array.from({ length: 5 }, (_, i) => (
<Star key={i} size={size} className={i < rating ? 'text-amber-400 fill-amber-400' : 'text-slate-300 dark:text-slate-600'} />
))}
<span className="ml-1.5 text-sm font-semibold text-slate-700 dark:text-slate-300">{rating}/5</span>
</div>
)
}
function ratingColor(r: number, threshold: number): string {
if (r >= 5) return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
if (r >= 4) return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
if (r >= threshold) return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
}
type Tab = 'pending' | 'published' | 'all' | 'preview' | 'settings'
// ── Component ─────────────────────────────────────────────────────────────────
export function ReviewsPage() {
const [reviews, setReviews] = useState<Review[]>(MOCK_REVIEWS)
const [tab, setTab] = useState<Tab>('pending')
const [replyId, setReplyId] = useState<string | null>(null)
const [replyText, setReplyText] = useState('')
const [copied, setCopied] = useState(false)
// Settings
const [threshold, setThreshold] = useState(4)
const [platforms, setPlatforms] = useState<ReviewPlatform[]>(DEFAULT_PLATFORMS)
const [newPlatformName, setNewPlatformName] = useState('')
const [newPlatformUrl, setNewPlatformUrl] = useState('')
// Settings — sending
const [sendHours, setSendHours] = useState(2)
const [sendChannel, setSendChannel] = useState<'email' | 'sms' | 'both'>('email')
const [sendRetry, setSendRetry] = useState(true)
const [useSourceRedirect, setUseSourceRedirect] = useState(true)
// Preview state
const [previewRating, setPreviewRating] = useState(0)
const [previewHover, setPreviewHover] = useState(0)
const [previewSubmitted, setPreviewSubmitted] = useState(false)
const [previewHotelName, setPreviewHotelName] = useState('Гранд Палас')
const [previewColor, setPreviewColor] = useState(PREVIEW_COLORS[0])
const [previewWelcome, setPreviewWelcome] = useState('Как вам у нас?')
const [previewShowText, setPreviewShowText] = useState(true)
const pending = reviews.filter(r => r.status === 'pending')
const published = reviews.filter(r => r.status === 'published')
const avgRating = published.length
? (published.reduce((s, r) => s + r.rating, 0) / published.length).toFixed(1)
: '—'
const nps = published.length
? Math.round(
(published.filter(r => r.rating >= 5).length / published.length * 100) -
(published.filter(r => r.rating <= 2).length / published.length * 100),
)
: 0
const filtered = tab === 'all' ? reviews
: tab === 'settings' || tab === 'preview' ? []
: reviews.filter(r => r.status === tab)
const sendReply = (id: string) => {
if (!replyText.trim()) return
setReviews(prev => prev.map(r =>
r.id === id ? { ...r, reply: replyText.trim(), status: 'published' } : r,
))
setReplyId(null)
setReplyText('')
}
const reject = (id: string) =>
setReviews(prev => prev.map(r => r.id === id ? { ...r, status: 'rejected' } : r))
const addPlatform = () => {
if (!newPlatformName.trim()) return
setPlatforms(prev => [...prev, {
id: `p-${Date.now()}`,
name: newPlatformName.trim(),
url: newPlatformUrl.trim(),
enabled: true,
}])
setNewPlatformName('')
setNewPlatformUrl('')
}
const copyLink = () => {
navigator.clipboard.writeText(REVIEW_LINK).catch(() => {})
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const enabledPlatforms = platforms.filter(p => p.enabled)
// Determine redirect platform based on booking source
const getRedirectPlatform = (source?: BookingSource): string | null => {
if (!useSourceRedirect) return null
if (source === 'booking') return 'Booking.com'
if (source === 'yandex') return 'Яндекс Путешествия'
if (source === 'google') return 'Google'
return null
}
const TABS: { key: Tab; label: string; count?: number; colorCls?: string; Icon?: React.ElementType }[] = [
{ key: 'pending', label: 'Требуют ответа', count: pending.length, colorCls: 'bg-red-500 text-white' },
{ key: 'published', label: 'Архив', count: published.length },
{ key: 'all', label: 'Все' },
{ key: 'preview', label: 'Превью', Icon: Eye },
{ key: 'settings', label: 'Настройки', Icon: Settings2 },
]
return (
<div className="p-4 md:p-6 space-y-5">
{/* Header */}
<div>
<h1 className="text-xl font-bold text-slate-900 dark:text-slate-100">Отзывы гостей</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
Отрицательные (&lt; {threshold} звёзд) модерация. Положительные автоматический редирект на площадки.
</p>
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{[
{ label: 'Средняя оценка', value: avgRating, sub: 'из 5 звёзд', color: 'text-amber-600 dark:text-amber-400' },
{ label: 'NPS', value: nps >= 0 ? `+${nps}` : `${nps}`, sub: 'лояльность', color: 'text-emerald-600 dark:text-emerald-400' },
{ label: 'В архиве', value: published.length, sub: 'опубликованных', color: 'text-brand-600 dark:text-brand-400' },
{ label: 'Ждут ответа', value: pending.length, sub: 'негативных', color: pending.length > 0 ? 'text-red-600 dark:text-red-400' : 'text-slate-500' },
].map(s => (
<div key={s.label} className="card p-4">
<p className={cn('text-2xl font-bold', s.color)}>{s.value}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">{s.label}</p>
<p className="text-[10px] text-slate-400">{s.sub}</p>
</div>
))}
</div>
{/* Logic banners */}
<div className="grid md:grid-cols-2 gap-3">
<div className="card p-3 border-red-200 dark:border-red-700/50 bg-red-50/50 dark:bg-red-900/10 flex items-start gap-3">
<ThumbsDown size={14} className="text-red-600 mt-0.5 shrink-0" />
<div>
<p className="font-semibold text-slate-900 dark:text-slate-100 text-sm">Отрицательные (&lt; {threshold} звёзд)</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
Модерация менеджер пишет личный ответ гостю. Публично не публикуется.
</p>
</div>
</div>
<div className="card p-3 border-blue-200 dark:border-blue-700/50 bg-blue-50/50 dark:bg-blue-900/10 flex items-start gap-3">
<ArrowUpRight size={14} className="text-blue-600 mt-0.5 shrink-0" />
<div>
<p className="font-semibold text-slate-900 dark:text-slate-100 text-sm">Положительные ( {threshold} звёзд)</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
Авторедирект на площадку источника бронирования или выбор из: {enabledPlatforms.map(p => p.name).join(', ') || '(настройте)'}
</p>
</div>
</div>
</div>
{/* Review link */}
<div className="card p-4">
<div className="flex items-center gap-4 flex-wrap">
<QrCode size={24} className="text-slate-500 shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-semibold text-slate-900 dark:text-slate-100 text-sm mb-1">Ссылка для сбора отзывов</p>
<div className="flex items-center gap-2 p-2 rounded-xl bg-slate-50 dark:bg-slate-700/50 border border-slate-200 dark:border-slate-600">
<span className="text-xs font-mono text-slate-700 dark:text-slate-300 flex-1 truncate">{REVIEW_LINK}</span>
<button onClick={copyLink} className="shrink-0 flex items-center gap-1 px-2 py-1 rounded-lg bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 text-xs font-medium hover:border-brand-400">
{copied ? <CheckCheck size={11} className="text-emerald-600" /> : <Copy size={11} />}
{copied ? 'Скопировано' : 'Копировать'}
</button>
</div>
</div>
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 border-b border-slate-200 dark:border-slate-700 overflow-x-auto">
{TABS.map(t => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={cn(
'px-4 py-2.5 text-sm font-medium border-b-2 transition-colors flex items-center gap-1.5 shrink-0 whitespace-nowrap',
tab === t.key
? 'border-brand-600 text-brand-600 dark:text-brand-400'
: 'border-transparent text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200',
)}
>
{t.Icon && <t.Icon size={13} />}
{t.label}
{t.count !== undefined && t.count > 0 && (
<span className={cn('text-[10px] px-1.5 py-0.5 rounded-full font-semibold', t.colorCls ?? 'bg-slate-200 dark:bg-slate-600 text-slate-700 dark:text-slate-300')}>
{t.count}
</span>
)}
</button>
))}
</div>
{/* ══════════════════════ TAB: preview ══════════════════════ */}
{tab === 'preview' && (
<div className="flex flex-col lg:flex-row gap-6">
{/* Phone frame */}
<div className="flex justify-center lg:justify-start">
<div className="w-72 border-4 border-slate-800 dark:border-slate-600 rounded-3xl shadow-2xl overflow-hidden bg-white dark:bg-slate-900">
{/* Phone notch */}
<div className="bg-slate-800 dark:bg-slate-950 h-5 flex items-center justify-center">
<div className="w-16 h-1.5 rounded-full bg-slate-600" />
</div>
{/* Preview content */}
<div className="p-5 text-center space-y-4">
{/* Hotel logo */}
<div
className="w-16 h-16 rounded-full mx-auto flex items-center justify-center text-2xl font-bold text-white shadow-lg"
style={{ backgroundColor: previewColor }}
>
{previewHotelName[0]}
</div>
<div>
<p className="font-bold text-slate-900 dark:text-slate-100 text-base">{previewHotelName}</p>
<p className="text-lg font-semibold text-slate-700 dark:text-slate-300 mt-1">{previewWelcome}</p>
</div>
{/* Star selector */}
{!previewSubmitted && (
<div className="flex justify-center gap-1">
{Array.from({ length: 5 }, (_, i) => (
<button
key={i}
onMouseEnter={() => setPreviewHover(i + 1)}
onMouseLeave={() => setPreviewHover(0)}
onClick={() => setPreviewRating(i + 1)}
className="transition-transform hover:scale-125"
>
<Star
size={32}
className={cn(
'transition-colors',
(previewHover || previewRating) > i
? 'text-amber-400 fill-amber-400'
: 'text-slate-300 dark:text-slate-600',
)}
/>
</button>
))}
</div>
)}
{/* Text area (if rating selected and positive) */}
{previewRating > 0 && !previewSubmitted && previewShowText && (
<textarea
className="w-full border border-slate-200 dark:border-slate-600 rounded-xl p-2.5 text-xs text-slate-700 dark:text-slate-300 bg-slate-50 dark:bg-slate-800 resize-none"
rows={3}
placeholder="Расскажите подробнее... (необязательно)"
readOnly
/>
)}
{/* Submit button */}
{previewRating > 0 && !previewSubmitted && (
<button
onClick={() => setPreviewSubmitted(true)}
className="w-full py-2.5 rounded-xl text-white text-sm font-semibold transition-opacity hover:opacity-90"
style={{ backgroundColor: previewColor }}
>
Отправить отзыв
</button>
)}
{/* After submit: negative flow */}
{previewSubmitted && previewRating < threshold && (
<div className="p-3 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-600 text-sm text-slate-600 dark:text-slate-400">
Спасибо за честный отзыв. Мы уже работаем над улучшениями.
</div>
)}
{/* After submit: positive flow — redirect buttons */}
{previewSubmitted && previewRating >= threshold && (
<div className="space-y-2">
<p className="text-xs text-slate-500 dark:text-slate-400">Поделитесь на площадках:</p>
{enabledPlatforms.map(p => (
<button
key={p.id}
className="w-full flex items-center justify-between px-3 py-2 rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800 text-xs font-medium text-slate-700 dark:text-slate-300 hover:border-brand-400 transition-colors"
>
{p.name}
<ExternalLink size={11} className="text-slate-400" />
</button>
))}
</div>
)}
{/* Reset */}
{previewSubmitted && (
<button
onClick={() => { setPreviewSubmitted(false); setPreviewRating(0); setPreviewHover(0) }}
className="text-xs text-slate-400 underline"
>
Сбросить превью
</button>
)}
</div>
<div className="bg-slate-800 dark:bg-slate-950 h-4" />
</div>
</div>
{/* Customisation panel */}
<div className="flex-1 space-y-4">
<div className="card p-5 space-y-4">
<h3 className="font-semibold text-slate-900 dark:text-slate-100 flex items-center gap-2">
<Palette size={16} className="text-brand-600" />
Настроить внешний вид
</h3>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Название отеля</label>
<input
type="text"
className="input"
value={previewHotelName}
onChange={e => setPreviewHotelName(e.target.value)}
placeholder="Название отеля"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Приветственный текст</label>
<input
type="text"
className="input"
value={previewWelcome}
onChange={e => setPreviewWelcome(e.target.value)}
placeholder="Как вам у нас?"
/>
</div>
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Основной цвет</p>
<div className="flex items-center gap-2 flex-wrap">
{PREVIEW_COLORS.map(c => (
<button
key={c}
onClick={() => setPreviewColor(c)}
className={cn(
'w-8 h-8 rounded-full border-2 transition-transform hover:scale-110',
previewColor === c ? 'border-slate-800 dark:border-white scale-110' : 'border-transparent',
)}
style={{ backgroundColor: c }}
/>
))}
<input
type="color"
className="w-8 h-8 rounded-full cursor-pointer border-0 bg-transparent"
value={previewColor}
onChange={e => setPreviewColor(e.target.value)}
title="Свой цвет"
/>
</div>
</div>
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-semibold text-slate-800 dark:text-slate-200">Показывать поле для текста</p>
<p className="text-xs text-slate-500 dark:text-slate-400">Гость может оставить развёрнутый комментарий</p>
</div>
<button
onClick={() => setPreviewShowText(v => !v)}
className={cn('relative w-11 h-6 rounded-full transition-colors shrink-0', previewShowText ? 'bg-brand-600' : 'bg-slate-300 dark:bg-slate-600')}
>
<div className={cn('absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform', previewShowText ? 'left-[22px]' : 'left-1')} />
</button>
</div>
</div>
{/* Threshold info */}
<div className="card p-4 bg-amber-50/50 dark:bg-amber-900/10 border-amber-200 dark:border-amber-700/50">
<p className="text-sm font-semibold text-slate-800 dark:text-slate-200 mb-1">Текущий порог: {threshold} из 5 звёзд</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
При оценке {threshold} звёзд гость видит кнопки площадок. Ниже страницу благодарности.
</p>
</div>
</div>
</div>
)}
{/* ══════════════════════ TAB: settings ══════════════════════ */}
{tab === 'settings' && (
<div className="max-w-2xl space-y-5">
{/* Sending timing */}
<div className="card p-5 space-y-4">
<h3 className="font-semibold text-slate-900 dark:text-slate-100 flex items-center gap-2">
<Send size={15} className="text-brand-600" />
Отправка ссылки
</h3>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
Через сколько часов после выезда отправлять ссылку?
</label>
<div className="flex items-center gap-3">
<input
type="number"
min={1}
max={72}
className="input w-24"
value={sendHours}
onChange={e => setSendHours(Math.min(72, Math.max(1, parseInt(e.target.value) || 1)))}
/>
<span className="text-sm text-slate-500 dark:text-slate-400">часов (172)</span>
</div>
</div>
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Канал отправки</p>
<div className="flex gap-2">
{([
{ key: 'email', label: 'Email', Icon: Mail },
{ key: 'sms', label: 'SMS', Icon: Smartphone },
{ key: 'both', label: 'Email + SMS', Icon: Send },
] as const).map(ch => (
<button
key={ch.key}
onClick={() => setSendChannel(ch.key)}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors',
sendChannel === ch.key
? 'bg-brand-600 border-brand-600 text-white'
: 'border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-400 hover:border-brand-400',
)}
>
<ch.Icon size={13} />
{ch.label}
</button>
))}
</div>
</div>
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-semibold text-slate-800 dark:text-slate-200 flex items-center gap-1.5">
<Clock size={13} className="text-slate-500" />
Повторная отправка
</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Если гость не открыл ссылку через 24ч</p>
</div>
<button
onClick={() => setSendRetry(v => !v)}
className={cn('relative w-11 h-6 rounded-full transition-colors shrink-0', sendRetry ? 'bg-brand-600' : 'bg-slate-300 dark:bg-slate-600')}
>
<div className={cn('absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform', sendRetry ? 'left-[22px]' : 'left-1')} />
</button>
</div>
</div>
{/* Threshold */}
<div className="card p-5 space-y-4">
<h3 className="font-semibold text-slate-900 dark:text-slate-100">Порог перенаправления</h3>
<p className="text-sm text-slate-500 dark:text-slate-400">
Ниже порога модерация. Выше гость получает ссылку на внешние площадки.
</p>
<div className="flex items-center gap-4">
<div>
<p className="text-xs text-slate-500 mb-2">Порог (из 5 звёзд)</p>
<input
type="range" min={1} max={4} value={threshold}
onChange={e => setThreshold(parseInt(e.target.value))}
className="w-48"
/>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-brand-600 dark:text-brand-400">{threshold}</div>
<div className="flex gap-0.5 mt-1">
{Array.from({ length: 5 }, (_, i) => (
<Star key={i} size={14} className={i < threshold ? 'text-amber-400 fill-amber-400' : 'text-slate-300'} />
))}
</div>
<p className="text-xs text-slate-400 mt-0.5">{threshold} из 5 звёзд</p>
</div>
</div>
<div className="flex gap-3 text-xs">
<div className="flex-1 p-2.5 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700">
<p className="font-medium text-red-700 dark:text-red-300">Рейтинг &lt; {threshold} звёзд</p>
<p className="text-slate-500 mt-0.5"> Модерация + личный ответ</p>
</div>
<div className="flex-1 p-2.5 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700">
<p className="font-medium text-blue-700 dark:text-blue-300">Рейтинг {threshold} звёзд</p>
<p className="text-slate-500 mt-0.5"> Авторедирект на площадки</p>
</div>
</div>
</div>
{/* Redirect logic */}
<div className="card p-5 space-y-4">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="font-semibold text-slate-900 dark:text-slate-100 flex items-center gap-2">
<Globe size={15} className="text-brand-600" />
Логика редиректа по источнику
</h3>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
Учитывать источник бронирования при выборе площадки для редиректа
</p>
</div>
<button
onClick={() => setUseSourceRedirect(v => !v)}
className={cn('relative w-11 h-6 rounded-full transition-colors shrink-0', useSourceRedirect ? 'bg-brand-600' : 'bg-slate-300 dark:bg-slate-600')}
>
<div className={cn('absolute top-1 w-4 h-4 rounded-full bg-white shadow transition-transform', useSourceRedirect ? 'left-[22px]' : 'left-1')} />
</button>
</div>
<div className="space-y-2">
{[
{ source: 'Booking.com бронирование', redirect: 'Booking.com', smart: true },
{ source: 'Яндекс Путешествия', redirect: 'Яндекс Путешествия', smart: true },
{ source: 'Google', redirect: 'Google', smart: true },
{ source: 'Прямое бронирование / виджет', redirect: 'Гость выбирает сам', smart: false },
].map(row => (
<div key={row.source} className={cn(
'flex items-center justify-between p-3 rounded-xl border text-sm',
useSourceRedirect
? 'border-slate-200 dark:border-slate-600 bg-slate-50 dark:bg-slate-700/30'
: 'border-slate-100 dark:border-slate-700 bg-white dark:bg-slate-800 opacity-50',
)}>
<span className="text-slate-600 dark:text-slate-400">{row.source}</span>
<div className="flex items-center gap-1.5">
<ArrowUpRight size={12} className="text-slate-400" />
<span className={cn('font-medium', row.smart ? 'text-blue-600 dark:text-blue-400' : 'text-slate-500')}>
{row.redirect}
</span>
{row.smart && useSourceRedirect && (
<span className="text-[9px] bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 px-1 py-0.5 rounded font-semibold"> авто</span>
)}
</div>
</div>
))}
</div>
</div>
{/* Platforms */}
<div className="card p-5 space-y-4">
<h3 className="font-semibold text-slate-900 dark:text-slate-100">Площадки для перенаправления</h3>
<p className="text-sm text-slate-500 dark:text-slate-400">
Гость с положительным отзывом получит ссылки на эти площадки.
</p>
<div className="space-y-2">
{platforms.map(p => (
<div key={p.id} className="flex items-center gap-3 p-3 rounded-xl border border-slate-200 dark:border-slate-600">
<button
onClick={() => setPlatforms(prev => prev.map(x => x.id === p.id ? { ...x, enabled: !x.enabled } : x))}
className={cn('relative w-9 h-5 rounded-full transition-colors shrink-0', p.enabled ? 'bg-brand-600' : 'bg-slate-300 dark:bg-slate-600')}
>
<div className={cn('absolute top-0.5 w-4 h-4 rounded-full bg-white shadow-sm transition-transform', p.enabled ? 'left-[18px]' : 'left-0.5')} />
</button>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">{p.name}</p>
<p className="text-xs text-slate-400 truncate">{p.url || 'URL не указан'}</p>
</div>
{p.url && (
<a href={p.url} target="_blank" rel="noopener noreferrer" className="p-1.5 text-slate-400 hover:text-brand-600">
<ExternalLink size={13} />
</a>
)}
<button onClick={() => setPlatforms(prev => prev.filter(x => x.id !== p.id))} className="p-1.5 text-slate-400 hover:text-red-500 transition-colors">
<Trash2 size={13} />
</button>
</div>
))}
</div>
<div className="pt-2 border-t border-slate-200 dark:border-slate-600">
<p className="text-xs font-medium text-slate-600 dark:text-slate-400 mb-2">Добавить площадку</p>
<div className="flex gap-2">
<input
type="text" className="input flex-1"
placeholder="Название (например: Tripadvisor)"
value={newPlatformName}
onChange={e => setNewPlatformName(e.target.value)}
/>
<input
type="text" className="input flex-1"
placeholder="Ссылка на страницу отеля"
value={newPlatformUrl}
onChange={e => setNewPlatformUrl(e.target.value)}
/>
<button onClick={addPlatform} className="btn-primary shrink-0">
<Plus size={14} />Добавить
</button>
</div>
</div>
</div>
</div>
)}
{/* ══════════════════════ Review list ══════════════════════ */}
{tab !== 'settings' && tab !== 'preview' && (
<div className="space-y-3">
{filtered.length === 0 && (
<div className="card p-8 text-center text-slate-500 dark:text-slate-400">
<MessageSquare size={32} className="mx-auto mb-2 opacity-30" />
<p>Отзывов нет</p>
</div>
)}
{filtered.map(review => {
const isNegative = review.rating < threshold
const redirectTarget = getRedirectPlatform(review.bookingSource)
return (
<div
key={review.id}
className={cn(
'card p-4',
review.status === 'pending' && 'border-red-300 dark:border-red-700/60 bg-red-50/50 dark:bg-red-900/10',
)}
>
<div className="flex items-start gap-3 flex-wrap">
<div className={cn('w-10 h-10 rounded-full flex items-center justify-center gap-0.5 text-sm font-bold shrink-0', ratingColor(review.rating, threshold))}>
{review.rating}<Star size={10} className="fill-current" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 flex-wrap mb-1">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-semibold text-slate-900 dark:text-slate-100">{review.guestName}</p>
<Badge className="bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-400">{review.roomNumber}</Badge>
<Badge className="bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-400">{SOURCE_LABEL[review.source]}</Badge>
{review.bookingSource && (
<Badge className="bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-400">
{BOOKING_SOURCE_LABEL[review.bookingSource]}
</Badge>
)}
{isNegative && <Badge className="bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">Негативный</Badge>}
{/* Redirect badge for published positive */}
{review.status === 'published' && !isNegative && review.autoRedirectedTo && review.autoRedirectedTo.length > 0 && (
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 flex items-center gap-1">
<ArrowUpRight size={10} />
{review.bookingSource && ['booking','yandex','google'].includes(review.bookingSource)
? `${review.autoRedirectedTo[0]}`
: '→ Выбрал площадку'
}
</Badge>
)}
</div>
<span className="text-xs text-slate-400">{format(review.createdAt, 'd MMM, HH:mm', { locale: ru })}</span>
</div>
<StarRating rating={review.rating} />
<p className="text-sm text-slate-700 dark:text-slate-300 mt-2 leading-relaxed">{review.text}</p>
{/* Manager reply */}
{review.reply && (
<div className="mt-3 p-3 rounded-xl bg-blue-50 dark:bg-blue-900/20 border-l-4 border-blue-400">
<p className="text-xs text-blue-600 dark:text-blue-400 font-semibold mb-1">Личный ответ менеджера:</p>
<p className="text-sm text-slate-700 dark:text-slate-300">{review.reply}</p>
</div>
)}
{/* Redirect info for positive pending */}
{review.status === 'pending' && !isNegative && (
<div className="mt-2 p-2.5 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700">
<p className="text-xs text-blue-700 dark:text-blue-300">
{redirectTarget
? `Авторедирект: гость получит ссылку на ${redirectTarget} (источник бронирования)`
: `Гость получит ссылки для публикации на: ${enabledPlatforms.map(p => p.name).join(', ')}`
}
</p>
</div>
)}
{replyId === review.id && (
<div className="mt-3 space-y-2">
<textarea
className="input resize-none text-sm w-full"
rows={2}
placeholder="Личный ответ гостю (отправится по email/SMS)..."
value={replyText}
onChange={e => setReplyText(e.target.value)}
autoFocus
/>
<div className="flex gap-2">
<button onClick={() => sendReply(review.id)} className="btn-primary text-xs py-1.5">Отправить гостю</button>
<button onClick={() => setReplyId(null)} className="btn-secondary text-xs py-1.5">Отмена</button>
</div>
</div>
)}
</div>
{/* Actions */}
<div className="flex gap-2 shrink-0 flex-wrap">
{review.status === 'pending' && !review.reply && replyId !== review.id && (
<>
<button
onClick={() => { setReplyId(review.id); setReplyText('') }}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-brand-600 text-white text-xs font-medium hover:bg-brand-700 transition-colors"
>
<MessageSquare size={12} />Ответить гостю
</button>
<button
onClick={() => reject(review.id)}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 text-xs font-medium hover:bg-red-200 transition-colors"
>
<ThumbsDown size={12} />Отклонить
</button>
</>
)}
{review.status === 'rejected' && (
<button
onClick={() => setReviews(prev => prev.map(r => r.id === review.id
? { ...r, status: isNegative ? 'pending' : 'published' }
: r,
))}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-600 text-xs font-medium text-slate-600 dark:text-slate-400 hover:border-brand-400"
>
Восстановить
</button>
)}
</div>
</div>
</div>
)
})}
</div>
)}
</div>
)
}