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)
|
// 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(
|
await db.query(
|
||||||
`INSERT INTO channels (hotel_id, name) VALUES ($1, $2)`,
|
`INSERT INTO channels (hotel_id, name) VALUES ($1, $2)`,
|
||||||
[hotelId, name],
|
[hotelId, name],
|
||||||
|
|||||||
@@ -6,19 +6,17 @@ import type { Channel, SyncStatus } from '../types'
|
|||||||
import { cn } from '../lib/utils'
|
import { cn } from '../lib/utils'
|
||||||
import { Badge } from '../components/ui/Badge'
|
import { Badge } from '../components/ui/Badge'
|
||||||
|
|
||||||
const CHANNEL_ICONS: Record<string, string> = {
|
const CHANNEL_META: Record<string, { icon: string; label: string; description: string; integration: string }> = {
|
||||||
booking_com: '🔵',
|
booking_com: { icon: '🔵', label: 'Booking.com', description: 'Крупнейшая международная OTA', integration: 'XML API' },
|
||||||
airbnb: '🔴',
|
yandex_travel: { icon: '🔴', label: 'Яндекс Путешествия', description: 'Ведущая платформа бронирования в РФ', integration: 'TravelLine' },
|
||||||
expedia: '🟡',
|
ostrovok: { icon: '🟣', label: 'Островок', description: 'Один из крупнейших российских OTA', integration: 'REST API' },
|
||||||
vrbo: '🟢',
|
sutochno: { icon: '🟠', label: 'Суточно.ру', description: 'Посуточная аренда и гостиницы', integration: 'iCal / TravelLine' },
|
||||||
yandex_travel: '🔴',
|
avito: { icon: '🟢', label: 'Авито', description: 'Крупнейшая площадка объявлений в РФ', integration: 'XML фид / REST' },
|
||||||
ostrovok: '🟣',
|
bronevik: { icon: '🔷', label: 'Bronevik.com', description: 'Корпоративное бронирование командировок', integration: 'SOAP/XML API' },
|
||||||
sutochno: '🟠',
|
hotels101: { icon: '🟡', label: '101hotels.ru', description: 'Российский агрегатор отелей', integration: 'OTA XML' },
|
||||||
onetwotrip: '🔵',
|
onetwotrip: { icon: '🩵', label: 'OneTwoTrip', description: 'Онлайн-сервис для путешествий', integration: 'XML API' },
|
||||||
}
|
}
|
||||||
|
|
||||||
const RUSSIAN_CHANNELS = new Set(['yandex_travel', 'ostrovok', 'sutochno', 'onetwotrip'])
|
|
||||||
|
|
||||||
function SyncStatusBadge({ status }: { status: SyncStatus }) {
|
function SyncStatusBadge({ status }: { status: SyncStatus }) {
|
||||||
const map: Record<SyncStatus, { icon: React.ElementType; label: string; cls: string }> = {
|
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' },
|
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 totalImported = channels.reduce((s, c) => s + c.bookingsImported, 0)
|
||||||
const activeCount = channels.filter(c => c.isEnabled).length
|
const activeCount = channels.filter(c => c.isEnabled).length
|
||||||
|
|
||||||
const internationalChannels = channels.filter(c => !RUSSIAN_CHANNELS.has(c.name))
|
const internationalChannels = channels.filter(c => c.name === 'booking_com')
|
||||||
const russianChannels = channels.filter(c => RUSSIAN_CHANNELS.has(c.name))
|
const russianChannels = channels.filter(c => c.name !== 'booking_com')
|
||||||
|
|
||||||
if (loading) return (
|
if (loading) return (
|
||||||
<div className="p-6 flex items-center justify-center text-slate-400 text-sm">Загрузка...</div>
|
<div className="p-6 flex items-center justify-center text-slate-400 text-sm">Загрузка...</div>
|
||||||
@@ -148,8 +146,10 @@ export function ChannelsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-amber-800 dark:text-amber-300">Демо-режим</p>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,10 +170,15 @@ function ChannelCard({ channel, onSync, onToggle }: {
|
|||||||
<div className="flex items-start justify-between mb-4">
|
<div className="flex items-start justify-between mb-4">
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<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>
|
||||||
<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} />
|
<SyncStatusBadge status={channel.lastSyncStatus} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -231,6 +236,12 @@ function ChannelCard({ channel, onSync, onToggle }: {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{CHANNEL_META[channel.name]?.integration && (
|
||||||
|
<p className="text-xs text-slate-400 dark:text-slate-500">
|
||||||
|
Интеграция: {CHANNEL_META[channel.name].integration}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={onSync}
|
onClick={onSync}
|
||||||
disabled={isSyncing}
|
disabled={isSyncing}
|
||||||
|
|||||||
Reference in New Issue
Block a user