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:
@@ -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 {
|
||||
|
||||
@@ -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">Отрицательные (< {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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user