import React, { useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Network, Loader2, Settings2, Plus, Trash2, Save, Eye, EyeOff, RefreshCw, CheckCircle2, ExternalLink, Info, Crown, Link as LinkIcon, Inbox, Clock, Globe, Layers, Zap, ShieldCheck, TrendingUp, AlertTriangle, HelpCircle, Copy, Send, Activity, Power, PowerOff, Search, Check, ChevronDown, X, } from "lucide-react"; import { apiClient } from "../lib/api-client"; import { __, sprintf } from "../lib/i18n"; import { PageHeader } from "../components/common/PageHeader"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "../components/ui/card"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { ModulePageSkeleton, ModuleListSkeleton, ModuleTableSkeleton, } from "../components/ui/module-skeleton"; import { Label } from "../components/ui/label"; import { Select } from "../components/ui/select"; import { Badge } from "../components/ui/badge"; import { useToast } from "../components/ui/toast"; import { ConfirmationDialog } from "../components/ui/confirmation-dialog"; import { channelManagerApi, type ChannelHealth, type ChannelManagerMeta, type ChannelRow, type ChannelTypeDef, type MappingRow, } from "../api/channel-manager-api"; /** * Channel Manager admin hub. * * White-label-safe: no product-name strings in user-facing copy * (the `"yatra"` second argument to __() is the i18n textdomain, * not visible text — it's how WP gettext keys translations). * * Layout mirrors the Whatsapp module: PageHeader + intro "About" * card + tab-strip Card with section content per tab. * * Tabs: * - Channels: list + add/edit/delete channel instances + credentials * - Mappings: trip × channel × external_id rows * - Bookings: inbound staged channel bookings * - Logs: audit of every push/pull/webhook event */ type CmTab = | "channels" | "mappings" | "bookings" | "logs" | "reconciliation" | "latency"; function getInitialTab(): CmTab { if (typeof window === "undefined") return "channels"; const tab = new URLSearchParams(window.location.search).get("tab"); if ( tab === "mappings" || tab === "bookings" || tab === "logs" || tab === "reconciliation" || tab === "latency" ) return tab; return "channels"; } const ChannelManager: React.FC = () => { const [activeTab, setActiveTab] = useState(() => getInitialTab()); const switchTab = (next: CmTab) => { setActiveTab(next); if (typeof window !== "undefined") { const url = new URL(window.location.href); url.searchParams.set("tab", next); window.history.replaceState({}, "", url.toString()); } }; const { data: meta, isLoading: metaLoading } = useQuery({ queryKey: ["channel-manager-meta"], queryFn: () => channelManagerApi.getMeta(), }); if (metaLoading) { return ; } if (!meta || !meta.is_eligible) { return (
); } const ready = Boolean(meta.is_module_enabled); const tabs: Array<{ key: CmTab; label: string; icon: any }> = [ { key: "channels", label: __("Channels", "yatra"), icon: Network }, { key: "mappings", label: __("Trip mappings", "yatra"), icon: LinkIcon }, { key: "bookings", label: __("Bookings inbox", "yatra"), icon: Inbox }, { key: "logs", label: __("Sync activity", "yatra"), icon: Clock }, // Reconciliation answers "where do I have unfinished business?" — // stale mappings, pending booking promotions, breaker states. { key: "reconciliation", label: __("Reconciliation", "yatra"), icon: AlertTriangle, }, // Latency answers "are my OTAs slow today?" — p50/p95/p99 of // sync durations across 24h + 7d windows. { key: "latency", label: __("Latency", "yatra"), icon: Activity }, ]; return (
{!ready ? ( ) : activeTab === "channels" ? ( ) : activeTab === "mappings" ? ( ) : activeTab === "bookings" ? ( ) : activeTab === "reconciliation" ? ( ) : activeTab === "latency" ? ( ) : ( )}
); }; /* -------------------------------------------------------------------------- */ /* About / How it works — always visible, brand-neutral */ /* -------------------------------------------------------------------------- */ const AboutCard: React.FC = () => ( {__("What is a Channel Manager?", "yatra")} {__( "OTAs (Online Travel Agencies) — Viator, GetYourGuide, Klook, TripAdvisor, Airbnb Experiences — drive 30–60% of bookings for most tour operators. Managing them by hand means updating inventory and prices in multiple dashboards, copy-pasting bookings between systems, and risking overbooking when seats sell on two channels at once.", "yatra", )}

{__( "This module connects directly to each OTA's API. Inventory + pricing are pushed automatically whenever a booking, departure, or availability change happens here. Bookings made on the OTA flow back into the same booking list as direct sales — same customer notifications, same revenue reporting, same automation rules.", "yatra", )}
); const HowItWorksStep: React.FC<{ icon: any; step: number; title: string; body: string; }> = ({ icon: Icon, step, title, body }) => (
{step}

{title}

{body}

); const ValueCallout: React.FC<{ icon: any; title: string; body: string; }> = ({ icon: Icon, title, body }) => (
{title}

{body}

); /* -------------------------------------------------------------------------- */ /* Upgrade / module prompts */ /* -------------------------------------------------------------------------- */ const UpgradeCard: React.FC<{ meta?: ChannelManagerMeta }> = ({ meta }) => (
{__("Channel Manager — Scale plan", "yatra")} {__( "Available on the Scale plan. Standalone channel managers charge $99–299/month for the same capability — it's included here at no extra cost.", "yatra", )}
); const ModulePrompt: React.FC = () => (

{__("Enable the Channel Manager module", "yatra")}

{__( "Your license tier qualifies. Turn on Channel Manager under Modules to start configuring it.", "yatra", )}

); /* -------------------------------------------------------------------------- */ /* Channels tab */ /* -------------------------------------------------------------------------- */ const ChannelsSection: React.FC<{ meta: ChannelManagerMeta }> = ({ meta }) => { const queryClient = useQueryClient(); const { showToast } = useToast(); const [editing, setEditing] = useState(null); // Confirmation dialog for delete — replaces native window.confirm() // so the destructive prompt matches the rest of the admin UI. const [pendingDeleteChannel, setPendingDeleteChannel] = useState<{ id: number; name: string; } | null>(null); const { data, isLoading } = useQuery({ queryKey: ["channel-manager-channels"], queryFn: () => channelManagerApi.listChannels(), }); const deleteChannel = useMutation({ mutationFn: (id: number) => channelManagerApi.deleteChannel(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["channel-manager-channels"] }); showToast(__("Channel deleted.", "yatra"), "success"); setPendingDeleteChannel(null); }, onError: (e: any) => { showToast(extractError(e), "error"); setPendingDeleteChannel(null); }, }); if (isLoading) { return ; } if (editing !== null) { const channel = editing === "new" ? null : (data?.data.find((c) => c.id === editing) ?? null); return ( setEditing(null)} /> ); } const channels = data?.data ?? []; const supportedChannelNames = meta.channel_types .map((t) => t.name) .join(", "); return (

{__("Configured channels", "yatra")}

{__( "Add one connection per OTA you want to distribute on. Credentials are stored libsodium-encrypted; nothing is proxied through any third-party service.", "yatra", )}

{meta.channel_types.length > 0 && (

{__("Supported OTAs:", "yatra")} {" "} {supportedChannelNames}

)}
{channels.length === 0 ? ( setEditing("new")} meta={meta} /> ) : (
{channels.map((ch) => { const type = meta.channel_types.find( (t) => t.type === ch.channel_type, ); return ( ); })}
{__("Channel", "yatra")} {__("Account", "yatra")} {__("Health", "yatra")} {__("Mode", "yatra")} {__("Status", "yatra")} {__("Last sync", "yatra")} {__("Actions", "yatra")}
{type?.name ?? ch.channel_type}
{ch.display_name || "—"}
{ch.account_label || "—"} {ch.is_test_mode ? __("Sandbox", "yatra") : __("Live", "yatra")} {ch.is_enabled ? __("Enabled", "yatra") : __("Disabled", "yatra")} {ch.last_sync_at ? new Date(ch.last_sync_at).toLocaleString() : "—"} {ch.last_sync_status === "failed" && ( {__("failed", "yatra")} )}
)}
{ if (!deleteChannel.isPending) setPendingDeleteChannel(null); }} onConfirm={() => { if (pendingDeleteChannel) deleteChannel.mutate(pendingDeleteChannel.id); }} title={__("Delete channel?", "yatra")} description={ pendingDeleteChannel ? __( "Delete the “{name}” channel? Credentials and trip mappings linked to it will be removed. This cannot be undone.", "yatra", ).replace("{name}", pendingDeleteChannel.name) : "" } confirmText={__("Delete channel", "yatra")} cancelText={__("Cancel", "yatra")} variant="danger" isLoading={deleteChannel.isPending} />
); }; const EmptyChannels: React.FC<{ onAdd: () => void; meta: ChannelManagerMeta; }> = ({ onAdd, meta }) => (

{__("No channels yet", "yatra")}

{__( "Connect your first OTA to start distributing trips. You'll need API credentials from that OTA's partner portal — there's a help link inside the form.", "yatra", )}

{meta.docs_url && ( )}
); const ChannelEditForm: React.FC<{ meta: ChannelManagerMeta; existing: ChannelRow | null; onClose: () => void; }> = ({ meta, existing, onClose }) => { const queryClient = useQueryClient(); const { showToast } = useToast(); const isCreate = existing === null; const [channelType, setChannelType] = useState( existing?.channel_type ?? meta.channel_types[0]?.type ?? "", ); const [displayName, setDisplayName] = useState(existing?.display_name ?? ""); const [accountLabel, setAccountLabel] = useState( existing?.account_label ?? "", ); const [isEnabled, setIsEnabled] = useState(existing?.is_enabled ?? false); const [isTestMode, setIsTestMode] = useState(existing?.is_test_mode ?? true); const [currency, setCurrency] = useState(existing?.currency ?? "USD"); const [defaultOffset, setDefaultOffset] = useState( existing?.default_offset_percent ?? 0, ); const [commission, setCommission] = useState( existing?.commission_percent ?? 0, ); const [buffer, setBuffer] = useState(existing?.inventory_buffer ?? 0); // Per-channel IP allowlist for the inbound webhook receiver. Stored // in the channel's `settings` JSON under `allowed_ips`. Empty = no // restriction (default). Comma- and/or newline-separated CIDR list. const [allowedIps, setAllowedIps] = useState( ((existing?.settings as Record | undefined) ?.allowed_ips as string) ?? "", ); const typeDef = useMemo( () => meta.channel_types.find((t) => t.type === channelType) ?? null, [meta.channel_types, channelType], ); const save = useMutation({ mutationFn: () => { const payload = { channel_type: channelType, display_name: displayName, account_label: accountLabel, is_enabled: isEnabled, is_test_mode: isTestMode, currency, default_offset_percent: defaultOffset, commission_percent: commission, inventory_buffer: buffer, // Merge into existing settings so unrelated keys (per-provider // overrides, custom fields a future feature might add) are // preserved on update. settings: { ...((existing?.settings as Record | undefined) ?? {}), allowed_ips: allowedIps, }, }; return isCreate ? channelManagerApi.createChannel(payload) : channelManagerApi.updateChannel(existing!.id, payload); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["channel-manager-channels"] }); showToast( isCreate ? __("Channel created. Add credentials next to enable sync.", "yatra") : __("Channel saved.", "yatra"), "success", ); if (isCreate) onClose(); }, onError: (e: any) => showToast(extractError(e), "error"), }); const testConnection = useMutation({ mutationFn: () => channelManagerApi.testConnection(existing!.id), onSuccess: (res) => { if (res.ok) { showToast( __("Connection OK — credentials accepted by the OTA.", "yatra"), "success", ); } else { showToast(res.error || __("Connection failed.", "yatra"), "error"); } }, onError: (e: any) => showToast(extractError(e), "error"), }); const syncChannel = useMutation({ mutationFn: () => channelManagerApi.syncChannel(existing!.id), onSuccess: (res) => showToast(res.message, "success"), onError: (e: any) => showToast(extractError(e), "error"), }); const testWebhook = useMutation({ mutationFn: () => channelManagerApi.testWebhook(existing!.id), onSuccess: (res) => { if (res.ok) { showToast( __( "Webhook self-test succeeded — your endpoint is reachable.", "yatra", ), "success", ); } else { showToast( res.error || __( "Webhook self-test failed — see Sync activity for details.", "yatra", ), "error", ); } }, onError: (e: any) => showToast(extractError(e), "error"), }); return (

{isCreate ? __("Add channel", "yatra") : __("Edit channel", "yatra")}

{typeDef?.description && (

{typeDef.description}

)} {typeDef && (
{typeDef.docs_url && ( {__("Integration docs", "yatra")} )} {typeDef.signup_url && ( {__("Apply to partner program", "yatra")} )}
)}
{!isCreate && ( <> )}
{isCreate && (

{__( "Channels are created in disabled + sandbox mode by default. You'll add credentials and test the connection on the next screen, then flip Enabled on once you've verified everything works.", "yatra", )}

)} {__("Connection", "yatra")} {__( "Pick which OTA this connection is for and give it a friendly label.", "yatra", )}
setDisplayName(e.target.value)} placeholder={typeDef?.name ?? ""} /> setAccountLabel(e.target.value)} placeholder={__("e.g. US production account", "yatra")} /> setCurrency(e.target.value.toUpperCase().slice(0, 3)) } placeholder="USD" maxLength={3} />
{__("Pricing & inventory rules", "yatra")} {__( "How prices and seat counts on this channel differ from your base trip. These apply across every trip mapped to this channel — individual mappings can override on a per-trip basis.", "yatra", )}
setDefaultOffset(Number(e.target.value) || 0)} placeholder="0" /> setCommission(Number(e.target.value) || 0)} placeholder="0" /> setBuffer(Number(e.target.value) || 0)} placeholder="0" />
{!isCreate && (