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 = { 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 (
{Array.from({ length: 5 }, (_, i) => ( ))} {rating}/5
) } 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([]) const [loading, setLoading] = useState(true) const [tab, setTab] = useState('pending') const [replyId, setReplyId] = useState(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(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(null)] const appUrl = (import.meta.env.VITE_APP_URL as string | undefined) ?? 'https://app.hotelsync.ru' const [customLocations, setCustomLocations] = useState([]) 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(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 (
{/* Header */}

Отзывы гостей

Отрицательные (< {threshold} звёзд) → модерация. Положительные → автоматический редирект на площадки.

{/* Stats */}
{[ { 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 => (

{s.value}

{s.label}

{s.sub}

))}
{/* Tabs */}
{TABS.map(t => ( ))}
{/* ══════════════════════ TAB: preview ══════════════════════ */} {tab === 'preview' && (
{/* Phone frame */}
{/* Header with optional background image */}
{previewHeaderImageUrl &&
}
{previewLogoUrl ? : previewHotelName[0] }

{previewHotelName}

{previewWelcome}

{!previewSubmitted && (
{Array.from({ length: 5 }, (_, i) => ( ))}
)} {previewRating > 0 && !previewSubmitted && previewShowText && (