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:
@@ -304,3 +304,63 @@ export async function sendReviewRequestEmail(params: {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendReviewReplyEmail(params: {
|
||||
to: string
|
||||
guestFirstName: string
|
||||
hotelName: string
|
||||
brandColor: string
|
||||
replyText: string
|
||||
originalRating: number
|
||||
}): Promise<void> {
|
||||
const { to, guestFirstName, hotelName, brandColor, replyText, originalRating } = params
|
||||
const siteUrl = process.env.SITE_URL ?? 'https://hotelsync.ru'
|
||||
const greeting = guestFirstName ? `Здравствуйте, ${guestFirstName}!` : 'Здравствуйте!'
|
||||
const stars = '⭐'.repeat(originalRating)
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
</head>
|
||||
<body style="font-family:Arial,sans-serif;background:#f1f5f9;margin:0;padding:40px 16px;">
|
||||
<div style="max-width:520px;margin:0 auto;background:#ffffff;border-radius:16px;overflow:hidden;box-shadow:0 4px 24px rgba(0,0,0,0.08);">
|
||||
<div style="background:${brandColor};padding:28px 40px;text-align:center;">
|
||||
<h1 style="color:#ffffff;margin:0;font-size:24px;font-weight:700;letter-spacing:-0.5px;">${hotelName}</h1>
|
||||
</div>
|
||||
<div style="padding:40px;">
|
||||
<h2 style="color:#1e293b;font-size:20px;margin:0 0 16px;font-weight:600;">Ответ на ваш отзыв</h2>
|
||||
<p style="color:#334155;line-height:1.7;margin:0 0 20px;font-size:15px;">${greeting}</p>
|
||||
<p style="color:#334155;line-height:1.7;margin:0 0 20px;font-size:15px;">
|
||||
Спасибо, что поделились своим мнением о <strong>${hotelName}</strong>. Мы получили ваш отзыв ${stars} и хотим ответить.
|
||||
</p>
|
||||
<div style="background:#f8fafc;border-left:4px solid ${brandColor};border-radius:0 10px 10px 0;padding:16px 20px;margin:0 0 28px;">
|
||||
<p style="color:#334155;font-size:14px;line-height:1.7;margin:0;font-style:italic;">${replyText}</p>
|
||||
</div>
|
||||
<p style="color:#475569;font-size:13px;margin:0;line-height:1.6;text-align:center;">
|
||||
Будем рады видеть вас снова!
|
||||
</p>
|
||||
<hr style="border:none;border-top:1px solid #e2e8f0;margin:20px 0;">
|
||||
<p style="color:#64748b;font-size:11px;margin:0;line-height:1.6;text-align:center;">
|
||||
© 2026 ${hotelName} · <a href="${siteUrl}" style="color:#6366f1;text-decoration:none;">hotelsync.ru</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const text = `${hotelName} — ответ на ваш отзыв\n\n${greeting}\n\nСпасибо за ваш отзыв. Ответ от ${hotelName}:\n\n"${replyText}"\n\nБудем рады видеть вас снова!\n\n© 2026 ${hotelName}`
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"${hotelName}" <${fromAddr()}>`,
|
||||
to,
|
||||
subject: `${hotelName} — ответ на ваш отзыв`,
|
||||
html,
|
||||
text,
|
||||
headers: {
|
||||
'List-Unsubscribe': unsubscribeHeader,
|
||||
'Content-Language': 'ru',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { FastifyPluginAsync } from 'fastify'
|
||||
import { db } from '../db'
|
||||
import { sendReviewReplyEmail } from '../email'
|
||||
|
||||
type SlugParam = { Params: { slug: string } }
|
||||
type SlugIdParam = { Params: { slug: string; id: string } }
|
||||
type TokenParam = { Params: { token: string } }
|
||||
type SlugParam = { Params: { slug: string } }
|
||||
type SlugIdParam = { Params: { slug: string; id: string } }
|
||||
type TokenParam = { Params: { token: string } }
|
||||
type SlugRoomParam = { Params: { slug: string; room: string } }
|
||||
|
||||
function extractFirstName(fullName: string | null): string {
|
||||
if (!fullName) return ''
|
||||
const parts = fullName.trim().split(/\s+/)
|
||||
return parts.length >= 2 ? parts[1] : ''
|
||||
}
|
||||
|
||||
const canAccess = (userSlug: string | null, role: string, slug: string) =>
|
||||
role === 'super_admin' || userSlug === slug
|
||||
@@ -78,6 +86,35 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
vals,
|
||||
)
|
||||
if (!rows[0]) return reply.code(404).send({ error: 'Review not found' })
|
||||
|
||||
// Send email to guest when manager adds a reply
|
||||
if (replyText && rows[0].booking_id) {
|
||||
const { rows: bRows } = await db.query<{
|
||||
guest_email: string | null; guest_name: string | null
|
||||
}>(
|
||||
`SELECT guest_email, guest_name FROM bookings WHERE id = $1`,
|
||||
[rows[0].booking_id],
|
||||
)
|
||||
const guestEmail = bRows[0]?.guest_email
|
||||
if (guestEmail) {
|
||||
const { rows: hRows } = await db.query<{ name: string }>(
|
||||
`SELECT h.name,
|
||||
COALESCE((SELECT value #>> '{}' FROM hotel_settings
|
||||
WHERE hotel_id = h.id AND key = 'review_brand_color'), '#2563eb') AS color
|
||||
FROM hotels h WHERE h.id = $1`,
|
||||
[hotelId],
|
||||
)
|
||||
sendReviewReplyEmail({
|
||||
to: guestEmail,
|
||||
guestFirstName: extractFirstName(bRows[0]?.guest_name ?? null),
|
||||
hotelName: (hRows[0] as unknown as { name: string; color: string })?.name ?? '',
|
||||
brandColor: (hRows[0] as unknown as { name: string; color: string })?.color ?? '#2563eb',
|
||||
replyText,
|
||||
originalRating: rows[0].rating,
|
||||
}).catch(err => console.error('[reviews] reply email error:', err))
|
||||
}
|
||||
}
|
||||
|
||||
return rows[0]
|
||||
},
|
||||
)
|
||||
@@ -218,6 +255,93 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
|
||||
// ── GET /api/public/review-qr/:slug/:room ─────────────────────────────────
|
||||
// Anonymous QR review — returns hotel config, no booking required
|
||||
fastify.get<SlugRoomParam>(
|
||||
'/api/public/review-qr/:slug/:room',
|
||||
async (request, reply) => {
|
||||
const { slug, room } = request.params
|
||||
|
||||
const { rows } = await db.query<{ id: string; name: string }>(
|
||||
`SELECT id, name FROM hotels WHERE slug = $1 AND is_active = true`,
|
||||
[slug],
|
||||
)
|
||||
const hotel = rows[0]
|
||||
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
const { rows: settings } = await db.query(
|
||||
`SELECT key, value FROM hotel_settings
|
||||
WHERE hotel_id = $1
|
||||
AND key IN (
|
||||
'review_redirect_threshold','review_platforms',
|
||||
'review_show_text_field','review_welcome_text',
|
||||
'review_brand_color'
|
||||
)`,
|
||||
[hotel.id],
|
||||
)
|
||||
const s: Record<string, unknown> = {}
|
||||
for (const r of settings) s[r.key] = r.value
|
||||
|
||||
const allPlatforms = Array.isArray(s.review_platforms)
|
||||
? (s.review_platforms as { name: string; url: string; enabled: boolean }[])
|
||||
.filter(p => p.enabled)
|
||||
.map(p => ({ name: p.name, url: p.url }))
|
||||
: []
|
||||
|
||||
return {
|
||||
hotelName: hotel.name,
|
||||
logoLetter: hotel.name[0] ?? 'О',
|
||||
welcomeText: (s.review_welcome_text as string | undefined) ?? 'Как вам у нас?',
|
||||
color: (s.review_brand_color as string | undefined) ?? '#2563eb',
|
||||
redirectThreshold: Number(s.review_redirect_threshold ?? 4),
|
||||
showTextField: s.review_show_text_field !== false,
|
||||
platforms: allPlatforms,
|
||||
roomNumber: room,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// ── POST /api/public/review-qr/:slug/:room ────────────────────────────────
|
||||
fastify.post<SlugRoomParam & { Body: { rating: number; text?: string } }>(
|
||||
'/api/public/review-qr/:slug/:room',
|
||||
async (request, reply) => {
|
||||
const { slug, room } = request.params
|
||||
const { rating, text } = request.body
|
||||
|
||||
if (!rating || rating < 1 || rating > 5) {
|
||||
return reply.code(400).send({ error: 'Rating must be 1–5' })
|
||||
}
|
||||
|
||||
const { rows } = await db.query<{ id: string }>(
|
||||
`SELECT id FROM hotels WHERE slug = $1`,
|
||||
[slug],
|
||||
)
|
||||
const hotel = rows[0]
|
||||
if (!hotel) return reply.code(404).send({ error: 'Hotel not found' })
|
||||
|
||||
// Find room_id by number for attribution
|
||||
const { rows: roomRows } = await db.query<{ id: string }>(
|
||||
`SELECT id FROM rooms WHERE hotel_id = $1 AND number = $2 LIMIT 1`,
|
||||
[hotel.id, room],
|
||||
)
|
||||
|
||||
await db.query(
|
||||
`INSERT INTO reviews
|
||||
(hotel_id, booking_id, guest_id, guest_name, source, rating, text, is_public)
|
||||
VALUES ($1, NULL, NULL, $2, 'qr', $3, $4, $5)`,
|
||||
[
|
||||
hotel.id,
|
||||
roomRows[0] ? `Номер ${room}` : `QR (номер ${room})`,
|
||||
rating,
|
||||
text ?? null,
|
||||
rating >= 4,
|
||||
],
|
||||
)
|
||||
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export default reviewsRoutes
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
209
src/pages/GuestReviewQrPage.tsx
Normal file
209
src/pages/GuestReviewQrPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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