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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user