Files
hotelsync/src/pages/ReviewsPage.tsx
HotelSync 31de5be873
Some checks failed
Deploy to Production / deploy (push) Has been cancelled
feat: review form logo/header image customizer, fix unread msg job startup
- ReviewsPage: logo upload (circle/square), header bg image, show-email toggle
- GuestReviewQrPage/GuestReviewPage: render logo image, header bg image, logo shape
- Backend: return review_logo_url, review_logo_shape, review_header_image_url, show_email from both public review endpoints
- jobs.ts: run unread messages job 20s after startup (was only on interval)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 23:09:43 +03:00

1094 lines
60 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, useRef } from 'react'
import { useAuth } from '../contexts/AuthContext'
import {
Star, ThumbsDown, Copy, CheckCheck, MessageSquare, QrCode,
ArrowUpRight, Plus, Trash2, ExternalLink, Settings2,
Mail, Clock, Eye, Palette, Globe, Save, Loader2, Download, Printer,
Upload, Image as ImageIcon,
} from 'lucide-react'
import { format } from 'date-fns'
import { ru } from 'date-fns/locale'
import { QRCodeSVG } from 'qrcode.react'
import { cn } from '../lib/utils'
import { Badge } from '../components/ui/Badge'
import { api } from '../lib/api'
import type { ReviewApi } from '../lib/api'
// ── Types ─────────────────────────────────────────────────────────────────────
interface ReviewPlatform {
id: string
name: string
url: string
enabled: boolean
}
// ── Default platforms ──────────────────────────────────────────────────────
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 },
]
const BOOKING_SOURCE_LABEL: Record<string, 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' | 'qr'
// ── Component ─────────────────────────────────────────────────────────────────
export function ReviewsPage() {
const { user } = useAuth()
const slug = user?.hotelSlug ?? ''
const [reviews, setReviews] = useState<ReviewApi[]>([])
const [loading, setLoading] = useState(true)
const [tab, setTab] = useState<Tab>('pending')
const [replyId, setReplyId] = useState<string | null>(null)
const [replyText, setReplyText] = useState('')
const [replyEmail, setReplyEmail] = useState('')
const [copied, setCopied] = useState(false)
// Settings
const [settingsLoaded, setSettingsLoaded] = useState(false)
const [saving, setSaving] = useState(false)
const [threshold, setThreshold] = useState(4)
const [platforms, setPlatforms] = useState<ReviewPlatform[]>(DEFAULT_PLATFORMS)
const [newPlatformName, setNewPlatformName] = useState('')
const [newPlatformUrl, setNewPlatformUrl] = useState('')
const [sendHours, setSendHours] = useState(2)
const [sendRetry, setSendRetry] = useState(true)
const [requestEnabled, setRequestEnabled] = useState(true)
const [useSourceRedirect, setUseSourceRedirect] = useState(true)
// QR section
const [rooms, setRooms] = useState<{ id: string; number: string }[]>([])
const [qrPrintRef] = [useRef<HTMLDivElement>(null)]
const appUrl = (import.meta.env.VITE_APP_URL as string | undefined) ?? 'https://app.hotelsync.ru'
const [customLocations, setCustomLocations] = useState<string[]>([])
const [newLocation, setNewLocation] = useState('')
// Source → platform mapping (configurable)
interface SourceMapEntry { bookingSource: string; platformName: string }
const DEFAULT_SOURCE_MAP: SourceMapEntry[] = [
{ bookingSource: 'booking_com', platformName: 'Booking.com' },
{ bookingSource: 'yandex_travel', platformName: 'Яндекс Путешествия' },
{ bookingSource: 'airbnb', platformName: 'Airbnb' },
{ bookingSource: 'expedia', platformName: 'Expedia' },
]
const [sourceMap, setSourceMap] = useState<SourceMapEntry[]>(DEFAULT_SOURCE_MAP)
const [newSrcSource, setNewSrcSource] = useState('')
const [newSrcPlatform, setNewSrcPlatform] = useState('')
// 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 [previewLogoUrl, setPreviewLogoUrl] = useState('')
const [previewLogoShape, setPreviewLogoShape] = useState<'circle' | 'square'>('circle')
const [previewHeaderImageUrl, setPreviewHeaderImageUrl] = useState('')
const [previewShowEmail, setPreviewShowEmail] = useState(true)
const [uploadingLogo, setUploadingLogo] = useState(false)
const [uploadingHeader, setUploadingHeader] = useState(false)
// ── Load data ───────────────────────────────────────────────────────────────
const loadReviews = useCallback(async () => {
if (!slug) return
setLoading(true)
try {
const data = await api.reviews.list(slug)
setReviews(data)
} catch {
// ignore
} finally {
setLoading(false)
}
}, [slug])
const loadSettings = useCallback(async () => {
if (!slug) return
try {
const s = await api.hotelSettings.get(slug)
if (s.review_redirect_threshold !== undefined) setThreshold(Number(s.review_redirect_threshold))
if (s.review_request_hours !== undefined) setSendHours(Number(s.review_request_hours))
if (s.review_request_retry !== undefined) setSendRetry(Boolean(s.review_request_retry))
if (s.review_request_enabled !== undefined) setRequestEnabled(Boolean(s.review_request_enabled))
if (s.review_redirect_source !== undefined) setUseSourceRedirect(Boolean(s.review_redirect_source))
if (s.review_welcome_text !== undefined) setPreviewWelcome(String(s.review_welcome_text))
if (s.review_brand_color !== undefined) setPreviewColor(String(s.review_brand_color))
if (s.review_show_text_field !== undefined) setPreviewShowText(Boolean(s.review_show_text_field))
if (s.review_logo_url !== undefined) setPreviewLogoUrl(String(s.review_logo_url ?? ''))
if (s.review_logo_shape !== undefined) setPreviewLogoShape(s.review_logo_shape as 'circle' | 'square')
if (s.review_header_image_url !== undefined) setPreviewHeaderImageUrl(String(s.review_header_image_url ?? ''))
if (s.review_show_email !== undefined) setPreviewShowEmail(s.review_show_email !== false)
if (Array.isArray(s.review_platforms)) setPlatforms(s.review_platforms as ReviewPlatform[])
if (Array.isArray(s.review_source_map)) setSourceMap(s.review_source_map as SourceMapEntry[])
if (Array.isArray(s.review_custom_locations)) setCustomLocations(s.review_custom_locations as string[])
} catch {
// ignore
} finally {
setSettingsLoaded(true)
}
}, [slug])
useEffect(() => {
loadReviews()
loadSettings()
if (slug) {
api.rooms.list(slug).then(data => {
setRooms((data as { id: string; number: string }[]).map(r => ({ id: r.id, number: r.number })))
}).catch(() => {})
}
}, [loadReviews, loadSettings, slug])
// ── Save settings ───────────────────────────────────────────────────────────
const saveSettings = async () => {
if (!slug) return
setSaving(true)
try {
await api.hotelSettings.update(slug, {
review_redirect_threshold: threshold,
review_request_hours: sendHours,
review_request_retry: sendRetry,
review_request_enabled: requestEnabled,
review_redirect_source: useSourceRedirect,
review_welcome_text: previewWelcome,
review_brand_color: previewColor,
review_show_text_field: previewShowText,
review_logo_url: previewLogoUrl || null,
review_logo_shape: previewLogoShape,
review_header_image_url: previewHeaderImageUrl || null,
review_show_email: previewShowEmail,
review_platforms: platforms,
review_source_map: sourceMap,
review_custom_locations: customLocations,
})
} finally {
setSaving(false)
}
}
// ── Review actions ──────────────────────────────────────────────────────────
const sendReply = async (id: string, isQr: boolean) => {
if (!replyText.trim() || !slug) return
const data: { reply: string; guestEmail?: string } = { reply: replyText.trim() }
if (isQr && replyEmail.trim()) data.guestEmail = replyEmail.trim()
const updated = await api.reviews.update(slug, id, data)
setReviews(prev => prev.map(r => r.id === id ? updated : r))
setReplyId(null)
setReplyText('')
}
const reject = async (id: string) => {
if (!slug) return
const updated = await api.reviews.update(slug, id, { rejected: true })
setReviews(prev => prev.map(r => r.id === id ? updated : r))
}
const restore = async (id: string) => {
if (!slug) return
const updated = await api.reviews.update(slug, id, { rejected: false })
setReviews(prev => prev.map(x => x.id === id ? updated : x))
}
const addLocation = () => {
const loc = newLocation.trim()
if (!loc || customLocations.includes(loc)) return
setCustomLocations(prev => [...prev, loc])
setNewLocation('')
}
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 = () => {
const link = `https://app.hotelsync.ru/review/` // token-based, shown as example
navigator.clipboard.writeText(link).catch(() => {})
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
// ── Derived state ───────────────────────────────────────────────────────────
// pending = негативные без ответа и без отклонения
// archived = всё обработанное: опубликованные + отвеченные + отклонённые
const pending = reviews.filter(r => !r.isPublic && !r.reply && !r.rejectedAt)
const archived = reviews.filter(r => r.isPublic || !!r.reply || !!r.rejectedAt)
const enabledPlatforms = platforms.filter(p => p.enabled)
const avgRating = reviews.length
? (reviews.reduce((s, r) => s + r.rating, 0) / reviews.length).toFixed(1)
: '—'
const nps = reviews.length
? Math.round(
(reviews.filter(r => r.rating >= 5).length / reviews.length * 100) -
(reviews.filter(r => r.rating <= 2).length / reviews.length * 100),
)
: 0
const filtered = tab === 'all' ? reviews
: tab === 'settings' || tab === 'preview' || tab === 'qr' ? []
: tab === 'pending' ? pending
: archived
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: archived.length },
{ key: 'all', label: 'Все' },
{ key: 'preview', label: 'Превью', Icon: Eye },
{ key: 'qr', label: 'QR-коды', Icon: QrCode },
{ 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: archived.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>
{/* 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">
<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>
{/* Header with optional background image */}
<div
className="relative flex items-center justify-center py-5"
style={previewHeaderImageUrl
? { backgroundImage: `url(${previewHeaderImageUrl})`, backgroundSize: 'cover', backgroundPosition: 'center' }
: { backgroundColor: previewColor + '22' }
}
>
{previewHeaderImageUrl && <div className="absolute inset-0 bg-black/30" />}
<div
className={cn(
'relative w-14 h-14 flex items-center justify-center text-2xl font-bold text-white shadow-lg overflow-hidden',
previewLogoShape === 'square' ? 'rounded-2xl' : 'rounded-full',
)}
style={previewLogoUrl ? {} : { backgroundColor: previewColor }}
>
{previewLogoUrl
? <img src={previewLogoUrl} alt="" className="w-full h-full object-cover" />
: previewHotelName[0]
}
</div>
</div>
<div className="p-5 text-center space-y-4">
<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>
{!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>
)}
{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
/>
)}
{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>
)}
{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>
)}
{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>
)}
{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>
{/* Logo upload */}
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Логотип</p>
<div className="flex items-center gap-3 flex-wrap">
{previewLogoUrl && (
<img src={previewLogoUrl} alt="" className="w-12 h-12 object-cover rounded-xl border border-slate-200 dark:border-slate-600 shrink-0" />
)}
<label className={cn('btn-secondary gap-1.5 cursor-pointer text-sm', uploadingLogo && 'opacity-50 pointer-events-none')}>
{uploadingLogo ? <Loader2 size={13} className="animate-spin" /> : <Upload size={13} />}
{previewLogoUrl ? 'Заменить' : 'Загрузить'}
<input type="file" accept="image/*" className="hidden" onChange={async e => {
const file = e.target.files?.[0]; if (!file) return
setUploadingLogo(true)
try { setPreviewLogoUrl(await api.upload.photo(file, 'hotels')) }
finally { setUploadingLogo(false); e.target.value = '' }
}} />
</label>
{previewLogoUrl && (
<button onClick={() => setPreviewLogoUrl('')} className="text-xs text-red-500 hover:text-red-700 dark:text-red-400">Убрать</button>
)}
</div>
<div className="flex gap-2 mt-2">
{(['circle', 'square'] as const).map(shape => (
<button
key={shape}
onClick={() => setPreviewLogoShape(shape)}
className={cn('px-3 py-1.5 text-xs rounded-lg border transition-colors', previewLogoShape === shape ? 'border-brand-600 bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-400' : 'border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-400 hover:border-slate-300')}
>
{shape === 'circle' ? '● Круг' : '■ Квадрат'}
</button>
))}
</div>
</div>
{/* Header background image */}
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Фон за логотипом</p>
{previewHeaderImageUrl && (
<img src={previewHeaderImageUrl} alt="" className="w-full h-16 object-cover rounded-xl border border-slate-200 dark:border-slate-600 mb-2" />
)}
<div className="flex items-center gap-3 flex-wrap">
<label className={cn('btn-secondary gap-1.5 cursor-pointer text-sm', uploadingHeader && 'opacity-50 pointer-events-none')}>
{uploadingHeader ? <Loader2 size={13} className="animate-spin" /> : <ImageIcon size={13} />}
{previewHeaderImageUrl ? 'Заменить фото' : 'Фото отеля'}
<input type="file" accept="image/*" className="hidden" onChange={async e => {
const file = e.target.files?.[0]; if (!file) return
setUploadingHeader(true)
try { setPreviewHeaderImageUrl(await api.upload.photo(file, 'hotels')) }
finally { setUploadingHeader(false); e.target.value = '' }
}} />
</label>
{previewHeaderImageUrl && (
<button onClick={() => setPreviewHeaderImageUrl('')} className="text-xs text-red-500 hover:text-red-700 dark:text-red-400">Убрать</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">Показывать поле для текста</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 className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-semibold text-slate-800 dark:text-slate-200">Поле Email для QR-отзывов</p>
<p className="text-xs text-slate-500 dark:text-slate-400">Гость может указать email для получения ответа</p>
</div>
<button
onClick={() => setPreviewShowEmail(v => !v)}
className={cn('relative w-11 h-6 rounded-full transition-colors shrink-0', previewShowEmail ? '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', previewShowEmail ? 'left-[22px]' : 'left-1')} />
</button>
</div>
</div>
<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>
<button onClick={saveSettings} disabled={saving} className="btn-primary gap-2">
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
Сохранить внешний вид
</button>
</div>
</div>
)}
{/* ══════════════════════ TAB: qr ══════════════════════ */}
{tab === 'qr' && (
<div className="space-y-5">
<div className="card p-5">
<div className="flex items-start gap-3 mb-4">
<QrCode size={18} className="text-brand-600 mt-0.5 shrink-0" />
<div>
<h3 className="font-semibold text-slate-900 dark:text-slate-100">QR-коды по номерам</h3>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
Распечатайте и разместите в номерах. Гость сканирует оставляет анонимный отзыв.
Ссылка привязана к номеру вы увидите из какой комнаты пришёл отзыв.
</p>
</div>
</div>
<button
onClick={() => window.print()}
className="btn-primary gap-2 mb-6"
>
<Printer size={14} />
Распечатать все QR-коды
</button>
{rooms.length === 0 && (
<div className="text-center text-slate-400 py-8">
<QrCode size={32} className="mx-auto mb-2 opacity-30" />
<p className="text-sm">Номера не найдены. Добавьте номера в разделе «Номера».</p>
</div>
)}
<div ref={qrPrintRef} className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 print:grid-cols-3">
{rooms.map(room => {
const url = `${appUrl}/review-qr/${slug}/${encodeURIComponent(room.number)}`
return (
<div
key={room.id}
className="flex flex-col items-center gap-3 p-4 rounded-2xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800 print:border print:border-slate-300 print:break-inside-avoid"
>
<QRCodeSVG
value={url}
size={120}
bgColor="#ffffff"
fgColor="#1e293b"
level="M"
/>
<div className="text-center">
<p className="font-bold text-slate-900 dark:text-slate-100 text-sm">Номер {room.number}</p>
<p className="text-[10px] text-slate-400 mt-0.5 break-all">{url}</p>
</div>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-brand-600 hover:underline print:hidden"
>
<ExternalLink size={11} />
Открыть
</a>
<a
href={`data:image/svg+xml;charset=utf-8,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><rect width="200" height="200" fill="white"/></svg>`
)}`}
download={`qr-room-${room.number}.svg`}
className="flex items-center gap-1 text-xs text-slate-500 hover:text-brand-600 print:hidden"
onClick={e => {
e.preventDefault()
const svgEl = e.currentTarget.closest('.flex.flex-col')?.querySelector('svg')
if (!svgEl) return
const blob = new Blob([svgEl.outerHTML], { type: 'image/svg+xml' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = `qr-room-${room.number}.svg`
a.click()
}}
>
<Download size={11} />
Скачать SVG
</a>
</div>
)
})}
</div>
</div>
{/* Custom locations */}
<div className="card p-5">
<div className="flex items-start gap-3 mb-4">
<QrCode size={18} className="text-brand-600 mt-0.5 shrink-0" />
<div>
<h3 className="font-semibold text-slate-900 dark:text-slate-100">QR-коды для мест</h3>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
Для ресепшна, ресторана или любого другого места. Не привязаны к конкретному номеру.
</p>
</div>
</div>
<div className="flex gap-2 mb-5">
<input
type="text"
className="input flex-1"
placeholder="Название (Ресепшн, Ресторан, Лобби…)"
value={newLocation}
onChange={e => setNewLocation(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') addLocation() }}
/>
<button onClick={addLocation} className="btn-primary shrink-0">
<Plus size={14} />Добавить
</button>
</div>
{customLocations.length === 0 && (
<p className="text-sm text-slate-400 text-center py-4">Нет пользовательских локаций</p>
)}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 print:grid-cols-3">
{customLocations.map(loc => {
const url = `${appUrl}/review-qr/${slug}/${encodeURIComponent(loc)}`
return (
<div
key={loc}
className="relative flex flex-col items-center gap-3 p-4 rounded-2xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800 print:border print:border-slate-300 print:break-inside-avoid"
>
<button
onClick={() => setCustomLocations(prev => prev.filter(l => l !== loc))}
className="absolute top-2 right-2 p-1 rounded text-slate-300 hover:text-red-500 transition-colors print:hidden"
>
<Trash2 size={12} />
</button>
<QRCodeSVG value={url} size={120} bgColor="#ffffff" fgColor="#1e293b" level="M" />
<div className="text-center">
<p className="font-bold text-slate-900 dark:text-slate-100 text-sm">{loc}</p>
<p className="text-[10px] text-slate-400 mt-0.5 break-all">{url}</p>
</div>
<a href={url} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 text-xs text-brand-600 hover:underline print:hidden">
<ExternalLink size={11} />Открыть
</a>
<a
href="#"
download={`qr-${loc}.svg`}
className="flex items-center gap-1 text-xs text-slate-500 hover:text-brand-600 print:hidden"
onClick={e => {
e.preventDefault()
const svgEl = (e.currentTarget as HTMLElement).closest('.flex.flex-col')?.querySelector('svg')
if (!svgEl) return
const blob = new Blob([svgEl.outerHTML], { type: 'image/svg+xml' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = `qr-${loc}.svg`
a.click()
}}
>
<Download size={11} />Скачать SVG
</a>
</div>
)
})}
</div>
</div>
</div>
)}
{/* ══════════════════════ TAB: settings ══════════════════════ */}
{tab === 'settings' && (
<div className="max-w-2xl space-y-5">
{/* Enable toggle */}
<div className="card p-5">
<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">
<Mail size={15} className="text-brand-600" />
Автоматическая отправка запросов
</h3>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
После выезда гостя автоматически отправлять email с просьбой оставить отзыв
</p>
</div>
<button
onClick={() => setRequestEnabled(v => !v)}
className={cn('relative w-11 h-6 rounded-full transition-colors shrink-0', requestEnabled ? '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', requestEnabled ? 'left-[22px]' : 'left-1')} />
</button>
</div>
</div>
{/* 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">
<Clock 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 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">
{sourceMap.map((entry, idx) => (
<div key={idx} className={cn(
'flex items-center gap-3 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="flex-1 text-slate-600 dark:text-slate-400 font-mono text-xs">{entry.bookingSource}</span>
<ArrowUpRight size={12} className="text-slate-400 shrink-0" />
<span className="flex-1 font-medium text-blue-600 dark:text-blue-400">{entry.platformName}</span>
{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 shrink-0"> авто</span>
)}
<button
onClick={() => setSourceMap(prev => prev.filter((_, i) => i !== idx))}
className="p-1 text-slate-400 hover:text-red-500 transition-colors"
>
<Trash2 size={12} />
</button>
</div>
))}
{sourceMap.length === 0 && (
<p className="text-sm text-slate-400 text-center py-3">Нет правил. Гость всегда выбирает площадку сам.</p>
)}
<div className="pt-2 border-t border-slate-200 dark:border-slate-600 space-y-2">
<p className="text-xs font-medium text-slate-600 dark:text-slate-400">Добавить правило</p>
<div className="flex gap-2 flex-wrap">
<input
type="text"
className="input flex-1 min-w-[140px]"
placeholder="Источник (booking_com, yandex_travel…)"
value={newSrcSource}
onChange={e => setNewSrcSource(e.target.value)}
/>
<input
type="text"
className="input flex-1 min-w-[140px]"
placeholder="Площадка (Booking.com, Google…)"
value={newSrcPlatform}
onChange={e => setNewSrcPlatform(e.target.value)}
/>
<button
onClick={() => {
if (!newSrcSource.trim() || !newSrcPlatform.trim()) return
setSourceMap(prev => [...prev, { bookingSource: newSrcSource.trim(), platformName: newSrcPlatform.trim() }])
setNewSrcSource('')
setNewSrcPlatform('')
}}
className="btn-primary shrink-0"
>
<Plus size={14} />Добавить
</button>
</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>
{/* Save button */}
<button onClick={saveSettings} disabled={saving || !settingsLoaded} className="btn-primary gap-2">
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
Сохранить настройки
</button>
</div>
)}
{/* ══════════════════════ Review list ══════════════════════ */}
{tab !== 'settings' && tab !== 'preview' && tab !== 'qr' && (
<div className="space-y-3">
{loading && (
<div className="card p-8 text-center text-slate-500 dark:text-slate-400">
<Loader2 size={24} className="mx-auto mb-2 animate-spin opacity-50" />
<p>Загрузка...</p>
</div>
)}
{!loading && 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 bookingSrc = review.bookingSource ?? ''
const redirectTarget = useSourceRedirect
? bookingSrc === 'booking' ? 'Booking.com'
: bookingSrc === 'yandex' ? 'Яндекс Путешествия'
: bookingSrc === 'google' ? 'Google'
: null
: null
return (
<div
key={review.id}
className={cn(
'card p-4',
!review.isPublic && !review.reply && !review.rejectedAt && 'border-red-300 dark:border-red-700/60 bg-red-50/50 dark:bg-red-900/10',
!!review.rejectedAt && 'opacity-60',
)}
>
<div className="flex items-start gap-3">
<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>
{review.roomNumber && <Badge className="bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-400">{review.roomNumber}</Badge>}
{review.bookingSource && BOOKING_SOURCE_LABEL[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 && !review.rejectedAt && <Badge className="bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">Негативный</Badge>}
{!!review.rejectedAt && <Badge className="bg-slate-100 text-slate-500 dark:bg-slate-700 dark:text-slate-400">Отклонён</Badge>}
{review.isPublic && !isNegative && (
<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} />
{redirectTarget ? `${redirectTarget}` : '→ Выбрал площадку'}
</Badge>
)}
</div>
<span className="text-xs text-slate-400">{format(new Date(review.createdAt), 'd MMM, HH:mm', { locale: ru })}</span>
</div>
<StarRating rating={review.rating} />
{review.text && <p className="text-sm text-slate-700 dark:text-slate-300 mt-2 leading-relaxed">{review.text}</p>}
{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>
)}
{!review.isPublic && !review.reply && !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">
{review.source === 'qr' && !review.guestEmail && (
<p className="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 px-3 py-2 rounded-lg">
Гость не указал email ответ сохранится как внутренняя заметка
</p>
)}
{review.source === 'qr' && review.guestEmail && (
<p className="text-xs text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 px-3 py-2 rounded-lg">
Ответ отправится на: {review.guestEmail}
</p>
)}
<textarea
className="input resize-none text-sm w-full" rows={2}
placeholder={review.source === 'qr' && !review.guestEmail
? 'Внутренняя заметка (гость не оставил email)...'
: 'Личный ответ гостю (отправится по email)...'}
value={replyText}
onChange={e => setReplyText(e.target.value)}
autoFocus
/>
<div className="flex gap-2">
<button
onClick={() => sendReply(review.id, false)}
className="btn-primary text-xs py-1.5"
>
{review.source === 'qr' && !review.guestEmail
? 'Сохранить заметку'
: 'Отправить гостю'}
</button>
<button onClick={() => { setReplyId(null); setReplyEmail('') }} className="btn-secondary text-xs py-1.5">Отмена</button>
</div>
</div>
)}
</div>
<div className="flex gap-2 flex-wrap mt-3">
{!review.isPublic && !review.reply && !review.rejectedAt && replyId !== review.id && (
<>
<button
onClick={() => { setReplyId(review.id); setReplyText(''); setReplyEmail('') }}
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} />{review.source === 'qr' ? 'Добавить заметку / ответить' : 'Ответить гостю'}
</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.rejectedAt && (
<button
onClick={() => restore(review.id)}
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>
)
}