feat: configurable source→platform map, custom location QR codes

- Settings tab: replace static source map rows with editable table
  (add/delete mappings, persisted as review_source_map in hotel_settings)
- QR tab: add custom location QR codes (Ресепшн, Ресторан, etc.)
  not tied to a room, stored as review_custom_locations
- Backend: GET /api/public/review/:token reads review_source_map from
  hotel_settings instead of hardcoded SOURCE_KEYWORD mapping

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-21 10:44:07 +03:00
parent 47215c7d64
commit 0e2180e3bc
2 changed files with 168 additions and 77 deletions

View File

@@ -159,38 +159,36 @@ const reviewsRoutes: FastifyPluginAsync = async (fastify) => {
AND key IN (
'review_redirect_threshold','review_platforms',
'review_show_text_field','review_welcome_text',
'review_brand_color','review_redirect_source'
'review_brand_color','review_redirect_source',
'review_source_map'
)`,
[row.hotel_id],
)
const s: Record<string, unknown> = {}
for (const r of settings) s[r.key] = r.value
// Mapping booking source → keyword to match in platform name
const SOURCE_KEYWORD: Record<string, string> = {
booking_com: 'booking',
airbnb: 'airbnb',
expedia: 'expedia',
vrbo: 'vrbo',
yandex_travel: 'яндекс',
}
const allPlatforms = Array.isArray(s.review_platforms)
? (s.review_platforms as { name: string; url: string; enabled: boolean }[])
.filter(p => p.enabled)
: []
const sourceMap = Array.isArray(s.review_source_map)
? (s.review_source_map as { bookingSource: string; platformName: string }[])
: []
const useSourceRedirect = s.review_redirect_source !== false
const bookingSrc = row.booking_source ?? 'direct'
const keyword = SOURCE_KEYWORD[bookingSrc]
// If source redirect is on and we have a matching platform — show only that one
// If source redirect is on, find matching entry in sourceMap, then find the platform by name
let platforms = allPlatforms.map(p => ({ name: p.name, url: p.url }))
if (useSourceRedirect && keyword) {
const match = allPlatforms.find(p =>
p.name.toLowerCase().includes(keyword),
)
if (match) platforms = [{ name: match.name, url: match.url }]
if (useSourceRedirect && sourceMap.length > 0) {
const entry = sourceMap.find(e => e.bookingSource === bookingSrc)
if (entry) {
const match = allPlatforms.find(p =>
p.name.toLowerCase().includes(entry.platformName.toLowerCase()),
)
if (match) platforms = [{ name: match.name, url: match.url }]
}
}
return {

View File

@@ -88,10 +88,24 @@ 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'
// 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)
@@ -129,7 +143,9 @@ export function ReviewsPage() {
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 (Array.isArray(s.review_platforms)) setPlatforms(s.review_platforms as ReviewPlatform[])
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 {
@@ -163,6 +179,8 @@ export function ReviewsPage() {
review_brand_color: previewColor,
review_show_text_field: previewShowText,
review_platforms: platforms,
review_source_map: sourceMap,
review_custom_locations: customLocations,
})
} finally {
setSaving(false)
@@ -192,6 +210,13 @@ export function ReviewsPage() {
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, {
@@ -270,45 +295,6 @@ export function ReviewsPage() {
))}
</div>
{/* Logic banners */}
<div className="grid md:grid-cols-2 gap-3">
<div className="card p-3 border-red-200 dark:border-red-700/50 bg-red-50/50 dark:bg-red-900/10 flex items-start gap-3">
<ThumbsDown size={14} className="text-red-600 mt-0.5 shrink-0" />
<div>
<p className="font-semibold text-slate-900 dark:text-slate-100 text-sm">Отрицательные (&lt; {threshold} звёзд)</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
Модерация менеджер пишет личный ответ гостю. Публично не публикуется.
</p>
</div>
</div>
<div className="card p-3 border-blue-200 dark:border-blue-700/50 bg-blue-50/50 dark:bg-blue-900/10 flex items-start gap-3">
<ArrowUpRight size={14} className="text-blue-600 mt-0.5 shrink-0" />
<div>
<p className="font-semibold text-slate-900 dark:text-slate-100 text-sm">Положительные ( {threshold} звёзд)</p>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
Авторедирект на площадку источника бронирования или выбор из: {enabledPlatforms.map(p => p.name).join(', ') || '(настройте)'}
</p>
</div>
</div>
</div>
{/* Review link */}
<div className="card p-4">
<div className="flex items-center gap-4 flex-wrap">
<QrCode size={24} className="text-slate-500 shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-semibold text-slate-900 dark:text-slate-100 text-sm mb-1">Ссылки для сбора отзывов</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
Уникальная ссылка генерируется для каждого гостя при выезде и отправляется автоматически по email.
</p>
</div>
<button onClick={copyLink} className="shrink-0 flex items-center gap-1 px-2 py-1.5 rounded-lg bg-white dark:bg-slate-700 border border-slate-200 dark:border-slate-600 text-xs font-medium hover:border-brand-400">
{copied ? <CheckCheck size={11} className="text-emerald-600" /> : <Copy size={11} />}
{copied ? 'Скопировано' : 'Пример ссылки'}
</button>
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 border-b border-slate-200 dark:border-slate-700 overflow-x-auto">
{TABS.map(t => (
@@ -564,6 +550,81 @@ export function ReviewsPage() {
})}
</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>
)}
@@ -679,28 +740,60 @@ export function ReviewsPage() {
</button>
</div>
<div className="space-y-2">
{[
{ source: 'Booking.com бронирование', redirect: 'Booking.com', smart: true },
{ source: 'Яндекс Путешествия', redirect: 'Яндекс Путешествия', smart: true },
{ source: 'Google', redirect: 'Google', smart: true },
{ source: 'Прямое бронирование / виджет', redirect: 'Гость выбирает сам', smart: false },
].map(row => (
<div key={row.source} className={cn(
'flex items-center justify-between p-3 rounded-xl border text-sm',
{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="text-slate-600 dark:text-slate-400">{row.source}</span>
<div className="flex items-center gap-1.5">
<ArrowUpRight size={12} className="text-slate-400" />
<span className={cn('font-medium', row.smart ? 'text-blue-600 dark:text-blue-400' : 'text-slate-500')}>{row.redirect}</span>
{row.smart && 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"> авто</span>
)}
</div>
<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>