feat: reply email to guest, QR code reviews per room

- PATCH reviews/:id now sends email to guest when manager adds reply
  (sendReviewReplyEmail: hotel-branded, shows original rating, manager's text)
- New public endpoints: GET/POST /api/public/review-qr/:slug/:room
  (anonymous QR review, no token/booking required, source='qr')
- New page GuestReviewQrPage at /review-qr/:slug/:room
- ReviewsPage: new QR-коды tab — grid of QR codes per room with download SVG + print

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-21 10:23:54 +03:00
parent 67c47a6d01
commit 89e919bd8d
5 changed files with 503 additions and 10 deletions

View File

@@ -35,6 +35,7 @@ import { LoyaltyPage } from './pages/LoyaltyPage'
import { MaintenancePage } from './pages/MaintenancePage'
import { DiscountsPage } from './pages/DiscountsPage'
import { GuestReviewPage } from './pages/GuestReviewPage'
import { GuestReviewQrPage } from './pages/GuestReviewQrPage'
import { GuestRoomServicePage } from './pages/GuestRoomServicePage'
import { ResetPasswordPage } from './pages/ResetPasswordPage'
import { AcceptInvitePage } from './pages/AcceptInvitePage'
@@ -89,6 +90,7 @@ export default function App() {
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/invite/:token" element={<AcceptInvitePage />} />
<Route path="/review/:token" element={<GuestReviewPage />} />
<Route path="/review-qr/:slug/:room" element={<GuestReviewQrPage />} />
<Route path="/room-service/:slug" element={<GuestRoomServicePage />} />
<Route path="/:slug/pay" element={<PayDepositPage />} />
<Route path="/:slug/book" element={<BookingWidgetStandalonePage />} />

View File

@@ -0,0 +1,209 @@
import { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { Star, ExternalLink, CheckCircle2, Heart, Loader2 } from 'lucide-react'
import { cn } from '../lib/utils'
const BASE = (import.meta.env.VITE_API_URL as string | undefined) ?? 'https://api.hotelsync.ru'
interface QrConfig {
hotelName: string
logoLetter: string
welcomeText: string
color: string
redirectThreshold: number
showTextField: boolean
platforms: { name: string; url: string }[]
roomNumber: string
}
export function GuestReviewQrPage() {
const { slug, room } = useParams<{ slug: string; room: string }>()
const [config, setConfig] = useState<QrConfig | null>(null)
const [status, setStatus] = useState<'loading' | 'ready' | 'invalid'>('loading')
const [rating, setRating] = useState(0)
const [hover, setHover] = useState(0)
const [text, setText] = useState('')
const [submitted, setSubmitted] = useState(false)
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
if (!slug || !room) { setStatus('invalid'); return }
fetch(`${BASE}/api/public/review-qr/${slug}/${encodeURIComponent(room)}`)
.then(async res => {
if (!res.ok) { setStatus('invalid'); return }
setConfig(await res.json() as QrConfig)
setStatus('ready')
})
.catch(() => setStatus('invalid'))
}, [slug, room])
const handleSubmit = async () => {
if (rating === 0 || !slug || !room) return
setSubmitting(true)
try {
await fetch(`${BASE}/api/public/review-qr/${slug}/${encodeURIComponent(room)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rating, text: text.trim() || undefined }),
})
setSubmitted(true)
} catch {
setSubmitted(true)
} finally {
setSubmitting(false)
}
}
const color = config?.color ?? '#2563eb'
const threshold = config?.redirectThreshold ?? 4
const isPositive = rating >= threshold
if (status === 'loading') {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center">
<Loader2 size={32} className="text-slate-400 animate-spin" />
</div>
)
}
if (status === 'invalid') {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center p-4">
<div className="bg-white rounded-3xl shadow-2xl p-10 text-center max-w-sm w-full">
<div className="text-5xl mb-4">🔗</div>
<p className="font-bold text-slate-900 text-lg">Страница не найдена</p>
<p className="text-slate-500 text-sm mt-2">Проверьте QR-код.</p>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex items-center justify-center p-4">
<div className="w-full max-w-sm">
<div className="bg-white rounded-3xl shadow-2xl overflow-hidden">
<div className="h-2" style={{ backgroundColor: color }} />
<div className="px-8 py-8 text-center space-y-6">
{/* Logo */}
<div
className="w-20 h-20 rounded-2xl mx-auto flex items-center justify-center text-3xl font-bold text-white shadow-lg"
style={{ backgroundColor: color }}
>
{config?.logoLetter ?? 'О'}
</div>
<div>
<h1 className="text-xl font-bold text-slate-900">{config?.hotelName}</h1>
{!submitted && (
<p className="text-slate-500 mt-1 text-base">{config?.welcomeText ?? 'Как вам у нас?'}</p>
)}
</div>
{/* ── NOT SUBMITTED ── */}
{!submitted && (
<>
<div className="flex justify-center gap-2">
{Array.from({ length: 5 }, (_, i) => (
<button
key={i}
onMouseEnter={() => setHover(i + 1)}
onMouseLeave={() => setHover(0)}
onClick={() => setRating(i + 1)}
className="transition-transform hover:scale-110 active:scale-95"
>
<Star
size={40}
className={cn(
'transition-colors drop-shadow-sm',
(hover || rating) > i
? 'text-amber-400 fill-amber-400'
: 'text-slate-200 fill-slate-200',
)}
/>
</button>
))}
</div>
{(hover || rating) > 0 && (
<p className="text-sm font-medium text-slate-600 -mt-2">
{['', 'Очень плохо', 'Плохо', 'Нормально', 'Хорошо', 'Отлично!'][hover || rating]}
</p>
)}
{rating > 0 && config?.showTextField && (
<textarea
className="w-full border border-slate-200 rounded-2xl p-3.5 text-sm text-slate-700 bg-slate-50 resize-none focus:outline-none focus:border-blue-400 transition-colors placeholder:text-slate-400"
rows={3}
placeholder="Расскажите подробнее... (необязательно)"
value={text}
onChange={e => setText(e.target.value)}
/>
)}
<button
onClick={handleSubmit}
disabled={rating === 0 || submitting}
className="w-full py-3.5 rounded-2xl text-white font-semibold text-base transition-all hover:opacity-90 active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed shadow-lg flex items-center justify-center gap-2"
style={{ backgroundColor: color }}
>
{submitting && <Loader2 size={16} className="animate-spin" />}
Отправить отзыв
</button>
</>
)}
{/* ── SUBMITTED: NEGATIVE ── */}
{submitted && !isPositive && (
<div className="space-y-4 py-2">
<div className="w-16 h-16 rounded-full bg-slate-100 flex items-center justify-center mx-auto">
<Heart size={28} className="text-slate-400" />
</div>
<div>
<p className="font-bold text-slate-900 text-lg">Спасибо за честность</p>
<p className="text-slate-500 text-sm mt-1.5 leading-relaxed">
Мы сожалеем, что что-то пошло не так. Ваш отзыв поможет нам стать лучше.
</p>
</div>
</div>
)}
{/* ── SUBMITTED: POSITIVE ── */}
{submitted && isPositive && (
<div className="space-y-4 py-2">
<div className="w-16 h-16 rounded-full bg-emerald-50 flex items-center justify-center mx-auto">
<CheckCircle2 size={32} className="text-emerald-500" />
</div>
<div>
<p className="font-bold text-slate-900 text-lg">Спасибо за отзыв!</p>
<p className="text-slate-500 text-sm mt-1.5 leading-relaxed">
Если вам не сложно поделитесь впечатлениями на одной из площадок:
</p>
</div>
<div className="space-y-2.5">
{(config?.platforms ?? []).map(p => (
<a
key={p.name}
href={p.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between w-full px-4 py-3 rounded-2xl border-2 border-slate-100 hover:border-blue-200 bg-slate-50 hover:bg-blue-50 text-slate-700 font-medium text-sm transition-all group"
>
<span>{p.name}</span>
<ExternalLink size={14} className="text-slate-400 group-hover:text-blue-500 transition-colors" />
</a>
))}
</div>
</div>
)}
</div>
<div className="px-8 pb-6 text-center">
<p className="text-xs text-slate-300">HotelSync · система сбора отзывов</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,12 +1,13 @@
import { useState, useEffect, useCallback } from 'react'
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,
Mail, Clock, Eye, Palette, Globe, Save, Loader2, Download, Printer,
} 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'
@@ -60,7 +61,7 @@ function ratingColor(r: number, threshold: number): string {
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
}
type Tab = 'pending' | 'published' | 'all' | 'preview' | 'settings'
type Tab = 'pending' | 'published' | 'all' | 'preview' | 'settings' | 'qr'
// ── Component ─────────────────────────────────────────────────────────────────
@@ -87,6 +88,11 @@ export function ReviewsPage() {
const [requestEnabled, setRequestEnabled] = useState(true)
const [useSourceRedirect, setUseSourceRedirect] = useState(true)
// QR section — list of rooms loaded from API
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'
// Preview state
const [previewRating, setPreviewRating] = useState(0)
const [previewHover, setPreviewHover] = useState(0)
@@ -134,7 +140,12 @@ export function ReviewsPage() {
useEffect(() => {
loadReviews()
loadSettings()
}, [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 ───────────────────────────────────────────────────────────
@@ -224,8 +235,9 @@ export function ReviewsPage() {
{ 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 },
{ key: 'preview', label: 'Превью', Icon: Eye },
{ key: 'qr', label: 'QR-коды', Icon: QrCode },
{ key: 'settings', label: 'Настройки', Icon: Settings2 },
]
return (
@@ -466,6 +478,92 @@ export function ReviewsPage() {
</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>
</div>
)}
{/* ══════════════════════ TAB: settings ══════════════════════ */}
{tab === 'settings' && (
<div className="max-w-2xl space-y-5">
@@ -650,7 +748,7 @@ export function ReviewsPage() {
)}
{/* ══════════════════════ Review list ══════════════════════ */}
{tab !== 'settings' && tab !== 'preview' && (
{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">