feat: Russian market channels — Яндекс, Островок, Суточно, Авито, Bronevik, 101hotels + role-based sidebar
This commit is contained in:
31
backend/migrations/036_channels_ru.sql
Normal file
31
backend/migrations/036_channels_ru.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- Replace old channel constraint with Russian market channels
|
||||
ALTER TABLE channels DROP CONSTRAINT IF EXISTS channels_name_check;
|
||||
ALTER TABLE channels ADD CONSTRAINT channels_name_check
|
||||
CHECK (name IN (
|
||||
'booking_com',
|
||||
'yandex_travel',
|
||||
'ostrovok',
|
||||
'sutochno',
|
||||
'avito',
|
||||
'bronevik',
|
||||
'hotels101',
|
||||
'onetwotrip'
|
||||
));
|
||||
|
||||
-- Remove old non-RU channels from all hotels
|
||||
DELETE FROM channels WHERE name IN ('airbnb', 'expedia', 'vrbo');
|
||||
|
||||
-- Add new Russian channels for all existing hotels (skip if already exists)
|
||||
INSERT INTO channels (hotel_id, name)
|
||||
SELECT h.id, c.name
|
||||
FROM hotels h
|
||||
CROSS JOIN (VALUES
|
||||
('yandex_travel'),
|
||||
('ostrovok'),
|
||||
('sutochno'),
|
||||
('avito'),
|
||||
('bronevik'),
|
||||
('hotels101'),
|
||||
('onetwotrip')
|
||||
) AS c(name)
|
||||
ON CONFLICT (hotel_id, name) DO NOTHING;
|
||||
@@ -47,7 +47,7 @@ export async function seedIfEmpty() {
|
||||
}
|
||||
|
||||
// Insert channels (disabled by default)
|
||||
for (const name of ['booking_com', 'airbnb', 'expedia', 'vrbo']) {
|
||||
for (const name of ['booking_com', 'yandex_travel', 'ostrovok', 'sutochno', 'avito', 'bronevik', 'hotels101', 'onetwotrip']) {
|
||||
await db.query(
|
||||
`INSERT INTO channels (hotel_id, name) VALUES ($1, $2)`,
|
||||
[hotelId, name],
|
||||
|
||||
@@ -6,19 +6,17 @@ import type { Channel, SyncStatus } from '../types'
|
||||
import { cn } from '../lib/utils'
|
||||
import { Badge } from '../components/ui/Badge'
|
||||
|
||||
const CHANNEL_ICONS: Record<string, string> = {
|
||||
booking_com: '🔵',
|
||||
airbnb: '🔴',
|
||||
expedia: '🟡',
|
||||
vrbo: '🟢',
|
||||
yandex_travel: '🔴',
|
||||
ostrovok: '🟣',
|
||||
sutochno: '🟠',
|
||||
onetwotrip: '🔵',
|
||||
const CHANNEL_META: Record<string, { icon: string; label: string; description: string; integration: string }> = {
|
||||
booking_com: { icon: '🔵', label: 'Booking.com', description: 'Крупнейшая международная OTA', integration: 'XML API' },
|
||||
yandex_travel: { icon: '🔴', label: 'Яндекс Путешествия', description: 'Ведущая платформа бронирования в РФ', integration: 'TravelLine' },
|
||||
ostrovok: { icon: '🟣', label: 'Островок', description: 'Один из крупнейших российских OTA', integration: 'REST API' },
|
||||
sutochno: { icon: '🟠', label: 'Суточно.ру', description: 'Посуточная аренда и гостиницы', integration: 'iCal / TravelLine' },
|
||||
avito: { icon: '🟢', label: 'Авито', description: 'Крупнейшая площадка объявлений в РФ', integration: 'XML фид / REST' },
|
||||
bronevik: { icon: '🔷', label: 'Bronevik.com', description: 'Корпоративное бронирование командировок', integration: 'SOAP/XML API' },
|
||||
hotels101: { icon: '🟡', label: '101hotels.ru', description: 'Российский агрегатор отелей', integration: 'OTA XML' },
|
||||
onetwotrip: { icon: '🩵', label: 'OneTwoTrip', description: 'Онлайн-сервис для путешествий', integration: 'XML API' },
|
||||
}
|
||||
|
||||
const RUSSIAN_CHANNELS = new Set(['yandex_travel', 'ostrovok', 'sutochno', 'onetwotrip'])
|
||||
|
||||
function SyncStatusBadge({ status }: { status: SyncStatus }) {
|
||||
const map: Record<SyncStatus, { icon: React.ElementType; label: string; cls: string }> = {
|
||||
idle: { icon: Clock, label: 'Не настроен', cls: 'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300' },
|
||||
@@ -75,8 +73,8 @@ export function ChannelsPage() {
|
||||
const totalImported = channels.reduce((s, c) => s + c.bookingsImported, 0)
|
||||
const activeCount = channels.filter(c => c.isEnabled).length
|
||||
|
||||
const internationalChannels = channels.filter(c => !RUSSIAN_CHANNELS.has(c.name))
|
||||
const russianChannels = channels.filter(c => RUSSIAN_CHANNELS.has(c.name))
|
||||
const internationalChannels = channels.filter(c => c.name === 'booking_com')
|
||||
const russianChannels = channels.filter(c => c.name !== 'booking_com')
|
||||
|
||||
if (loading) return (
|
||||
<div className="p-6 flex items-center justify-center text-slate-400 text-sm">Загрузка...</div>
|
||||
@@ -148,8 +146,10 @@ export function ChannelsPage() {
|
||||
<div>
|
||||
<p className="font-medium text-amber-800 dark:text-amber-300">Демо-режим</p>
|
||||
<p className="text-sm text-amber-700 dark:text-amber-400 mt-0.5">
|
||||
В демо-режиме синхронизация имитируется. В продакшн-версии используется webhook-интеграция
|
||||
с реальными API каналов через менеджер каналов (Staah, SiteMinder, HotelRunner и др.)
|
||||
В демо-режиме синхронизация имитируется. В продакшн-версии используется прямая интеграция
|
||||
с API каждой площадки или через TravelLine как единую точку подключения.
|
||||
Для Яндекс Путешествий, Островка и Суточно.ру доступно подключение через TravelLine.
|
||||
Авито — через XML-фид и REST API. Bronevik — через SOAP XML API.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -170,10 +170,15 @@ function ChannelCard({ channel, onSync, onToggle }: {
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-11 h-11 rounded-xl bg-slate-100 dark:bg-slate-700 flex items-center justify-center text-2xl">
|
||||
{CHANNEL_ICONS[channel.name]}
|
||||
{CHANNEL_META[channel.name]?.icon ?? '🌐'}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900 dark:text-slate-100">{channel.displayName}</p>
|
||||
<p className="font-semibold text-slate-900 dark:text-slate-100">
|
||||
{CHANNEL_META[channel.name]?.label ?? channel.displayName}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 mb-1">
|
||||
{CHANNEL_META[channel.name]?.description}
|
||||
</p>
|
||||
<SyncStatusBadge status={channel.lastSyncStatus} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -231,6 +236,12 @@ function ChannelCard({ channel, onSync, onToggle }: {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{CHANNEL_META[channel.name]?.integration && (
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500">
|
||||
Интеграция: {CHANNEL_META[channel.name].integration}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onSync}
|
||||
disabled={isSyncing}
|
||||
|
||||
Reference in New Issue
Block a user