diff --git a/backend/src/routes/reviews.ts b/backend/src/routes/reviews.ts index 3108eba..eff05d7 100644 --- a/backend/src/routes/reviews.ts +++ b/backend/src/routes/reviews.ts @@ -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 = {} for (const r of settings) s[r.key] = r.value - // Mapping booking source → keyword to match in platform name - const SOURCE_KEYWORD: Record = { - 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 { diff --git a/src/pages/ReviewsPage.tsx b/src/pages/ReviewsPage.tsx index 825913f..b1611c8 100644 --- a/src/pages/ReviewsPage.tsx +++ b/src/pages/ReviewsPage.tsx @@ -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(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(null)] + const appUrl = (import.meta.env.VITE_APP_URL as string | undefined) ?? 'https://app.hotelsync.ru' + const [customLocations, setCustomLocations] = useState([]) + 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(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() { ))} - {/* Logic banners */} -
-
- -
-

Отрицательные (< {threshold} звёзд)

-

- Модерация — менеджер пишет личный ответ гостю. Публично не публикуется. -

-
-
-
- -
-

Положительные (≥ {threshold} звёзд)

-

- Авторедирект на площадку источника бронирования или выбор из: {enabledPlatforms.map(p => p.name).join(', ') || '(настройте)'} -

-
-
-
- - {/* Review link */} -
-
- -
-

Ссылки для сбора отзывов

-

- Уникальная ссылка генерируется для каждого гостя при выезде и отправляется автоматически по email. -

-
- -
-
- {/* Tabs */}
{TABS.map(t => ( @@ -564,6 +550,81 @@ export function ReviewsPage() { })}
+ + {/* Custom locations */} +
+
+ +
+

QR-коды для мест

+

+ Для ресепшна, ресторана или любого другого места. Не привязаны к конкретному номеру. +

+
+
+ +
+ setNewLocation(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') addLocation() }} + /> + +
+ + {customLocations.length === 0 && ( +

Нет пользовательских локаций

+ )} + +
+ {customLocations.map(loc => { + const url = `${appUrl}/review-qr/${slug}/${encodeURIComponent(loc)}` + return ( + + ) + })} +
+
)} @@ -679,28 +740,60 @@ export function ReviewsPage() {
- {[ - { source: 'Booking.com бронирование', redirect: 'Booking.com', smart: true }, - { source: 'Яндекс Путешествия', redirect: 'Яндекс Путешествия', smart: true }, - { source: 'Google', redirect: 'Google', smart: true }, - { source: 'Прямое бронирование / виджет', redirect: 'Гость выбирает сам', smart: false }, - ].map(row => ( -
( +
- {row.source} -
- - {row.redirect} - {row.smart && useSourceRedirect && ( - ⚡ авто - )} -
+ {entry.bookingSource} + + {entry.platformName} + {useSourceRedirect && ( + ⚡ авто + )} +
))} + {sourceMap.length === 0 && ( +

Нет правил. Гость всегда выбирает площадку сам.

+ )} +
+

Добавить правило

+
+ setNewSrcSource(e.target.value)} + /> + setNewSrcPlatform(e.target.value)} + /> + +
+