| 1 |
import React, { useEffect, useMemo, useRef, useState } from "react"; |
| 2 |
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 3 |
import { |
| 4 |
Network, |
| 5 |
Loader2, |
| 6 |
Settings2, |
| 7 |
Plus, |
| 8 |
Trash2, |
| 9 |
Save, |
| 10 |
Eye, |
| 11 |
EyeOff, |
| 12 |
RefreshCw, |
| 13 |
CheckCircle2, |
| 14 |
ExternalLink, |
| 15 |
Info, |
| 16 |
Crown, |
| 17 |
Link as LinkIcon, |
| 18 |
Inbox, |
| 19 |
Clock, |
| 20 |
Globe, |
| 21 |
Layers, |
| 22 |
Zap, |
| 23 |
ShieldCheck, |
| 24 |
TrendingUp, |
| 25 |
AlertTriangle, |
| 26 |
HelpCircle, |
| 27 |
Copy, |
| 28 |
Send, |
| 29 |
Activity, |
| 30 |
Power, |
| 31 |
PowerOff, |
| 32 |
Search, |
| 33 |
Check, |
| 34 |
ChevronDown, |
| 35 |
X, |
| 36 |
} from "lucide-react"; |
| 37 |
import { apiClient } from "../lib/api-client"; |
| 38 |
import { __, sprintf } from "../lib/i18n"; |
| 39 |
import { PageHeader } from "../components/common/PageHeader"; |
| 40 |
import { |
| 41 |
Card, |
| 42 |
CardContent, |
| 43 |
CardDescription, |
| 44 |
CardHeader, |
| 45 |
CardTitle, |
| 46 |
} from "../components/ui/card"; |
| 47 |
import { Button } from "../components/ui/button"; |
| 48 |
import { Input } from "../components/ui/input"; |
| 49 |
import { |
| 50 |
ModulePageSkeleton, |
| 51 |
ModuleListSkeleton, |
| 52 |
ModuleTableSkeleton, |
| 53 |
} from "../components/ui/module-skeleton"; |
| 54 |
import { Label } from "../components/ui/label"; |
| 55 |
import { Select } from "../components/ui/select"; |
| 56 |
import { Badge } from "../components/ui/badge"; |
| 57 |
import { useToast } from "../components/ui/toast"; |
| 58 |
import { ConfirmationDialog } from "../components/ui/confirmation-dialog"; |
| 59 |
import { |
| 60 |
channelManagerApi, |
| 61 |
type ChannelHealth, |
| 62 |
type ChannelManagerMeta, |
| 63 |
type ChannelRow, |
| 64 |
type ChannelTypeDef, |
| 65 |
type MappingRow, |
| 66 |
} from "../api/channel-manager-api"; |
| 67 |
|
| 68 |
/** |
| 69 |
* Channel Manager admin hub. |
| 70 |
* |
| 71 |
* White-label-safe: no product-name strings in user-facing copy |
| 72 |
* (the `"yatra"` second argument to __() is the i18n textdomain, |
| 73 |
* not visible text — it's how WP gettext keys translations). |
| 74 |
* |
| 75 |
* Layout mirrors the Whatsapp module: PageHeader + intro "About" |
| 76 |
* card + tab-strip Card with section content per tab. |
| 77 |
* |
| 78 |
* Tabs: |
| 79 |
* - Channels: list + add/edit/delete channel instances + credentials |
| 80 |
* - Mappings: trip × channel × external_id rows |
| 81 |
* - Bookings: inbound staged channel bookings |
| 82 |
* - Logs: audit of every push/pull/webhook event |
| 83 |
*/ |
| 84 |
|
| 85 |
type CmTab = |
| 86 |
| "channels" |
| 87 |
| "mappings" |
| 88 |
| "bookings" |
| 89 |
| "logs" |
| 90 |
| "reconciliation" |
| 91 |
| "latency"; |
| 92 |
|
| 93 |
function getInitialTab(): CmTab { |
| 94 |
if (typeof window === "undefined") return "channels"; |
| 95 |
const tab = new URLSearchParams(window.location.search).get("tab"); |
| 96 |
if ( |
| 97 |
tab === "mappings" || |
| 98 |
tab === "bookings" || |
| 99 |
tab === "logs" || |
| 100 |
tab === "reconciliation" || |
| 101 |
tab === "latency" |
| 102 |
) |
| 103 |
return tab; |
| 104 |
return "channels"; |
| 105 |
} |
| 106 |
|
| 107 |
const ChannelManager: React.FC = () => { |
| 108 |
const [activeTab, setActiveTab] = useState<CmTab>(() => getInitialTab()); |
| 109 |
|
| 110 |
const switchTab = (next: CmTab) => { |
| 111 |
setActiveTab(next); |
| 112 |
if (typeof window !== "undefined") { |
| 113 |
const url = new URL(window.location.href); |
| 114 |
url.searchParams.set("tab", next); |
| 115 |
window.history.replaceState({}, "", url.toString()); |
| 116 |
} |
| 117 |
}; |
| 118 |
|
| 119 |
const { data: meta, isLoading: metaLoading } = useQuery<ChannelManagerMeta>({ |
| 120 |
queryKey: ["channel-manager-meta"], |
| 121 |
queryFn: () => channelManagerApi.getMeta(), |
| 122 |
}); |
| 123 |
|
| 124 |
if (metaLoading) { |
| 125 |
return <ModulePageSkeleton variant="tabs" />; |
| 126 |
} |
| 127 |
|
| 128 |
if (!meta || !meta.is_eligible) { |
| 129 |
return ( |
| 130 |
<div className="space-y-6"> |
| 131 |
<PageHeader |
| 132 |
description={__( |
| 133 |
"Distribute your tours on global OTAs from one dashboard. Push inventory and pricing automatically, receive bookings back into a unified inbox.", |
| 134 |
"yatra", |
| 135 |
)} |
| 136 |
/> |
| 137 |
<AboutCard /> |
| 138 |
<UpgradeCard meta={meta} /> |
| 139 |
</div> |
| 140 |
); |
| 141 |
} |
| 142 |
|
| 143 |
const ready = Boolean(meta.is_module_enabled); |
| 144 |
|
| 145 |
const tabs: Array<{ key: CmTab; label: string; icon: any }> = [ |
| 146 |
{ key: "channels", label: __("Channels", "yatra"), icon: Network }, |
| 147 |
{ key: "mappings", label: __("Trip mappings", "yatra"), icon: LinkIcon }, |
| 148 |
{ key: "bookings", label: __("Bookings inbox", "yatra"), icon: Inbox }, |
| 149 |
{ key: "logs", label: __("Sync activity", "yatra"), icon: Clock }, |
| 150 |
// Reconciliation answers "where do I have unfinished business?" — |
| 151 |
// stale mappings, pending booking promotions, breaker states. |
| 152 |
{ |
| 153 |
key: "reconciliation", |
| 154 |
label: __("Reconciliation", "yatra"), |
| 155 |
icon: AlertTriangle, |
| 156 |
}, |
| 157 |
// Latency answers "are my OTAs slow today?" — p50/p95/p99 of |
| 158 |
// sync durations across 24h + 7d windows. |
| 159 |
{ key: "latency", label: __("Latency", "yatra"), icon: Activity }, |
| 160 |
]; |
| 161 |
|
| 162 |
return ( |
| 163 |
<div className="space-y-6"> |
| 164 |
<PageHeader |
| 165 |
description={__( |
| 166 |
"Distribute your tours on global OTAs from one dashboard. Push inventory and pricing automatically, receive bookings back into a unified inbox.", |
| 167 |
"yatra", |
| 168 |
)} |
| 169 |
/> |
| 170 |
|
| 171 |
<AboutCard /> |
| 172 |
|
| 173 |
<Card> |
| 174 |
<div className="border-b border-gray-200 dark:border-gray-700"> |
| 175 |
<nav className="flex flex-wrap gap-1 px-4"> |
| 176 |
{tabs.map((tab) => { |
| 177 |
const Icon = tab.icon; |
| 178 |
const active = activeTab === tab.key; |
| 179 |
return ( |
| 180 |
<button |
| 181 |
key={tab.key} |
| 182 |
type="button" |
| 183 |
onClick={() => switchTab(tab.key)} |
| 184 |
className={`flex items-center gap-2 px-4 py-3 border-b-2 font-medium text-sm transition-colors ${ |
| 185 |
active |
| 186 |
? "border-blue-500 text-blue-600 dark:text-blue-400" |
| 187 |
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:border-gray-300 dark:hover:border-gray-600" |
| 188 |
}`} |
| 189 |
> |
| 190 |
<Icon className="w-4 h-4" /> |
| 191 |
{tab.label} |
| 192 |
</button> |
| 193 |
); |
| 194 |
})} |
| 195 |
</nav> |
| 196 |
</div> |
| 197 |
<CardContent className="p-6"> |
| 198 |
{!ready ? ( |
| 199 |
<ModulePrompt /> |
| 200 |
) : activeTab === "channels" ? ( |
| 201 |
<ChannelsSection meta={meta} /> |
| 202 |
) : activeTab === "mappings" ? ( |
| 203 |
<MappingsSection /> |
| 204 |
) : activeTab === "bookings" ? ( |
| 205 |
<BookingsSection /> |
| 206 |
) : activeTab === "reconciliation" ? ( |
| 207 |
<ReconciliationSection /> |
| 208 |
) : activeTab === "latency" ? ( |
| 209 |
<LatencySection /> |
| 210 |
) : ( |
| 211 |
<LogsSection /> |
| 212 |
)} |
| 213 |
</CardContent> |
| 214 |
</Card> |
| 215 |
</div> |
| 216 |
); |
| 217 |
}; |
| 218 |
|
| 219 |
/* -------------------------------------------------------------------------- */ |
| 220 |
/* About / How it works — always visible, brand-neutral */ |
| 221 |
/* -------------------------------------------------------------------------- */ |
| 222 |
|
| 223 |
const AboutCard: React.FC = () => ( |
| 224 |
<Card className="border-blue-200 bg-blue-50/40 dark:border-blue-800 dark:bg-blue-950/20"> |
| 225 |
<CardHeader> |
| 226 |
<CardTitle className="flex items-center gap-2"> |
| 227 |
<Info className="h-5 w-5 text-blue-600 dark:text-blue-400" /> |
| 228 |
{__("What is a Channel Manager?", "yatra")} |
| 229 |
</CardTitle> |
| 230 |
<CardDescription className="mt-2 leading-relaxed"> |
| 231 |
{__( |
| 232 |
"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.", |
| 233 |
"yatra", |
| 234 |
)} |
| 235 |
<br /> |
| 236 |
<br /> |
| 237 |
{__( |
| 238 |
"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.", |
| 239 |
"yatra", |
| 240 |
)} |
| 241 |
</CardDescription> |
| 242 |
</CardHeader> |
| 243 |
<CardContent> |
| 244 |
<div className="grid gap-4 md:grid-cols-3"> |
| 245 |
<HowItWorksStep |
| 246 |
icon={Network} |
| 247 |
step={1} |
| 248 |
title={__("Connect a channel", "yatra")} |
| 249 |
body={__( |
| 250 |
"Add an OTA, paste in your provider credentials, and set commission + price offset. Start in sandbox mode until your first test booking flows through.", |
| 251 |
"yatra", |
| 252 |
)} |
| 253 |
/> |
| 254 |
<HowItWorksStep |
| 255 |
icon={LinkIcon} |
| 256 |
step={2} |
| 257 |
title={__("Map your trips", "yatra")} |
| 258 |
body={__( |
| 259 |
"For each trip you want to sell on that OTA, link it to its counterpart product on their side. One trip can map to many channels with different prices and inventory rules.", |
| 260 |
"yatra", |
| 261 |
)} |
| 262 |
/> |
| 263 |
<HowItWorksStep |
| 264 |
icon={Zap} |
| 265 |
step={3} |
| 266 |
title={__("Sync runs automatically", "yatra")} |
| 267 |
body={__( |
| 268 |
"Inventory and pricing push within seconds of any change. Bookings arrive via webhook, are staged for safety, then promoted into the standard booking list — instantly visible everywhere else.", |
| 269 |
"yatra", |
| 270 |
)} |
| 271 |
/> |
| 272 |
</div> |
| 273 |
|
| 274 |
<div className="mt-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-4"> |
| 275 |
<ValueCallout |
| 276 |
icon={Globe} |
| 277 |
title={__("Reach new customers", "yatra")} |
| 278 |
body={__( |
| 279 |
"OTAs market your trips to millions of travelers worldwide.", |
| 280 |
"yatra", |
| 281 |
)} |
| 282 |
/> |
| 283 |
<ValueCallout |
| 284 |
icon={ShieldCheck} |
| 285 |
title={__("No overbooking", "yatra")} |
| 286 |
body={__( |
| 287 |
"Real-time inventory keeps stock in sync across every channel.", |
| 288 |
"yatra", |
| 289 |
)} |
| 290 |
/> |
| 291 |
<ValueCallout |
| 292 |
icon={TrendingUp} |
| 293 |
title={__("Smart pricing", "yatra")} |
| 294 |
body={__( |
| 295 |
"Per-channel offsets cover commission while keeping margins.", |
| 296 |
"yatra", |
| 297 |
)} |
| 298 |
/> |
| 299 |
<ValueCallout |
| 300 |
icon={Layers} |
| 301 |
title={__("Bring your own credentials", "yatra")} |
| 302 |
body={__( |
| 303 |
"Connections go direct to the OTA. No proxying, no markup.", |
| 304 |
"yatra", |
| 305 |
)} |
| 306 |
/> |
| 307 |
</div> |
| 308 |
</CardContent> |
| 309 |
</Card> |
| 310 |
); |
| 311 |
|
| 312 |
const HowItWorksStep: React.FC<{ |
| 313 |
icon: any; |
| 314 |
step: number; |
| 315 |
title: string; |
| 316 |
body: string; |
| 317 |
}> = ({ icon: Icon, step, title, body }) => ( |
| 318 |
<div className="rounded-lg border border-blue-200/70 bg-white p-4 dark:border-blue-800/60 dark:bg-gray-900"> |
| 319 |
<div className="flex items-center gap-2 mb-2"> |
| 320 |
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-blue-600 text-xs font-semibold text-white"> |
| 321 |
{step} |
| 322 |
</span> |
| 323 |
<Icon className="h-4 w-4 text-blue-600 dark:text-blue-400" /> |
| 324 |
<h4 className="text-sm font-semibold text-gray-900 dark:text-white"> |
| 325 |
{title} |
| 326 |
</h4> |
| 327 |
</div> |
| 328 |
<p className="text-xs leading-relaxed text-gray-600 dark:text-gray-300"> |
| 329 |
{body} |
| 330 |
</p> |
| 331 |
</div> |
| 332 |
); |
| 333 |
|
| 334 |
const ValueCallout: React.FC<{ |
| 335 |
icon: any; |
| 336 |
title: string; |
| 337 |
body: string; |
| 338 |
}> = ({ icon: Icon, title, body }) => ( |
| 339 |
<div className="rounded-md border border-gray-200 bg-white p-3 dark:border-gray-700 dark:bg-gray-900"> |
| 340 |
<div className="flex items-center gap-2"> |
| 341 |
<Icon className="h-4 w-4 text-blue-500" /> |
| 342 |
<h5 className="text-xs font-semibold text-gray-900 dark:text-white"> |
| 343 |
{title} |
| 344 |
</h5> |
| 345 |
</div> |
| 346 |
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">{body}</p> |
| 347 |
</div> |
| 348 |
); |
| 349 |
|
| 350 |
/* -------------------------------------------------------------------------- */ |
| 351 |
/* Upgrade / module prompts */ |
| 352 |
/* -------------------------------------------------------------------------- */ |
| 353 |
|
| 354 |
const UpgradeCard: React.FC<{ meta?: ChannelManagerMeta }> = ({ meta }) => ( |
| 355 |
<Card className="max-w-3xl"> |
| 356 |
<CardHeader> |
| 357 |
<div className="flex items-start gap-3"> |
| 358 |
<div className="rounded-md bg-purple-100 p-2 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300"> |
| 359 |
<Crown className="h-5 w-5" /> |
| 360 |
</div> |
| 361 |
<div className="flex-1"> |
| 362 |
<CardTitle>{__("Channel Manager — Scale plan", "yatra")}</CardTitle> |
| 363 |
<CardDescription className="mt-1"> |
| 364 |
{__( |
| 365 |
"Available on the Scale plan. Standalone channel managers charge $99–299/month for the same capability — it's included here at no extra cost.", |
| 366 |
"yatra", |
| 367 |
)} |
| 368 |
</CardDescription> |
| 369 |
</div> |
| 370 |
</div> |
| 371 |
</CardHeader> |
| 372 |
<CardContent> |
| 373 |
<Button asChild> |
| 374 |
<a |
| 375 |
href={ |
| 376 |
meta?.upgrade_url || |
| 377 |
"https://wpyatra.com/pricing?module=channel-manager" |
| 378 |
} |
| 379 |
target="_blank" |
| 380 |
rel="noopener noreferrer" |
| 381 |
> |
| 382 |
{__("Upgrade to Scale", "yatra")} |
| 383 |
<ExternalLink className="ml-2 h-4 w-4" /> |
| 384 |
</a> |
| 385 |
</Button> |
| 386 |
</CardContent> |
| 387 |
</Card> |
| 388 |
); |
| 389 |
|
| 390 |
const ModulePrompt: React.FC = () => ( |
| 391 |
<div className="rounded-xl border border-amber-200 bg-amber-50 p-6 dark:border-amber-800 dark:bg-amber-950/30"> |
| 392 |
<div className="flex gap-3"> |
| 393 |
<Settings2 className="mt-0.5 h-6 w-6 shrink-0 text-amber-600 dark:text-amber-400" /> |
| 394 |
<div> |
| 395 |
<h3 className="text-base font-semibold text-amber-900 dark:text-amber-100"> |
| 396 |
{__("Enable the Channel Manager module", "yatra")} |
| 397 |
</h3> |
| 398 |
<p className="mt-1 text-sm text-amber-800/90 dark:text-amber-200/90"> |
| 399 |
{__( |
| 400 |
"Your license tier qualifies. Turn on Channel Manager under Modules to start configuring it.", |
| 401 |
"yatra", |
| 402 |
)} |
| 403 |
</p> |
| 404 |
<Button |
| 405 |
asChild |
| 406 |
variant="outline" |
| 407 |
className="mt-3 border-amber-300 text-amber-800 hover:bg-amber-100 dark:border-amber-700 dark:text-amber-200 dark:hover:bg-amber-900/40" |
| 408 |
> |
| 409 |
<a href="admin.php?page=yatra&subpage=modules"> |
| 410 |
{__("Open Modules", "yatra")} |
| 411 |
</a> |
| 412 |
</Button> |
| 413 |
</div> |
| 414 |
</div> |
| 415 |
</div> |
| 416 |
); |
| 417 |
|
| 418 |
/* -------------------------------------------------------------------------- */ |
| 419 |
/* Channels tab */ |
| 420 |
/* -------------------------------------------------------------------------- */ |
| 421 |
|
| 422 |
const ChannelsSection: React.FC<{ meta: ChannelManagerMeta }> = ({ meta }) => { |
| 423 |
const queryClient = useQueryClient(); |
| 424 |
const { showToast } = useToast(); |
| 425 |
const [editing, setEditing] = useState<number | "new" | null>(null); |
| 426 |
// Confirmation dialog for delete — replaces native window.confirm() |
| 427 |
// so the destructive prompt matches the rest of the admin UI. |
| 428 |
const [pendingDeleteChannel, setPendingDeleteChannel] = useState<{ |
| 429 |
id: number; |
| 430 |
name: string; |
| 431 |
} | null>(null); |
| 432 |
|
| 433 |
const { data, isLoading } = useQuery({ |
| 434 |
queryKey: ["channel-manager-channels"], |
| 435 |
queryFn: () => channelManagerApi.listChannels(), |
| 436 |
}); |
| 437 |
|
| 438 |
const deleteChannel = useMutation({ |
| 439 |
mutationFn: (id: number) => channelManagerApi.deleteChannel(id), |
| 440 |
onSuccess: () => { |
| 441 |
queryClient.invalidateQueries({ queryKey: ["channel-manager-channels"] }); |
| 442 |
showToast(__("Channel deleted.", "yatra"), "success"); |
| 443 |
setPendingDeleteChannel(null); |
| 444 |
}, |
| 445 |
onError: (e: any) => { |
| 446 |
showToast(extractError(e), "error"); |
| 447 |
setPendingDeleteChannel(null); |
| 448 |
}, |
| 449 |
}); |
| 450 |
|
| 451 |
if (isLoading) { |
| 452 |
return <ModuleListSkeleton rows={4} />; |
| 453 |
} |
| 454 |
|
| 455 |
if (editing !== null) { |
| 456 |
const channel = |
| 457 |
editing === "new" |
| 458 |
? null |
| 459 |
: (data?.data.find((c) => c.id === editing) ?? null); |
| 460 |
return ( |
| 461 |
<ChannelEditForm |
| 462 |
meta={meta} |
| 463 |
existing={channel} |
| 464 |
onClose={() => setEditing(null)} |
| 465 |
/> |
| 466 |
); |
| 467 |
} |
| 468 |
|
| 469 |
const channels = data?.data ?? []; |
| 470 |
const supportedChannelNames = meta.channel_types |
| 471 |
.map((t) => t.name) |
| 472 |
.join(", "); |
| 473 |
|
| 474 |
return ( |
| 475 |
<div className="space-y-4"> |
| 476 |
<div className="flex items-start justify-between gap-3 flex-wrap"> |
| 477 |
<div className="max-w-2xl"> |
| 478 |
<h3 className="text-lg font-semibold text-gray-900 dark:text-white"> |
| 479 |
{__("Configured channels", "yatra")} |
| 480 |
</h3> |
| 481 |
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1"> |
| 482 |
{__( |
| 483 |
"Add one connection per OTA you want to distribute on. Credentials are stored libsodium-encrypted; nothing is proxied through any third-party service.", |
| 484 |
"yatra", |
| 485 |
)} |
| 486 |
</p> |
| 487 |
{meta.channel_types.length > 0 && ( |
| 488 |
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400"> |
| 489 |
<span className="font-medium text-gray-700 dark:text-gray-300"> |
| 490 |
{__("Supported OTAs:", "yatra")} |
| 491 |
</span>{" "} |
| 492 |
{supportedChannelNames} |
| 493 |
</p> |
| 494 |
)} |
| 495 |
</div> |
| 496 |
<Button onClick={() => setEditing("new")}> |
| 497 |
<Plus className="mr-1.5 h-4 w-4" /> |
| 498 |
{__("Add channel", "yatra")} |
| 499 |
</Button> |
| 500 |
</div> |
| 501 |
|
| 502 |
<Card> |
| 503 |
<CardContent className="p-0"> |
| 504 |
{channels.length === 0 ? ( |
| 505 |
<EmptyChannels onAdd={() => setEditing("new")} meta={meta} /> |
| 506 |
) : ( |
| 507 |
<div className="overflow-x-auto"> |
| 508 |
<table className="w-full"> |
| 509 |
<thead className="bg-gray-50 dark:bg-gray-800"> |
| 510 |
<tr> |
| 511 |
<Th>{__("Channel", "yatra")}</Th> |
| 512 |
<Th>{__("Account", "yatra")}</Th> |
| 513 |
<Th>{__("Health", "yatra")}</Th> |
| 514 |
<Th>{__("Mode", "yatra")}</Th> |
| 515 |
<Th>{__("Status", "yatra")}</Th> |
| 516 |
<Th>{__("Last sync", "yatra")}</Th> |
| 517 |
<Th align="right">{__("Actions", "yatra")}</Th> |
| 518 |
</tr> |
| 519 |
</thead> |
| 520 |
<tbody className="divide-y divide-gray-200 dark:divide-gray-700"> |
| 521 |
{channels.map((ch) => { |
| 522 |
const type = meta.channel_types.find( |
| 523 |
(t) => t.type === ch.channel_type, |
| 524 |
); |
| 525 |
return ( |
| 526 |
<tr |
| 527 |
key={ch.id} |
| 528 |
className="hover:bg-gray-50 dark:hover:bg-gray-800/50" |
| 529 |
> |
| 530 |
<td className="px-4 py-3"> |
| 531 |
<div className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-white"> |
| 532 |
<Network className="h-4 w-4 text-purple-500" /> |
| 533 |
{type?.name ?? ch.channel_type} |
| 534 |
</div> |
| 535 |
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5"> |
| 536 |
{ch.display_name || "—"} |
| 537 |
</div> |
| 538 |
</td> |
| 539 |
<td className="px-4 py-3 text-sm text-gray-700 dark:text-gray-200"> |
| 540 |
{ch.account_label || "—"} |
| 541 |
</td> |
| 542 |
<td className="px-4 py-3"> |
| 543 |
<HealthBadge health={ch.health} /> |
| 544 |
</td> |
| 545 |
<td className="px-4 py-3"> |
| 546 |
<Badge |
| 547 |
className={ |
| 548 |
ch.is_test_mode |
| 549 |
? "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400" |
| 550 |
: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400" |
| 551 |
} |
| 552 |
> |
| 553 |
{ch.is_test_mode |
| 554 |
? __("Sandbox", "yatra") |
| 555 |
: __("Live", "yatra")} |
| 556 |
</Badge> |
| 557 |
</td> |
| 558 |
<td className="px-4 py-3"> |
| 559 |
<Badge |
| 560 |
className={ |
| 561 |
ch.is_enabled |
| 562 |
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" |
| 563 |
: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300" |
| 564 |
} |
| 565 |
> |
| 566 |
{ch.is_enabled |
| 567 |
? __("Enabled", "yatra") |
| 568 |
: __("Disabled", "yatra")} |
| 569 |
</Badge> |
| 570 |
</td> |
| 571 |
<td className="px-4 py-3 text-xs text-gray-500 dark:text-gray-400"> |
| 572 |
{ch.last_sync_at |
| 573 |
? new Date(ch.last_sync_at).toLocaleString() |
| 574 |
: "—"} |
| 575 |
{ch.last_sync_status === "failed" && ( |
| 576 |
<span className="ml-2 inline-flex items-center gap-1 text-red-600"> |
| 577 |
<AlertTriangle className="h-3 w-3" /> |
| 578 |
{__("failed", "yatra")} |
| 579 |
</span> |
| 580 |
)} |
| 581 |
</td> |
| 582 |
<td className="px-4 py-3 text-right"> |
| 583 |
<div className="inline-flex gap-1"> |
| 584 |
<Button |
| 585 |
variant="outline" |
| 586 |
size="sm" |
| 587 |
onClick={() => setEditing(ch.id)} |
| 588 |
> |
| 589 |
{__("Edit", "yatra")} |
| 590 |
</Button> |
| 591 |
<Button |
| 592 |
variant="outline" |
| 593 |
size="sm" |
| 594 |
className="border-red-200 text-red-700 hover:bg-red-50 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-900/30" |
| 595 |
onClick={() => |
| 596 |
setPendingDeleteChannel({ |
| 597 |
id: ch.id, |
| 598 |
name: ch.display_name || ch.channel_type, |
| 599 |
}) |
| 600 |
} |
| 601 |
> |
| 602 |
<Trash2 className="h-3.5 w-3.5" /> |
| 603 |
</Button> |
| 604 |
</div> |
| 605 |
</td> |
| 606 |
</tr> |
| 607 |
); |
| 608 |
})} |
| 609 |
</tbody> |
| 610 |
</table> |
| 611 |
</div> |
| 612 |
)} |
| 613 |
</CardContent> |
| 614 |
</Card> |
| 615 |
<ConfirmationDialog |
| 616 |
isOpen={pendingDeleteChannel !== null} |
| 617 |
onClose={() => { |
| 618 |
if (!deleteChannel.isPending) setPendingDeleteChannel(null); |
| 619 |
}} |
| 620 |
onConfirm={() => { |
| 621 |
if (pendingDeleteChannel) |
| 622 |
deleteChannel.mutate(pendingDeleteChannel.id); |
| 623 |
}} |
| 624 |
title={__("Delete channel?", "yatra")} |
| 625 |
description={ |
| 626 |
pendingDeleteChannel |
| 627 |
? __( |
| 628 |
"Delete the “{name}” channel? Credentials and trip mappings linked to it will be removed. This cannot be undone.", |
| 629 |
"yatra", |
| 630 |
).replace("{name}", pendingDeleteChannel.name) |
| 631 |
: "" |
| 632 |
} |
| 633 |
confirmText={__("Delete channel", "yatra")} |
| 634 |
cancelText={__("Cancel", "yatra")} |
| 635 |
variant="danger" |
| 636 |
isLoading={deleteChannel.isPending} |
| 637 |
/> |
| 638 |
</div> |
| 639 |
); |
| 640 |
}; |
| 641 |
|
| 642 |
const EmptyChannels: React.FC<{ |
| 643 |
onAdd: () => void; |
| 644 |
meta: ChannelManagerMeta; |
| 645 |
}> = ({ onAdd, meta }) => ( |
| 646 |
<div className="p-10 text-center"> |
| 647 |
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30"> |
| 648 |
<Network className="h-7 w-7 text-blue-600 dark:text-blue-400" /> |
| 649 |
</div> |
| 650 |
<h4 className="text-base font-semibold text-gray-900 dark:text-white"> |
| 651 |
{__("No channels yet", "yatra")} |
| 652 |
</h4> |
| 653 |
<p className="mx-auto mt-1 max-w-md text-sm text-gray-500 dark:text-gray-400"> |
| 654 |
{__( |
| 655 |
"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.", |
| 656 |
"yatra", |
| 657 |
)} |
| 658 |
</p> |
| 659 |
<div className="mt-4 flex justify-center gap-2"> |
| 660 |
<Button onClick={onAdd}> |
| 661 |
<Plus className="mr-1.5 h-4 w-4" /> |
| 662 |
{__("Add your first channel", "yatra")} |
| 663 |
</Button> |
| 664 |
{meta.docs_url && ( |
| 665 |
<Button variant="outline" asChild> |
| 666 |
<a href={meta.docs_url} target="_blank" rel="noopener noreferrer"> |
| 667 |
<HelpCircle className="mr-1.5 h-4 w-4" /> |
| 668 |
{__("Read the setup guide", "yatra")} |
| 669 |
</a> |
| 670 |
</Button> |
| 671 |
)} |
| 672 |
</div> |
| 673 |
</div> |
| 674 |
); |
| 675 |
|
| 676 |
const ChannelEditForm: React.FC<{ |
| 677 |
meta: ChannelManagerMeta; |
| 678 |
existing: ChannelRow | null; |
| 679 |
onClose: () => void; |
| 680 |
}> = ({ meta, existing, onClose }) => { |
| 681 |
const queryClient = useQueryClient(); |
| 682 |
const { showToast } = useToast(); |
| 683 |
const isCreate = existing === null; |
| 684 |
|
| 685 |
const [channelType, setChannelType] = useState<string>( |
| 686 |
existing?.channel_type ?? meta.channel_types[0]?.type ?? "", |
| 687 |
); |
| 688 |
const [displayName, setDisplayName] = useState(existing?.display_name ?? ""); |
| 689 |
const [accountLabel, setAccountLabel] = useState( |
| 690 |
existing?.account_label ?? "", |
| 691 |
); |
| 692 |
const [isEnabled, setIsEnabled] = useState(existing?.is_enabled ?? false); |
| 693 |
const [isTestMode, setIsTestMode] = useState(existing?.is_test_mode ?? true); |
| 694 |
const [currency, setCurrency] = useState(existing?.currency ?? "USD"); |
| 695 |
const [defaultOffset, setDefaultOffset] = useState<number>( |
| 696 |
existing?.default_offset_percent ?? 0, |
| 697 |
); |
| 698 |
const [commission, setCommission] = useState<number>( |
| 699 |
existing?.commission_percent ?? 0, |
| 700 |
); |
| 701 |
const [buffer, setBuffer] = useState<number>(existing?.inventory_buffer ?? 0); |
| 702 |
// Per-channel IP allowlist for the inbound webhook receiver. Stored |
| 703 |
// in the channel's `settings` JSON under `allowed_ips`. Empty = no |
| 704 |
// restriction (default). Comma- and/or newline-separated CIDR list. |
| 705 |
const [allowedIps, setAllowedIps] = useState<string>( |
| 706 |
((existing?.settings as Record<string, unknown> | undefined) |
| 707 |
?.allowed_ips as string) ?? "", |
| 708 |
); |
| 709 |
|
| 710 |
const typeDef = useMemo( |
| 711 |
() => meta.channel_types.find((t) => t.type === channelType) ?? null, |
| 712 |
[meta.channel_types, channelType], |
| 713 |
); |
| 714 |
|
| 715 |
const save = useMutation({ |
| 716 |
mutationFn: () => { |
| 717 |
const payload = { |
| 718 |
channel_type: channelType, |
| 719 |
display_name: displayName, |
| 720 |
account_label: accountLabel, |
| 721 |
is_enabled: isEnabled, |
| 722 |
is_test_mode: isTestMode, |
| 723 |
currency, |
| 724 |
default_offset_percent: defaultOffset, |
| 725 |
commission_percent: commission, |
| 726 |
inventory_buffer: buffer, |
| 727 |
// Merge into existing settings so unrelated keys (per-provider |
| 728 |
// overrides, custom fields a future feature might add) are |
| 729 |
// preserved on update. |
| 730 |
settings: { |
| 731 |
...((existing?.settings as Record<string, unknown> | undefined) ?? |
| 732 |
{}), |
| 733 |
allowed_ips: allowedIps, |
| 734 |
}, |
| 735 |
}; |
| 736 |
return isCreate |
| 737 |
? channelManagerApi.createChannel(payload) |
| 738 |
: channelManagerApi.updateChannel(existing!.id, payload); |
| 739 |
}, |
| 740 |
onSuccess: () => { |
| 741 |
queryClient.invalidateQueries({ queryKey: ["channel-manager-channels"] }); |
| 742 |
showToast( |
| 743 |
isCreate |
| 744 |
? __("Channel created. Add credentials next to enable sync.", "yatra") |
| 745 |
: __("Channel saved.", "yatra"), |
| 746 |
"success", |
| 747 |
); |
| 748 |
if (isCreate) onClose(); |
| 749 |
}, |
| 750 |
onError: (e: any) => showToast(extractError(e), "error"), |
| 751 |
}); |
| 752 |
|
| 753 |
const testConnection = useMutation({ |
| 754 |
mutationFn: () => channelManagerApi.testConnection(existing!.id), |
| 755 |
onSuccess: (res) => { |
| 756 |
if (res.ok) { |
| 757 |
showToast( |
| 758 |
__("Connection OK — credentials accepted by the OTA.", "yatra"), |
| 759 |
"success", |
| 760 |
); |
| 761 |
} else { |
| 762 |
showToast(res.error || __("Connection failed.", "yatra"), "error"); |
| 763 |
} |
| 764 |
}, |
| 765 |
onError: (e: any) => showToast(extractError(e), "error"), |
| 766 |
}); |
| 767 |
|
| 768 |
const syncChannel = useMutation({ |
| 769 |
mutationFn: () => channelManagerApi.syncChannel(existing!.id), |
| 770 |
onSuccess: (res) => showToast(res.message, "success"), |
| 771 |
onError: (e: any) => showToast(extractError(e), "error"), |
| 772 |
}); |
| 773 |
|
| 774 |
const testWebhook = useMutation({ |
| 775 |
mutationFn: () => channelManagerApi.testWebhook(existing!.id), |
| 776 |
onSuccess: (res) => { |
| 777 |
if (res.ok) { |
| 778 |
showToast( |
| 779 |
__( |
| 780 |
"Webhook self-test succeeded — your endpoint is reachable.", |
| 781 |
"yatra", |
| 782 |
), |
| 783 |
"success", |
| 784 |
); |
| 785 |
} else { |
| 786 |
showToast( |
| 787 |
res.error || |
| 788 |
__( |
| 789 |
"Webhook self-test failed — see Sync activity for details.", |
| 790 |
"yatra", |
| 791 |
), |
| 792 |
"error", |
| 793 |
); |
| 794 |
} |
| 795 |
}, |
| 796 |
onError: (e: any) => showToast(extractError(e), "error"), |
| 797 |
}); |
| 798 |
|
| 799 |
return ( |
| 800 |
<div className="space-y-6"> |
| 801 |
<div className="flex items-start justify-between gap-4 flex-wrap"> |
| 802 |
<div> |
| 803 |
<h2 className="text-xl font-semibold text-gray-900 dark:text-white"> |
| 804 |
{isCreate |
| 805 |
? __("Add channel", "yatra") |
| 806 |
: __("Edit channel", "yatra")} |
| 807 |
</h2> |
| 808 |
{typeDef?.description && ( |
| 809 |
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1 max-w-2xl"> |
| 810 |
{typeDef.description} |
| 811 |
</p> |
| 812 |
)} |
| 813 |
{typeDef && ( |
| 814 |
<div className="mt-2 flex flex-wrap gap-2"> |
| 815 |
{typeDef.docs_url && ( |
| 816 |
<a |
| 817 |
href={typeDef.docs_url} |
| 818 |
target="_blank" |
| 819 |
rel="noopener noreferrer" |
| 820 |
className="inline-flex items-center gap-1 text-xs text-blue-600 hover:underline dark:text-blue-400" |
| 821 |
> |
| 822 |
<HelpCircle className="h-3 w-3" /> |
| 823 |
{__("Integration docs", "yatra")} |
| 824 |
</a> |
| 825 |
)} |
| 826 |
{typeDef.signup_url && ( |
| 827 |
<a |
| 828 |
href={typeDef.signup_url} |
| 829 |
target="_blank" |
| 830 |
rel="noopener noreferrer" |
| 831 |
className="inline-flex items-center gap-1 text-xs text-blue-600 hover:underline dark:text-blue-400" |
| 832 |
> |
| 833 |
<ExternalLink className="h-3 w-3" /> |
| 834 |
{__("Apply to partner program", "yatra")} |
| 835 |
</a> |
| 836 |
)} |
| 837 |
</div> |
| 838 |
)} |
| 839 |
</div> |
| 840 |
<div className="flex gap-2 flex-wrap"> |
| 841 |
{!isCreate && ( |
| 842 |
<> |
| 843 |
<Button |
| 844 |
variant="outline" |
| 845 |
onClick={() => testConnection.mutate()} |
| 846 |
disabled={testConnection.isPending} |
| 847 |
> |
| 848 |
{testConnection.isPending ? ( |
| 849 |
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" /> |
| 850 |
) : ( |
| 851 |
<CheckCircle2 className="mr-1.5 h-4 w-4" /> |
| 852 |
)} |
| 853 |
{__("Test connection", "yatra")} |
| 854 |
</Button> |
| 855 |
<Button |
| 856 |
variant="outline" |
| 857 |
onClick={() => syncChannel.mutate()} |
| 858 |
disabled={syncChannel.isPending || !isEnabled} |
| 859 |
title={ |
| 860 |
!isEnabled |
| 861 |
? __("Enable the channel before syncing.", "yatra") |
| 862 |
: undefined |
| 863 |
} |
| 864 |
> |
| 865 |
{syncChannel.isPending ? ( |
| 866 |
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" /> |
| 867 |
) : ( |
| 868 |
<RefreshCw className="mr-1.5 h-4 w-4" /> |
| 869 |
)} |
| 870 |
{__("Sync now", "yatra")} |
| 871 |
</Button> |
| 872 |
<Button |
| 873 |
variant="outline" |
| 874 |
onClick={() => testWebhook.mutate()} |
| 875 |
disabled={testWebhook.isPending} |
| 876 |
title={__( |
| 877 |
"Sends a signed synthetic booking to your own webhook URL so you can confirm the round-trip works.", |
| 878 |
"yatra", |
| 879 |
)} |
| 880 |
> |
| 881 |
{testWebhook.isPending ? ( |
| 882 |
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" /> |
| 883 |
) : ( |
| 884 |
<Send className="mr-1.5 h-4 w-4" /> |
| 885 |
)} |
| 886 |
{__("Test webhook", "yatra")} |
| 887 |
</Button> |
| 888 |
</> |
| 889 |
)} |
| 890 |
<Button variant="outline" onClick={onClose}> |
| 891 |
{__("Back to list", "yatra")} |
| 892 |
</Button> |
| 893 |
<Button |
| 894 |
onClick={() => save.mutate()} |
| 895 |
disabled={save.isPending || channelType === ""} |
| 896 |
> |
| 897 |
{save.isPending ? ( |
| 898 |
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" /> |
| 899 |
) : ( |
| 900 |
<Save className="mr-1.5 h-4 w-4" /> |
| 901 |
)} |
| 902 |
{isCreate |
| 903 |
? __("Create channel", "yatra") |
| 904 |
: __("Save changes", "yatra")} |
| 905 |
</Button> |
| 906 |
</div> |
| 907 |
</div> |
| 908 |
|
| 909 |
{isCreate && ( |
| 910 |
<div className="rounded-md border border-blue-200 bg-blue-50/60 px-4 py-3 text-sm text-blue-900 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-200"> |
| 911 |
<p className="flex items-start gap-2"> |
| 912 |
<Info className="h-4 w-4 mt-0.5 flex-shrink-0" /> |
| 913 |
<span> |
| 914 |
{__( |
| 915 |
"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.", |
| 916 |
"yatra", |
| 917 |
)} |
| 918 |
</span> |
| 919 |
</p> |
| 920 |
</div> |
| 921 |
)} |
| 922 |
|
| 923 |
<Card> |
| 924 |
<CardHeader> |
| 925 |
<CardTitle>{__("Connection", "yatra")}</CardTitle> |
| 926 |
<CardDescription> |
| 927 |
{__( |
| 928 |
"Pick which OTA this connection is for and give it a friendly label.", |
| 929 |
"yatra", |
| 930 |
)} |
| 931 |
</CardDescription> |
| 932 |
</CardHeader> |
| 933 |
<CardContent className="space-y-5"> |
| 934 |
<div className="grid gap-5 md:grid-cols-2"> |
| 935 |
<FormField |
| 936 |
label={__("OTA channel", "yatra")} |
| 937 |
description={__( |
| 938 |
"Pick the OTA you're connecting. Once a channel is created, this is locked — to switch OTAs you'd add a new channel.", |
| 939 |
"yatra", |
| 940 |
)} |
| 941 |
> |
| 942 |
<Select |
| 943 |
value={channelType} |
| 944 |
onChange={(e) => setChannelType(e.target.value)} |
| 945 |
disabled={!isCreate} |
| 946 |
> |
| 947 |
{meta.channel_types.map((t) => ( |
| 948 |
<option key={t.type} value={t.type}> |
| 949 |
{t.name} |
| 950 |
</option> |
| 951 |
))} |
| 952 |
</Select> |
| 953 |
</FormField> |
| 954 |
|
| 955 |
<FormField |
| 956 |
label={__("Display name", "yatra")} |
| 957 |
description={__( |
| 958 |
"How this channel appears in the admin UI. Leave blank to use the OTA's name.", |
| 959 |
"yatra", |
| 960 |
)} |
| 961 |
> |
| 962 |
<Input |
| 963 |
value={displayName} |
| 964 |
onChange={(e) => setDisplayName(e.target.value)} |
| 965 |
placeholder={typeDef?.name ?? ""} |
| 966 |
/> |
| 967 |
</FormField> |
| 968 |
|
| 969 |
<FormField |
| 970 |
label={__("Account label (internal)", "yatra")} |
| 971 |
description={__( |
| 972 |
"Optional note shown in logs and reports. Useful when you run multiple accounts on the same OTA (e.g. 'US production', 'EU staging').", |
| 973 |
"yatra", |
| 974 |
)} |
| 975 |
> |
| 976 |
<Input |
| 977 |
value={accountLabel} |
| 978 |
onChange={(e) => setAccountLabel(e.target.value)} |
| 979 |
placeholder={__("e.g. US production account", "yatra")} |
| 980 |
/> |
| 981 |
</FormField> |
| 982 |
|
| 983 |
<FormField |
| 984 |
label={__("Currency", "yatra")} |
| 985 |
description={__( |
| 986 |
"ISO 4217 code (e.g. USD, EUR, GBP). Must match the currency your products are priced in on the OTA — mismatches cause failed pricing pushes.", |
| 987 |
"yatra", |
| 988 |
)} |
| 989 |
> |
| 990 |
<Input |
| 991 |
value={currency} |
| 992 |
onChange={(e) => |
| 993 |
setCurrency(e.target.value.toUpperCase().slice(0, 3)) |
| 994 |
} |
| 995 |
placeholder="USD" |
| 996 |
maxLength={3} |
| 997 |
/> |
| 998 |
</FormField> |
| 999 |
</div> |
| 1000 |
</CardContent> |
| 1001 |
</Card> |
| 1002 |
|
| 1003 |
<Card> |
| 1004 |
<CardHeader> |
| 1005 |
<CardTitle>{__("Pricing & inventory rules", "yatra")}</CardTitle> |
| 1006 |
<CardDescription> |
| 1007 |
{__( |
| 1008 |
"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.", |
| 1009 |
"yatra", |
| 1010 |
)} |
| 1011 |
</CardDescription> |
| 1012 |
</CardHeader> |
| 1013 |
<CardContent className="space-y-5"> |
| 1014 |
<div className="grid gap-5 md:grid-cols-3"> |
| 1015 |
<FormField |
| 1016 |
label={__("Default price offset (%)", "yatra")} |
| 1017 |
description={__( |
| 1018 |
"Adds (or subtracts) a percentage to your base trip price before pushing to this channel. Use a positive number to cover the OTA's commission so your net stays whole; negative to run a channel discount.", |
| 1019 |
"yatra", |
| 1020 |
)} |
| 1021 |
> |
| 1022 |
<Input |
| 1023 |
type="number" |
| 1024 |
step="0.01" |
| 1025 |
value={defaultOffset} |
| 1026 |
onChange={(e) => setDefaultOffset(Number(e.target.value) || 0)} |
| 1027 |
placeholder="0" |
| 1028 |
/> |
| 1029 |
</FormField> |
| 1030 |
|
| 1031 |
<FormField |
| 1032 |
label={__("Commission (%) — informational", "yatra")} |
| 1033 |
description={__( |
| 1034 |
"What the OTA deducts from each sale. Not sent to the channel — used here only to estimate your net revenue in reports.", |
| 1035 |
"yatra", |
| 1036 |
)} |
| 1037 |
> |
| 1038 |
<Input |
| 1039 |
type="number" |
| 1040 |
step="0.01" |
| 1041 |
value={commission} |
| 1042 |
onChange={(e) => setCommission(Number(e.target.value) || 0)} |
| 1043 |
placeholder="0" |
| 1044 |
/> |
| 1045 |
</FormField> |
| 1046 |
|
| 1047 |
<FormField |
| 1048 |
label={__("Inventory buffer (seats)", "yatra")} |
| 1049 |
description={__( |
| 1050 |
"Seats held back from this channel so direct-bookers and other OTAs always have stock. Example: a 20-seat departure with a buffer of 2 will only ever show 18 seats on this channel.", |
| 1051 |
"yatra", |
| 1052 |
)} |
| 1053 |
> |
| 1054 |
<Input |
| 1055 |
type="number" |
| 1056 |
min={0} |
| 1057 |
value={buffer} |
| 1058 |
onChange={(e) => setBuffer(Number(e.target.value) || 0)} |
| 1059 |
placeholder="0" |
| 1060 |
/> |
| 1061 |
</FormField> |
| 1062 |
</div> |
| 1063 |
|
| 1064 |
{!isCreate && ( |
| 1065 |
<FormField |
| 1066 |
label={__("Inbound webhook IP allowlist", "yatra")} |
| 1067 |
description={__( |
| 1068 |
"Optional defense-in-depth on top of the signature secret. Comma- or newline-separated CIDRs. Empty = no restriction (default). When set, inbound webhook deliveries from any source outside the list are dropped BEFORE signature verification.", |
| 1069 |
"yatra", |
| 1070 |
)} |
| 1071 |
> |
| 1072 |
<textarea |
| 1073 |
className="w-full font-mono text-xs px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 min-h-[80px]" |
| 1074 |
value={allowedIps} |
| 1075 |
onChange={(e) => setAllowedIps(e.target.value)} |
| 1076 |
placeholder={"203.0.113.0/24\n198.51.100.42"} |
| 1077 |
/> |
| 1078 |
</FormField> |
| 1079 |
)} |
| 1080 |
</CardContent> |
| 1081 |
</Card> |
| 1082 |
|
| 1083 |
<Card> |
| 1084 |
<CardHeader> |
| 1085 |
<CardTitle>{__("Channel state", "yatra")}</CardTitle> |
| 1086 |
<CardDescription> |
| 1087 |
{__( |
| 1088 |
"Both flags must be considered together: a disabled channel won't sync at all, regardless of mode; sandbox mode lets you test live API calls against the OTA's staging environment without affecting real bookings.", |
| 1089 |
"yatra", |
| 1090 |
)} |
| 1091 |
</CardDescription> |
| 1092 |
</CardHeader> |
| 1093 |
<CardContent className="space-y-4"> |
| 1094 |
<ToggleRow |
| 1095 |
label={__("Enabled", "yatra")} |
| 1096 |
description={__( |
| 1097 |
"When off, this channel won't push inventory or pricing, and incoming bookings won't be ingested. Logs still record any attempts so you can audit them.", |
| 1098 |
"yatra", |
| 1099 |
)} |
| 1100 |
checked={isEnabled} |
| 1101 |
onChange={setIsEnabled} |
| 1102 |
/> |
| 1103 |
<ToggleRow |
| 1104 |
label={__("Sandbox / test mode", "yatra")} |
| 1105 |
description={__( |
| 1106 |
"Routes API calls to the OTA's sandbox environment. Always start in sandbox until a test booking has flowed through end-to-end. Switch off only after you've verified the connection in production.", |
| 1107 |
"yatra", |
| 1108 |
)} |
| 1109 |
checked={isTestMode} |
| 1110 |
onChange={setIsTestMode} |
| 1111 |
tone="amber" |
| 1112 |
/> |
| 1113 |
</CardContent> |
| 1114 |
</Card> |
| 1115 |
|
| 1116 |
{!isCreate && typeDef && ( |
| 1117 |
<CredentialsCard |
| 1118 |
channelId={existing!.id} |
| 1119 |
typeDef={typeDef} |
| 1120 |
status={existing!.credentials ?? {}} |
| 1121 |
/> |
| 1122 |
)} |
| 1123 |
|
| 1124 |
{!isCreate && ( |
| 1125 |
<WebhookCard |
| 1126 |
channelId={existing!.id} |
| 1127 |
webhookUrlTemplate={meta.webhook_url} |
| 1128 |
/> |
| 1129 |
)} |
| 1130 |
</div> |
| 1131 |
); |
| 1132 |
}; |
| 1133 |
|
| 1134 |
const CredentialsCard: React.FC<{ |
| 1135 |
channelId: number; |
| 1136 |
typeDef: ChannelTypeDef; |
| 1137 |
status: Record<string, { configured: boolean; hint: string }>; |
| 1138 |
}> = ({ channelId, typeDef, status }) => { |
| 1139 |
const queryClient = useQueryClient(); |
| 1140 |
const { showToast } = useToast(); |
| 1141 |
const [drafts, setDrafts] = useState<Record<string, string>>({}); |
| 1142 |
const [visible, setVisible] = useState<Record<string, boolean>>({}); |
| 1143 |
|
| 1144 |
const save = useMutation({ |
| 1145 |
mutationFn: (vars: { field: string; value: string }) => |
| 1146 |
channelManagerApi.updateCredential(channelId, vars.field, vars.value), |
| 1147 |
onSuccess: () => { |
| 1148 |
queryClient.invalidateQueries({ queryKey: ["channel-manager-channels"] }); |
| 1149 |
showToast(__("Credential saved.", "yatra"), "success"); |
| 1150 |
}, |
| 1151 |
onError: (e: any) => showToast(extractError(e), "error"), |
| 1152 |
}); |
| 1153 |
|
| 1154 |
return ( |
| 1155 |
<Card> |
| 1156 |
<CardHeader> |
| 1157 |
<CardTitle className="flex items-center gap-2"> |
| 1158 |
<ShieldCheck className="h-5 w-5 text-blue-500" /> |
| 1159 |
{__("API credentials", "yatra")} |
| 1160 |
</CardTitle> |
| 1161 |
<CardDescription> |
| 1162 |
{__( |
| 1163 |
"Stored encrypted with libsodium AEAD (XChaCha20-Poly1305). After saving, the value is never sent back to the browser — you only see a masked last-4 hint to confirm something is set.", |
| 1164 |
"yatra", |
| 1165 |
)} |
| 1166 |
</CardDescription> |
| 1167 |
</CardHeader> |
| 1168 |
<CardContent className="space-y-6"> |
| 1169 |
{Object.entries(typeDef.credentials).map(([field, def]) => { |
| 1170 |
const fieldStatus = status[field] ?? { |
| 1171 |
configured: false, |
| 1172 |
hint: "", |
| 1173 |
}; |
| 1174 |
const draft = drafts[field] ?? ""; |
| 1175 |
const isVisible = !!visible[field]; |
| 1176 |
return ( |
| 1177 |
<div key={field} className="space-y-2"> |
| 1178 |
<div className="flex items-center justify-between gap-3 flex-wrap"> |
| 1179 |
<Label className="flex items-center gap-2"> |
| 1180 |
{def.label} |
| 1181 |
{def.required && ( |
| 1182 |
<span className="text-xs text-red-600 dark:text-red-400"> |
| 1183 |
* |
| 1184 |
</span> |
| 1185 |
)} |
| 1186 |
</Label> |
| 1187 |
{def.docs_url && ( |
| 1188 |
<a |
| 1189 |
href={def.docs_url} |
| 1190 |
target="_blank" |
| 1191 |
rel="noopener noreferrer" |
| 1192 |
className="inline-flex items-center gap-1 text-xs text-blue-600 hover:underline dark:text-blue-400" |
| 1193 |
> |
| 1194 |
{__("Where to find this", "yatra")} |
| 1195 |
<ExternalLink className="h-3 w-3" /> |
| 1196 |
</a> |
| 1197 |
)} |
| 1198 |
</div> |
| 1199 |
{def.description && ( |
| 1200 |
<p className="text-xs text-gray-500 dark:text-gray-400 flex items-start gap-1.5"> |
| 1201 |
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0 text-gray-400" /> |
| 1202 |
<span>{def.description}</span> |
| 1203 |
</p> |
| 1204 |
)} |
| 1205 |
<div className="flex gap-2 flex-wrap"> |
| 1206 |
<div className="relative flex-1 min-w-[260px]"> |
| 1207 |
<Input |
| 1208 |
type={isVisible ? "text" : "password"} |
| 1209 |
value={draft} |
| 1210 |
placeholder={ |
| 1211 |
fieldStatus.configured |
| 1212 |
? fieldStatus.hint |
| 1213 |
: __("Paste the credential value…", "yatra") |
| 1214 |
} |
| 1215 |
onChange={(e) => |
| 1216 |
setDrafts((d) => ({ ...d, [field]: e.target.value })) |
| 1217 |
} |
| 1218 |
/> |
| 1219 |
<button |
| 1220 |
type="button" |
| 1221 |
onClick={() => |
| 1222 |
setVisible((v) => ({ ...v, [field]: !v[field] })) |
| 1223 |
} |
| 1224 |
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600" |
| 1225 |
aria-label={ |
| 1226 |
isVisible |
| 1227 |
? __("Hide value", "yatra") |
| 1228 |
: __("Show value", "yatra") |
| 1229 |
} |
| 1230 |
> |
| 1231 |
{isVisible ? ( |
| 1232 |
<EyeOff className="h-4 w-4" /> |
| 1233 |
) : ( |
| 1234 |
<Eye className="h-4 w-4" /> |
| 1235 |
)} |
| 1236 |
</button> |
| 1237 |
</div> |
| 1238 |
<Button |
| 1239 |
onClick={() => { |
| 1240 |
save.mutate({ field, value: draft }); |
| 1241 |
setDrafts((d) => ({ ...d, [field]: "" })); |
| 1242 |
}} |
| 1243 |
disabled={save.isPending || draft.trim() === ""} |
| 1244 |
> |
| 1245 |
<Save className="mr-1.5 h-4 w-4" /> |
| 1246 |
{__("Save", "yatra")} |
| 1247 |
</Button> |
| 1248 |
{fieldStatus.configured && ( |
| 1249 |
<Button |
| 1250 |
variant="outline" |
| 1251 |
onClick={() => save.mutate({ field, value: "" })} |
| 1252 |
disabled={save.isPending} |
| 1253 |
className="border-red-200 text-red-700 hover:bg-red-50 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-900/30" |
| 1254 |
> |
| 1255 |
{__("Clear", "yatra")} |
| 1256 |
</Button> |
| 1257 |
)} |
| 1258 |
</div> |
| 1259 |
{fieldStatus.configured ? ( |
| 1260 |
<p className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1"> |
| 1261 |
<CheckCircle2 className="h-3 w-3" /> |
| 1262 |
{__("Configured", "yatra")} — {fieldStatus.hint} |
| 1263 |
</p> |
| 1264 |
) : def.required ? ( |
| 1265 |
<p className="text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1"> |
| 1266 |
<AlertTriangle className="h-3 w-3" /> |
| 1267 |
{__("Required — sync will fail without this.", "yatra")} |
| 1268 |
</p> |
| 1269 |
) : null} |
| 1270 |
</div> |
| 1271 |
); |
| 1272 |
})} |
| 1273 |
</CardContent> |
| 1274 |
</Card> |
| 1275 |
); |
| 1276 |
}; |
| 1277 |
|
| 1278 |
const WebhookCard: React.FC<{ |
| 1279 |
channelId: number; |
| 1280 |
webhookUrlTemplate: string; |
| 1281 |
}> = ({ channelId, webhookUrlTemplate }) => { |
| 1282 |
const { showToast } = useToast(); |
| 1283 |
// Replace `{channel_id}` placeholder if present, otherwise append id. |
| 1284 |
const url = webhookUrlTemplate.includes("{channel_id}") |
| 1285 |
? webhookUrlTemplate.replace("{channel_id}", String(channelId)) |
| 1286 |
: `${webhookUrlTemplate.replace(/\/$/, "")}/${channelId}`; |
| 1287 |
|
| 1288 |
const copy = async () => { |
| 1289 |
try { |
| 1290 |
await navigator.clipboard.writeText(url); |
| 1291 |
showToast(__("Webhook URL copied.", "yatra"), "success"); |
| 1292 |
} catch { |
| 1293 |
showToast(__("Couldn't copy — copy manually.", "yatra"), "error"); |
| 1294 |
} |
| 1295 |
}; |
| 1296 |
|
| 1297 |
return ( |
| 1298 |
<Card> |
| 1299 |
<CardHeader> |
| 1300 |
<CardTitle className="flex items-center gap-2"> |
| 1301 |
<Inbox className="h-5 w-5 text-blue-500" /> |
| 1302 |
{__("Inbound webhook URL", "yatra")} |
| 1303 |
</CardTitle> |
| 1304 |
<CardDescription> |
| 1305 |
{__( |
| 1306 |
"Paste this URL into the OTA's partner portal under booking notifications. Whenever a booking is made on the OTA, they POST it here and it appears in the Bookings inbox seconds later. The endpoint verifies the request signature, so only the OTA can deliver to it.", |
| 1307 |
"yatra", |
| 1308 |
)} |
| 1309 |
</CardDescription> |
| 1310 |
</CardHeader> |
| 1311 |
<CardContent> |
| 1312 |
<div className="flex gap-2 flex-wrap"> |
| 1313 |
<code className="flex-1 min-w-[280px] rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-xs font-mono text-gray-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 break-all"> |
| 1314 |
{url} |
| 1315 |
</code> |
| 1316 |
<Button variant="outline" onClick={copy}> |
| 1317 |
<Copy className="mr-1.5 h-4 w-4" /> |
| 1318 |
{__("Copy", "yatra")} |
| 1319 |
</Button> |
| 1320 |
</div> |
| 1321 |
<p className="mt-3 text-xs text-gray-500 dark:text-gray-400 flex items-start gap-1.5"> |
| 1322 |
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" /> |
| 1323 |
<span> |
| 1324 |
{__( |
| 1325 |
"If your site is behind a firewall or VPN, make sure this URL is reachable from the public internet — otherwise the OTA's webhook deliveries will time out and you'll only get bookings on the next hourly cron tick.", |
| 1326 |
"yatra", |
| 1327 |
)} |
| 1328 |
</span> |
| 1329 |
</p> |
| 1330 |
</CardContent> |
| 1331 |
</Card> |
| 1332 |
); |
| 1333 |
}; |
| 1334 |
|
| 1335 |
/* -------------------------------------------------------------------------- */ |
| 1336 |
/* Mappings tab */ |
| 1337 |
/* -------------------------------------------------------------------------- */ |
| 1338 |
|
| 1339 |
const MappingsSection: React.FC = () => { |
| 1340 |
const queryClient = useQueryClient(); |
| 1341 |
const { showToast } = useToast(); |
| 1342 |
const [filterChannel, setFilterChannel] = useState<number>(0); |
| 1343 |
const [showAdd, setShowAdd] = useState(false); |
| 1344 |
const [editing, setEditing] = useState<MappingRow | null>(null); |
| 1345 |
const [selected, setSelected] = useState<Set<number>>(new Set()); |
| 1346 |
// Unified pending-delete state. `kind` differentiates a single-row |
| 1347 |
// delete from a bulk delete so one <ConfirmationDialog> can serve both. |
| 1348 |
const [pendingDelete, setPendingDelete] = useState< |
| 1349 |
{ kind: "one"; id: number } | { kind: "bulk"; ids: number[] } | null |
| 1350 |
>(null); |
| 1351 |
|
| 1352 |
const { data: channelsData } = useQuery({ |
| 1353 |
queryKey: ["channel-manager-channels"], |
| 1354 |
queryFn: () => channelManagerApi.listChannels(), |
| 1355 |
}); |
| 1356 |
const { data: mappingsData, isLoading } = useQuery({ |
| 1357 |
queryKey: ["channel-manager-mappings", filterChannel], |
| 1358 |
queryFn: () => channelManagerApi.listMappings(filterChannel || undefined), |
| 1359 |
}); |
| 1360 |
|
| 1361 |
const syncMapping = useMutation({ |
| 1362 |
mutationFn: (id: number) => channelManagerApi.syncMapping(id), |
| 1363 |
onSuccess: (res) => { |
| 1364 |
queryClient.invalidateQueries({ queryKey: ["channel-manager-mappings"] }); |
| 1365 |
if (res.ok) { |
| 1366 |
showToast(__("Mapping synced.", "yatra"), "success"); |
| 1367 |
} else { |
| 1368 |
showToast(res.error || __("Sync failed.", "yatra"), "error"); |
| 1369 |
} |
| 1370 |
}, |
| 1371 |
onError: (e: any) => showToast(extractError(e), "error"), |
| 1372 |
}); |
| 1373 |
|
| 1374 |
const deleteMapping = useMutation({ |
| 1375 |
mutationFn: (id: number) => channelManagerApi.deleteMapping(id), |
| 1376 |
onSuccess: () => { |
| 1377 |
queryClient.invalidateQueries({ queryKey: ["channel-manager-mappings"] }); |
| 1378 |
showToast(__("Mapping deleted.", "yatra"), "success"); |
| 1379 |
setPendingDelete(null); |
| 1380 |
}, |
| 1381 |
onError: (e: any) => { |
| 1382 |
showToast(extractError(e), "error"); |
| 1383 |
setPendingDelete(null); |
| 1384 |
}, |
| 1385 |
}); |
| 1386 |
|
| 1387 |
const bulkAction = useMutation({ |
| 1388 |
mutationFn: (vars: { |
| 1389 |
action: "sync" | "enable" | "disable" | "delete"; |
| 1390 |
ids: number[]; |
| 1391 |
}) => channelManagerApi.bulkMappings(vars.action, vars.ids), |
| 1392 |
onSuccess: (res) => { |
| 1393 |
queryClient.invalidateQueries({ queryKey: ["channel-manager-mappings"] }); |
| 1394 |
setSelected(new Set()); |
| 1395 |
showToast(res.message, res.ok ? "success" : "error"); |
| 1396 |
setPendingDelete(null); |
| 1397 |
}, |
| 1398 |
onError: (e: any) => { |
| 1399 |
showToast(extractError(e), "error"); |
| 1400 |
setPendingDelete(null); |
| 1401 |
}, |
| 1402 |
}); |
| 1403 |
|
| 1404 |
const channels = channelsData?.data ?? []; |
| 1405 |
// eslint-disable-next-line react-hooks/exhaustive-deps |
| 1406 |
const mappings = mappingsData?.data ?? []; |
| 1407 |
const channelById = new Map<number, ChannelRow>( |
| 1408 |
channels.map((c) => [c.id, c]), |
| 1409 |
); |
| 1410 |
|
| 1411 |
// Resolve every mapping's trip_id → title + admin-edit URL so the |
| 1412 |
// listing renders a friendly trip name (with a clickable link) |
| 1413 |
// instead of the bare numeric id. One batched /trips fetch keyed on |
| 1414 |
// the comma-joined id list keeps the network traffic to a single |
| 1415 |
// request even when the operator has dozens of mappings, and React |
| 1416 |
// Query's 5-minute staleTime means subsequent visits read straight |
| 1417 |
// from cache. |
| 1418 |
const tripIdsForLookup = useMemo(() => { |
| 1419 |
const ids = new Set<number>(); |
| 1420 |
mappings.forEach((m: MappingRow) => { |
| 1421 |
if (m.trip_id) ids.add(Number(m.trip_id)); |
| 1422 |
}); |
| 1423 |
return Array.from(ids).sort((a, b) => a - b); |
| 1424 |
}, [mappings]); |
| 1425 |
|
| 1426 |
const { data: tripsLookup } = useQuery< |
| 1427 |
Map<number, { title: string; edit_url: string }> |
| 1428 |
>({ |
| 1429 |
queryKey: ["channel-manager-mapping-trip-titles", tripIdsForLookup], |
| 1430 |
enabled: tripIdsForLookup.length > 0, |
| 1431 |
staleTime: 5 * 60_000, |
| 1432 |
queryFn: async () => { |
| 1433 |
// /trips supports a comma-separated `include` (or `ids`) filter on |
| 1434 |
// most Yatra builds; if the active build doesn't, the response |
| 1435 |
// simply returns the first page and unmatched ids fall back to |
| 1436 |
// "Trip #N" in the row renderer — safe degradation. |
| 1437 |
const params: Record<string, any> = { |
| 1438 |
per_page: Math.max(tripIdsForLookup.length, 25), |
| 1439 |
include: tripIdsForLookup.join(","), |
| 1440 |
}; |
| 1441 |
const res: any = await apiClient.get("/trips", { params }); |
| 1442 |
const items: any[] = res?.data?.data || res?.data || res || []; |
| 1443 |
const map = new Map<number, { title: string; edit_url: string }>(); |
| 1444 |
if (Array.isArray(items)) { |
| 1445 |
items.forEach((t: any) => { |
| 1446 |
const id = Number(t?.id || 0); |
| 1447 |
if (!id) return; |
| 1448 |
map.set(id, { |
| 1449 |
title: String(t?.title || t?.name || `Trip #${id}`), |
| 1450 |
edit_url: |
| 1451 |
t?.edit_url || |
| 1452 |
t?.admin_edit_url || |
| 1453 |
t?.permalink || |
| 1454 |
`${(window as any)?.yatraAdmin?.adminUrl || "admin.php"}?page=yatra&subpage=trips&action=edit&id=${id}`, |
| 1455 |
}); |
| 1456 |
}); |
| 1457 |
} |
| 1458 |
return map; |
| 1459 |
}, |
| 1460 |
}); |
| 1461 |
|
| 1462 |
// If we're showing the edit form, render that instead of the list. |
| 1463 |
if (editing !== null) { |
| 1464 |
return ( |
| 1465 |
<MappingEditForm |
| 1466 |
channels={channels} |
| 1467 |
existing={editing} |
| 1468 |
onClose={() => setEditing(null)} |
| 1469 |
/> |
| 1470 |
); |
| 1471 |
} |
| 1472 |
|
| 1473 |
const visibleIds = mappings.map((m) => m.id); |
| 1474 |
const allSelected = |
| 1475 |
visibleIds.length > 0 && visibleIds.every((id) => selected.has(id)); |
| 1476 |
const toggleAll = () => { |
| 1477 |
if (allSelected) { |
| 1478 |
setSelected(new Set()); |
| 1479 |
} else { |
| 1480 |
setSelected(new Set(visibleIds)); |
| 1481 |
} |
| 1482 |
}; |
| 1483 |
const toggleOne = (id: number) => { |
| 1484 |
setSelected((prev) => { |
| 1485 |
const next = new Set(prev); |
| 1486 |
next.has(id) ? next.delete(id) : next.add(id); |
| 1487 |
return next; |
| 1488 |
}); |
| 1489 |
}; |
| 1490 |
const runBulk = (action: "sync" | "enable" | "disable" | "delete") => { |
| 1491 |
const ids = Array.from(selected); |
| 1492 |
if (ids.length === 0) return; |
| 1493 |
if (action === "delete") { |
| 1494 |
// Defer to the confirmation dialog — its onConfirm handler |
| 1495 |
// dispatches the actual bulkAction mutation. |
| 1496 |
setPendingDelete({ kind: "bulk", ids }); |
| 1497 |
return; |
| 1498 |
} |
| 1499 |
bulkAction.mutate({ action, ids }); |
| 1500 |
}; |
| 1501 |
|
| 1502 |
return ( |
| 1503 |
<div className="space-y-4"> |
| 1504 |
<div className="flex items-start justify-between gap-3 flex-wrap"> |
| 1505 |
<div className="max-w-2xl"> |
| 1506 |
<h3 className="text-lg font-semibold text-gray-900 dark:text-white"> |
| 1507 |
{__("Trip mappings", "yatra")} |
| 1508 |
</h3> |
| 1509 |
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1"> |
| 1510 |
{__( |
| 1511 |
"A mapping links one of your trips to its counterpart product on a channel. One trip can be mapped to many channels (each can have its own price offset); one channel holds one mapping per trip.", |
| 1512 |
"yatra", |
| 1513 |
)} |
| 1514 |
</p> |
| 1515 |
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2 flex items-start gap-1.5"> |
| 1516 |
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" /> |
| 1517 |
<span> |
| 1518 |
{__( |
| 1519 |
"Once mapped, every change to your trip's availability or price triggers a sync within seconds. Use the per-mapping offset to override the channel default when one trip needs different pricing logic.", |
| 1520 |
"yatra", |
| 1521 |
)} |
| 1522 |
</span> |
| 1523 |
</p> |
| 1524 |
</div> |
| 1525 |
<div className="flex items-center gap-2 flex-wrap"> |
| 1526 |
{/* The shared Select renders w-full by default so its |
| 1527 |
flex item stretches to fill the row and pushes the |
| 1528 |
Add button onto a second line. Constraining it to a |
| 1529 |
fixed pixel width keeps the filter dropdown and the |
| 1530 |
Add button on the same row when there is space — |
| 1531 |
and flex-wrap (not nowrap) lets the Button drop |
| 1532 |
below cleanly on truly narrow viewports instead of |
| 1533 |
overflowing the parent. */} |
| 1534 |
<Select |
| 1535 |
value={String(filterChannel)} |
| 1536 |
onChange={(e) => setFilterChannel(Number(e.target.value))} |
| 1537 |
className="w-56" |
| 1538 |
> |
| 1539 |
<option value="0">{__("All channels", "yatra")}</option> |
| 1540 |
{channels.map((c) => ( |
| 1541 |
<option key={c.id} value={c.id}> |
| 1542 |
{c.display_name || c.channel_type} |
| 1543 |
</option> |
| 1544 |
))} |
| 1545 |
</Select> |
| 1546 |
<Button |
| 1547 |
onClick={() => setShowAdd(true)} |
| 1548 |
disabled={channels.length === 0} |
| 1549 |
title={ |
| 1550 |
channels.length === 0 |
| 1551 |
? __("Add at least one channel first.", "yatra") |
| 1552 |
: undefined |
| 1553 |
} |
| 1554 |
> |
| 1555 |
<Plus className="mr-1.5 h-4 w-4" /> |
| 1556 |
{__("Add mapping", "yatra")} |
| 1557 |
</Button> |
| 1558 |
</div> |
| 1559 |
</div> |
| 1560 |
|
| 1561 |
{showAdd && ( |
| 1562 |
<MappingAddForm |
| 1563 |
channels={channels} |
| 1564 |
defaultChannelId={filterChannel || channels[0]?.id || 0} |
| 1565 |
onClose={() => setShowAdd(false)} |
| 1566 |
/> |
| 1567 |
)} |
| 1568 |
|
| 1569 |
{selected.size > 0 && ( |
| 1570 |
<div className="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 dark:border-blue-800 dark:bg-blue-950/30 flex-wrap"> |
| 1571 |
<span className="text-sm font-medium text-blue-900 dark:text-blue-200"> |
| 1572 |
{__("{n} selected", "yatra").replace("{n}", String(selected.size))} |
| 1573 |
</span> |
| 1574 |
<div className="ml-auto flex gap-2 flex-wrap"> |
| 1575 |
<Button |
| 1576 |
size="sm" |
| 1577 |
variant="outline" |
| 1578 |
onClick={() => runBulk("sync")} |
| 1579 |
disabled={bulkAction.isPending} |
| 1580 |
> |
| 1581 |
<RefreshCw className="mr-1.5 h-3.5 w-3.5" /> |
| 1582 |
{__("Sync selected", "yatra")} |
| 1583 |
</Button> |
| 1584 |
<Button |
| 1585 |
size="sm" |
| 1586 |
variant="outline" |
| 1587 |
onClick={() => runBulk("enable")} |
| 1588 |
disabled={bulkAction.isPending} |
| 1589 |
> |
| 1590 |
<Power className="mr-1.5 h-3.5 w-3.5" /> |
| 1591 |
{__("Enable", "yatra")} |
| 1592 |
</Button> |
| 1593 |
<Button |
| 1594 |
size="sm" |
| 1595 |
variant="outline" |
| 1596 |
onClick={() => runBulk("disable")} |
| 1597 |
disabled={bulkAction.isPending} |
| 1598 |
> |
| 1599 |
<PowerOff className="mr-1.5 h-3.5 w-3.5" /> |
| 1600 |
{__("Pause", "yatra")} |
| 1601 |
</Button> |
| 1602 |
<Button |
| 1603 |
size="sm" |
| 1604 |
variant="outline" |
| 1605 |
className="border-red-200 text-red-700 hover:bg-red-50 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-900/30" |
| 1606 |
onClick={() => runBulk("delete")} |
| 1607 |
disabled={bulkAction.isPending} |
| 1608 |
> |
| 1609 |
<Trash2 className="mr-1.5 h-3.5 w-3.5" /> |
| 1610 |
{__("Delete", "yatra")} |
| 1611 |
</Button> |
| 1612 |
<Button |
| 1613 |
size="sm" |
| 1614 |
variant="outline" |
| 1615 |
onClick={() => setSelected(new Set())} |
| 1616 |
> |
| 1617 |
{__("Clear", "yatra")} |
| 1618 |
</Button> |
| 1619 |
</div> |
| 1620 |
</div> |
| 1621 |
)} |
| 1622 |
|
| 1623 |
<Card> |
| 1624 |
<CardContent className="p-0"> |
| 1625 |
{isLoading ? ( |
| 1626 |
<div className="p-4"> |
| 1627 |
<ModuleTableSkeleton rows={5} columns={4} /> |
| 1628 |
</div> |
| 1629 |
) : mappings.length === 0 ? ( |
| 1630 |
<div className="p-10 text-center"> |
| 1631 |
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30"> |
| 1632 |
<LinkIcon className="h-7 w-7 text-blue-600 dark:text-blue-400" /> |
| 1633 |
</div> |
| 1634 |
<h4 className="text-base font-semibold text-gray-900 dark:text-white"> |
| 1635 |
{__("No mappings yet", "yatra")} |
| 1636 |
</h4> |
| 1637 |
<p className="mx-auto mt-1 max-w-md text-sm text-gray-500 dark:text-gray-400"> |
| 1638 |
{channels.length === 0 |
| 1639 |
? __( |
| 1640 |
"Add a channel first, then come back here to map your trips to it.", |
| 1641 |
"yatra", |
| 1642 |
) |
| 1643 |
: __( |
| 1644 |
"Map each trip you want to sell on a channel to its counterpart product on that channel's side.", |
| 1645 |
"yatra", |
| 1646 |
)} |
| 1647 |
</p> |
| 1648 |
</div> |
| 1649 |
) : ( |
| 1650 |
<div className="overflow-x-auto"> |
| 1651 |
<table className="w-full"> |
| 1652 |
<thead className="bg-gray-50 dark:bg-gray-800"> |
| 1653 |
<tr> |
| 1654 |
<th className="px-4 py-3 w-10"> |
| 1655 |
<input |
| 1656 |
type="checkbox" |
| 1657 |
checked={allSelected} |
| 1658 |
onChange={toggleAll} |
| 1659 |
aria-label={__("Select all", "yatra")} |
| 1660 |
/> |
| 1661 |
</th> |
| 1662 |
<Th>{__("Channel", "yatra")}</Th> |
| 1663 |
<Th>{__("Trip", "yatra")}</Th> |
| 1664 |
<Th>{__("External product", "yatra")}</Th> |
| 1665 |
<Th>{__("Offset", "yatra")}</Th> |
| 1666 |
<Th>{__("Sync state", "yatra")}</Th> |
| 1667 |
<Th>{__("Last sync", "yatra")}</Th> |
| 1668 |
<Th align="right">{__("Actions", "yatra")}</Th> |
| 1669 |
</tr> |
| 1670 |
</thead> |
| 1671 |
<tbody className="divide-y divide-gray-200 dark:divide-gray-700"> |
| 1672 |
{mappings.map((m) => { |
| 1673 |
const ch = channelById.get(m.channel_id); |
| 1674 |
const isSelected = selected.has(m.id); |
| 1675 |
return ( |
| 1676 |
<tr |
| 1677 |
key={m.id} |
| 1678 |
className={`hover:bg-gray-50 dark:hover:bg-gray-800/50 ${ |
| 1679 |
isSelected ? "bg-blue-50/50 dark:bg-blue-950/20" : "" |
| 1680 |
}`} |
| 1681 |
> |
| 1682 |
<td className="px-4 py-3"> |
| 1683 |
<input |
| 1684 |
type="checkbox" |
| 1685 |
checked={isSelected} |
| 1686 |
onChange={() => toggleOne(m.id)} |
| 1687 |
aria-label={__("Select mapping", "yatra")} |
| 1688 |
/> |
| 1689 |
</td> |
| 1690 |
<td className="px-4 py-3 text-sm"> |
| 1691 |
{ch?.display_name || |
| 1692 |
ch?.channel_type || |
| 1693 |
`#${m.channel_id}`} |
| 1694 |
</td> |
| 1695 |
<td className="px-4 py-3 text-sm"> |
| 1696 |
{(() => { |
| 1697 |
const trip = tripsLookup?.get(m.trip_id); |
| 1698 |
const title = trip?.title || `Trip #${m.trip_id}`; |
| 1699 |
const url = trip?.edit_url; |
| 1700 |
return ( |
| 1701 |
<div className="flex items-center gap-1.5 min-w-0"> |
| 1702 |
{url ? ( |
| 1703 |
<a |
| 1704 |
href={url} |
| 1705 |
className="truncate font-medium text-blue-600 hover:underline dark:text-blue-400" |
| 1706 |
title={title} |
| 1707 |
> |
| 1708 |
{title} |
| 1709 |
</a> |
| 1710 |
) : ( |
| 1711 |
<span |
| 1712 |
className="truncate font-medium" |
| 1713 |
title={title} |
| 1714 |
> |
| 1715 |
{title} |
| 1716 |
</span> |
| 1717 |
)} |
| 1718 |
<code className="flex-shrink-0 rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-500 dark:bg-gray-800 dark:text-gray-400"> |
| 1719 |
#{m.trip_id} |
| 1720 |
</code> |
| 1721 |
</div> |
| 1722 |
); |
| 1723 |
})()} |
| 1724 |
</td> |
| 1725 |
<td className="px-4 py-3 text-sm"> |
| 1726 |
{m.external_url ? ( |
| 1727 |
<a |
| 1728 |
href={m.external_url} |
| 1729 |
target="_blank" |
| 1730 |
rel="noopener noreferrer" |
| 1731 |
className="text-blue-600 hover:underline dark:text-blue-400" |
| 1732 |
> |
| 1733 |
{m.external_product_id || "—"} |
| 1734 |
<ExternalLink className="ml-1 inline h-3 w-3" /> |
| 1735 |
</a> |
| 1736 |
) : ( |
| 1737 |
<code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs dark:bg-gray-800"> |
| 1738 |
{m.external_product_id || "—"} |
| 1739 |
</code> |
| 1740 |
)} |
| 1741 |
</td> |
| 1742 |
<td className="px-4 py-3 text-sm"> |
| 1743 |
{m.price_offset_percent !== 0 |
| 1744 |
? `${m.price_offset_percent > 0 ? "+" : ""}${m.price_offset_percent}%` |
| 1745 |
: "—"} |
| 1746 |
</td> |
| 1747 |
<td className="px-4 py-3"> |
| 1748 |
<Badge |
| 1749 |
className={ |
| 1750 |
m.is_active |
| 1751 |
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" |
| 1752 |
: "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-300" |
| 1753 |
} |
| 1754 |
> |
| 1755 |
{m.is_active |
| 1756 |
? __("Active", "yatra") |
| 1757 |
: __("Paused", "yatra")} |
| 1758 |
</Badge> |
| 1759 |
</td> |
| 1760 |
<td className="px-4 py-3 text-xs text-gray-500 dark:text-gray-400"> |
| 1761 |
{m.last_synced_at |
| 1762 |
? new Date(m.last_synced_at).toLocaleString() |
| 1763 |
: "—"} |
| 1764 |
</td> |
| 1765 |
<td className="px-4 py-3 text-right"> |
| 1766 |
<div className="inline-flex gap-1"> |
| 1767 |
<Button |
| 1768 |
variant="outline" |
| 1769 |
size="sm" |
| 1770 |
onClick={() => setEditing(m)} |
| 1771 |
title={__("Edit this mapping", "yatra")} |
| 1772 |
> |
| 1773 |
{__("Edit", "yatra")} |
| 1774 |
</Button> |
| 1775 |
<Button |
| 1776 |
variant="outline" |
| 1777 |
size="sm" |
| 1778 |
onClick={() => syncMapping.mutate(m.id)} |
| 1779 |
disabled={syncMapping.isPending} |
| 1780 |
title={__( |
| 1781 |
"Push inventory + pricing now", |
| 1782 |
"yatra", |
| 1783 |
)} |
| 1784 |
> |
| 1785 |
<RefreshCw className="h-3.5 w-3.5" /> |
| 1786 |
</Button> |
| 1787 |
<Button |
| 1788 |
variant="outline" |
| 1789 |
size="sm" |
| 1790 |
className="border-red-200 text-red-700 hover:bg-red-50 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-900/30" |
| 1791 |
onClick={() => |
| 1792 |
setPendingDelete({ kind: "one", id: m.id }) |
| 1793 |
} |
| 1794 |
> |
| 1795 |
<Trash2 className="h-3.5 w-3.5" /> |
| 1796 |
</Button> |
| 1797 |
</div> |
| 1798 |
</td> |
| 1799 |
</tr> |
| 1800 |
); |
| 1801 |
})} |
| 1802 |
</tbody> |
| 1803 |
</table> |
| 1804 |
</div> |
| 1805 |
)} |
| 1806 |
</CardContent> |
| 1807 |
</Card> |
| 1808 |
<ConfirmationDialog |
| 1809 |
isOpen={pendingDelete !== null} |
| 1810 |
onClose={() => { |
| 1811 |
if (!deleteMapping.isPending && !bulkAction.isPending) { |
| 1812 |
setPendingDelete(null); |
| 1813 |
} |
| 1814 |
}} |
| 1815 |
onConfirm={() => { |
| 1816 |
if (!pendingDelete) return; |
| 1817 |
if (pendingDelete.kind === "one") { |
| 1818 |
deleteMapping.mutate(pendingDelete.id); |
| 1819 |
} else { |
| 1820 |
bulkAction.mutate({ action: "delete", ids: pendingDelete.ids }); |
| 1821 |
} |
| 1822 |
}} |
| 1823 |
title={ |
| 1824 |
pendingDelete?.kind === "bulk" |
| 1825 |
? __("Delete selected mappings?", "yatra") |
| 1826 |
: __("Delete mapping?", "yatra") |
| 1827 |
} |
| 1828 |
description={ |
| 1829 |
pendingDelete?.kind === "bulk" |
| 1830 |
? __( |
| 1831 |
"Delete {n} mappings? They will stop syncing immediately. Existing bookings are unaffected.", |
| 1832 |
"yatra", |
| 1833 |
).replace("{n}", String(pendingDelete.ids.length)) |
| 1834 |
: __( |
| 1835 |
"Delete this mapping? The trip will no longer sync to this channel. Existing bookings already in the inbox are not affected.", |
| 1836 |
"yatra", |
| 1837 |
) |
| 1838 |
} |
| 1839 |
confirmText={ |
| 1840 |
pendingDelete?.kind === "bulk" |
| 1841 |
? __("Delete mappings", "yatra") |
| 1842 |
: __("Delete mapping", "yatra") |
| 1843 |
} |
| 1844 |
cancelText={__("Cancel", "yatra")} |
| 1845 |
variant="danger" |
| 1846 |
isLoading={deleteMapping.isPending || bulkAction.isPending} |
| 1847 |
/> |
| 1848 |
</div> |
| 1849 |
); |
| 1850 |
}; |
| 1851 |
|
| 1852 |
/** |
| 1853 |
* Searchable single-trip picker for the New Mapping form. |
| 1854 |
* |
| 1855 |
* Replaces a numeric "Trip ID" input (which required operators to |
| 1856 |
* find the database id from the trip-edit URL bar) with a typeahead |
| 1857 |
* that hits /trips?search=… and lets them pick by title. Same shape |
| 1858 |
* as the multi-trip picker in ApplicableTripSelector but trimmed to |
| 1859 |
* the single-select case the mapping form needs. |
| 1860 |
* |
| 1861 |
* Keeps the trip id as the underlying value (the only thing the |
| 1862 |
* mapping API cares about) so the surrounding form code is |
| 1863 |
* unchanged: setTrip(id) is the only output. |
| 1864 |
*/ |
| 1865 |
type TripOption = { id: number; title: string }; |
| 1866 |
|
| 1867 |
const TripPicker: React.FC<{ |
| 1868 |
value: number; |
| 1869 |
onChange: (id: number, label?: string) => void; |
| 1870 |
placeholder?: string; |
| 1871 |
}> = ({ value, onChange, placeholder }) => { |
| 1872 |
const [open, setOpen] = useState(false); |
| 1873 |
const [search, setSearch] = useState(""); |
| 1874 |
const [debounced, setDebounced] = useState(""); |
| 1875 |
const wrapperRef = useRef<HTMLDivElement | null>(null); |
| 1876 |
const searchInputRef = useRef<HTMLInputElement | null>(null); |
| 1877 |
|
| 1878 |
// Debounce search so we don't spam /trips on every keystroke. |
| 1879 |
useEffect(() => { |
| 1880 |
const t = setTimeout(() => setDebounced(search.trim()), 250); |
| 1881 |
return () => clearTimeout(t); |
| 1882 |
}, [search]); |
| 1883 |
|
| 1884 |
// Close the dropdown when the operator clicks outside the picker. |
| 1885 |
useEffect(() => { |
| 1886 |
if (!open) return; |
| 1887 |
const onDocClick = (e: MouseEvent) => { |
| 1888 |
if ( |
| 1889 |
wrapperRef.current && |
| 1890 |
!wrapperRef.current.contains(e.target as Node) |
| 1891 |
) { |
| 1892 |
setOpen(false); |
| 1893 |
} |
| 1894 |
}; |
| 1895 |
document.addEventListener("mousedown", onDocClick); |
| 1896 |
return () => document.removeEventListener("mousedown", onDocClick); |
| 1897 |
}, [open]); |
| 1898 |
|
| 1899 |
// Auto-focus the search field when the dropdown opens so the |
| 1900 |
// operator can start typing immediately. |
| 1901 |
useEffect(() => { |
| 1902 |
if (open && searchInputRef.current) { |
| 1903 |
searchInputRef.current.focus(); |
| 1904 |
} |
| 1905 |
}, [open]); |
| 1906 |
|
| 1907 |
// Result list: search hits /trips?search=. When no search term yet, |
| 1908 |
// surface the first page so the operator always has something to |
| 1909 |
// pick on first open instead of an empty box. |
| 1910 |
const { data: results = [], isFetching } = useQuery<TripOption[]>({ |
| 1911 |
queryKey: ["channel-manager-trip-picker", debounced], |
| 1912 |
queryFn: async () => { |
| 1913 |
const params: Record<string, any> = { per_page: 25 }; |
| 1914 |
if (debounced) params.search = debounced; |
| 1915 |
const res: any = await apiClient.get("/trips", { params }); |
| 1916 |
const items = res?.data?.data || res?.data || res || []; |
| 1917 |
if (!Array.isArray(items)) return []; |
| 1918 |
return items |
| 1919 |
.map((t: any) => ({ |
| 1920 |
id: Number(t?.id || 0), |
| 1921 |
title: String(t?.title || t?.name || `Trip #${t?.id}`), |
| 1922 |
})) |
| 1923 |
.filter((t: TripOption) => t.id > 0); |
| 1924 |
}, |
| 1925 |
enabled: open, |
| 1926 |
staleTime: 60_000, |
| 1927 |
}); |
| 1928 |
|
| 1929 |
// When `value` is set (editing an existing draft or after a pick), |
| 1930 |
// look up the matching label so the closed button shows the title |
| 1931 |
// rather than just "#42". One-shot lookup; cached by id. |
| 1932 |
const { data: selectedTrip } = useQuery<TripOption | null>({ |
| 1933 |
queryKey: ["channel-manager-trip-picker:resolve", value], |
| 1934 |
queryFn: async () => { |
| 1935 |
if (!value) return null; |
| 1936 |
try { |
| 1937 |
const res: any = await apiClient.get(`/trips/${value}`); |
| 1938 |
const t = res?.data?.data || res?.data || res || null; |
| 1939 |
if (!t || !t.id) return null; |
| 1940 |
return { |
| 1941 |
id: Number(t.id), |
| 1942 |
title: String(t.title || t.name || `Trip #${t.id}`), |
| 1943 |
}; |
| 1944 |
} catch { |
| 1945 |
return null; |
| 1946 |
} |
| 1947 |
}, |
| 1948 |
enabled: value > 0, |
| 1949 |
staleTime: 5 * 60_000, |
| 1950 |
}); |
| 1951 |
|
| 1952 |
return ( |
| 1953 |
<div ref={wrapperRef} className="relative"> |
| 1954 |
<button |
| 1955 |
type="button" |
| 1956 |
onClick={() => setOpen((o) => !o)} |
| 1957 |
className="flex h-12 w-full items-center justify-between rounded-md border-2 border-gray-300 bg-white px-4 text-left text-base text-gray-900 transition-colors hover:border-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:hover:border-gray-500" |
| 1958 |
aria-haspopup="listbox" |
| 1959 |
aria-expanded={open} |
| 1960 |
> |
| 1961 |
<span className="flex min-w-0 items-center gap-2"> |
| 1962 |
{value > 0 ? ( |
| 1963 |
<> |
| 1964 |
<span className="truncate font-medium"> |
| 1965 |
{selectedTrip?.title || `Trip #${value}`} |
| 1966 |
</span> |
| 1967 |
<span className="flex-shrink-0 rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-500 dark:bg-gray-700 dark:text-gray-400"> |
| 1968 |
#{value} |
| 1969 |
</span> |
| 1970 |
</> |
| 1971 |
) : ( |
| 1972 |
<span className="text-gray-500 dark:text-gray-400"> |
| 1973 |
{placeholder || __("Search and select a trip…", "yatra")} |
| 1974 |
</span> |
| 1975 |
)} |
| 1976 |
</span> |
| 1977 |
<span className="flex flex-shrink-0 items-center gap-1"> |
| 1978 |
{value > 0 && ( |
| 1979 |
<span |
| 1980 |
role="button" |
| 1981 |
tabIndex={0} |
| 1982 |
onClick={(e) => { |
| 1983 |
e.stopPropagation(); |
| 1984 |
onChange(0); |
| 1985 |
}} |
| 1986 |
onKeyDown={(e) => { |
| 1987 |
if (e.key === "Enter" || e.key === " ") { |
| 1988 |
e.preventDefault(); |
| 1989 |
e.stopPropagation(); |
| 1990 |
onChange(0); |
| 1991 |
} |
| 1992 |
}} |
| 1993 |
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700 dark:hover:bg-gray-700" |
| 1994 |
aria-label={__("Clear selection", "yatra")} |
| 1995 |
> |
| 1996 |
<X className="h-3.5 w-3.5" /> |
| 1997 |
</span> |
| 1998 |
)} |
| 1999 |
<ChevronDown |
| 2000 |
className={`h-4 w-4 text-gray-400 transition-transform ${ |
| 2001 |
open ? "rotate-180" : "" |
| 2002 |
}`} |
| 2003 |
/> |
| 2004 |
</span> |
| 2005 |
</button> |
| 2006 |
|
| 2007 |
{open && ( |
| 2008 |
<div className="absolute z-30 mt-1 w-full overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800"> |
| 2009 |
<div className="border-b border-gray-100 p-2 dark:border-gray-700"> |
| 2010 |
<div className="relative"> |
| 2011 |
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" /> |
| 2012 |
<input |
| 2013 |
ref={searchInputRef} |
| 2014 |
type="text" |
| 2015 |
value={search} |
| 2016 |
onChange={(e) => setSearch(e.target.value)} |
| 2017 |
placeholder={__("Type to search trips by title…", "yatra")} |
| 2018 |
className="w-full rounded border border-gray-200 bg-white py-1.5 pl-8 pr-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-900" |
| 2019 |
/> |
| 2020 |
</div> |
| 2021 |
</div> |
| 2022 |
<div className="max-h-72 overflow-auto"> |
| 2023 |
{isFetching ? ( |
| 2024 |
<div className="flex items-center justify-center p-6 text-sm text-gray-500"> |
| 2025 |
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> |
| 2026 |
{__("Searching…", "yatra")} |
| 2027 |
</div> |
| 2028 |
) : results.length === 0 ? ( |
| 2029 |
<div className="p-6 text-center text-sm text-gray-500 dark:text-gray-400"> |
| 2030 |
{debounced |
| 2031 |
? __("No trips match that search.", "yatra") |
| 2032 |
: __("No trips found.", "yatra")} |
| 2033 |
</div> |
| 2034 |
) : ( |
| 2035 |
<ul role="listbox" className="py-1"> |
| 2036 |
{results.map((t) => { |
| 2037 |
const isSelected = t.id === value; |
| 2038 |
return ( |
| 2039 |
<li key={t.id} role="option" aria-selected={isSelected}> |
| 2040 |
<button |
| 2041 |
type="button" |
| 2042 |
onClick={() => { |
| 2043 |
onChange(t.id, t.title); |
| 2044 |
setOpen(false); |
| 2045 |
setSearch(""); |
| 2046 |
}} |
| 2047 |
className={`flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-gray-100 dark:hover:bg-gray-700 ${ |
| 2048 |
isSelected |
| 2049 |
? "bg-blue-50 text-blue-900 dark:bg-blue-950/40 dark:text-blue-200" |
| 2050 |
: "" |
| 2051 |
}`} |
| 2052 |
> |
| 2053 |
<span className="flex min-w-0 items-center gap-2"> |
| 2054 |
<span className="truncate">{t.title}</span> |
| 2055 |
<span className="flex-shrink-0 rounded bg-gray-100 px-1.5 py-0.5 text-[11px] text-gray-500 dark:bg-gray-700 dark:text-gray-400"> |
| 2056 |
#{t.id} |
| 2057 |
</span> |
| 2058 |
</span> |
| 2059 |
{isSelected && ( |
| 2060 |
<Check className="h-4 w-4 flex-shrink-0 text-blue-600 dark:text-blue-400" /> |
| 2061 |
)} |
| 2062 |
</button> |
| 2063 |
</li> |
| 2064 |
); |
| 2065 |
})} |
| 2066 |
</ul> |
| 2067 |
)} |
| 2068 |
</div> |
| 2069 |
</div> |
| 2070 |
)} |
| 2071 |
</div> |
| 2072 |
); |
| 2073 |
}; |
| 2074 |
|
| 2075 |
const MappingAddForm: React.FC<{ |
| 2076 |
channels: ChannelRow[]; |
| 2077 |
defaultChannelId: number; |
| 2078 |
onClose: () => void; |
| 2079 |
}> = ({ channels, defaultChannelId, onClose }) => { |
| 2080 |
const queryClient = useQueryClient(); |
| 2081 |
const { showToast } = useToast(); |
| 2082 |
const [channelId, setChannelId] = useState<number>(defaultChannelId); |
| 2083 |
const [tripId, setTripId] = useState<number>(0); |
| 2084 |
const [externalProductId, setExternalProductId] = useState(""); |
| 2085 |
const [externalUrl, setExternalUrl] = useState(""); |
| 2086 |
const [offset, setOffset] = useState<number>(0); |
| 2087 |
const [syncInventory, setSyncInventory] = useState(true); |
| 2088 |
const [syncPricing, setSyncPricing] = useState(true); |
| 2089 |
|
| 2090 |
const create = useMutation({ |
| 2091 |
mutationFn: () => |
| 2092 |
channelManagerApi.createMapping({ |
| 2093 |
channel_id: channelId, |
| 2094 |
trip_id: tripId, |
| 2095 |
external_product_id: externalProductId, |
| 2096 |
external_url: externalUrl, |
| 2097 |
price_offset_percent: offset, |
| 2098 |
is_active: true, |
| 2099 |
sync_inventory: syncInventory, |
| 2100 |
sync_pricing: syncPricing, |
| 2101 |
}), |
| 2102 |
onSuccess: () => { |
| 2103 |
queryClient.invalidateQueries({ queryKey: ["channel-manager-mappings"] }); |
| 2104 |
showToast(__("Mapping created.", "yatra"), "success"); |
| 2105 |
onClose(); |
| 2106 |
}, |
| 2107 |
onError: (e: any) => showToast(extractError(e), "error"), |
| 2108 |
}); |
| 2109 |
|
| 2110 |
return ( |
| 2111 |
<Card> |
| 2112 |
<CardHeader> |
| 2113 |
<CardTitle>{__("New trip mapping", "yatra")}</CardTitle> |
| 2114 |
<CardDescription> |
| 2115 |
{__( |
| 2116 |
"Connect one of your trips to a product on the channel. You'll need the channel's product ID — check the channel's partner portal or the URL of the product page.", |
| 2117 |
"yatra", |
| 2118 |
)} |
| 2119 |
</CardDescription> |
| 2120 |
</CardHeader> |
| 2121 |
<CardContent className="space-y-5"> |
| 2122 |
<div className="grid gap-5 md:grid-cols-2"> |
| 2123 |
<FormField |
| 2124 |
label={__("Channel", "yatra")} |
| 2125 |
description={__("Which channel this mapping pushes to.", "yatra")} |
| 2126 |
> |
| 2127 |
<Select |
| 2128 |
value={String(channelId)} |
| 2129 |
onChange={(e) => setChannelId(Number(e.target.value))} |
| 2130 |
> |
| 2131 |
{channels.map((c) => ( |
| 2132 |
<option key={c.id} value={c.id}> |
| 2133 |
{c.display_name || c.channel_type} |
| 2134 |
</option> |
| 2135 |
))} |
| 2136 |
</Select> |
| 2137 |
</FormField> |
| 2138 |
|
| 2139 |
<FormField |
| 2140 |
label={__("Trip", "yatra")} |
| 2141 |
description={__( |
| 2142 |
"Pick the Yatra trip this mapping pushes to the channel. Start typing the trip title — the list filters as you type.", |
| 2143 |
"yatra", |
| 2144 |
)} |
| 2145 |
> |
| 2146 |
<TripPicker |
| 2147 |
value={tripId} |
| 2148 |
onChange={(id) => setTripId(id)} |
| 2149 |
placeholder={__("Search and select a trip…", "yatra")} |
| 2150 |
/> |
| 2151 |
</FormField> |
| 2152 |
|
| 2153 |
<FormField |
| 2154 |
label={__("Channel product ID", "yatra")} |
| 2155 |
description={__( |
| 2156 |
"The product's ID on the channel side. For Viator this is a product code (e.g. 12345P1); for GetYourGuide it's the offer/option ID. Copy it from the channel's partner portal.", |
| 2157 |
"yatra", |
| 2158 |
)} |
| 2159 |
> |
| 2160 |
<Input |
| 2161 |
value={externalProductId} |
| 2162 |
onChange={(e) => setExternalProductId(e.target.value)} |
| 2163 |
placeholder={__( |
| 2164 |
"e.g. 12345P1 (Viator) or 87654 (GetYourGuide)", |
| 2165 |
"yatra", |
| 2166 |
)} |
| 2167 |
/> |
| 2168 |
</FormField> |
| 2169 |
|
| 2170 |
<FormField |
| 2171 |
label={__("Public listing URL (optional)", "yatra")} |
| 2172 |
description={__( |
| 2173 |
"Link to the channel's public listing for this product. Shown as a quick-jump link in the mappings table so you can verify changes after syncing.", |
| 2174 |
"yatra", |
| 2175 |
)} |
| 2176 |
> |
| 2177 |
<Input |
| 2178 |
value={externalUrl} |
| 2179 |
onChange={(e) => setExternalUrl(e.target.value)} |
| 2180 |
placeholder="https://www.viator.com/..." |
| 2181 |
/> |
| 2182 |
</FormField> |
| 2183 |
|
| 2184 |
<FormField |
| 2185 |
label={__("Per-mapping price offset (%)", "yatra")} |
| 2186 |
description={__( |
| 2187 |
"Overrides the channel's default offset for this trip only. Use 0 to inherit the channel default. Set positive to mark up, negative to discount.", |
| 2188 |
"yatra", |
| 2189 |
)} |
| 2190 |
> |
| 2191 |
<Input |
| 2192 |
type="number" |
| 2193 |
step="0.01" |
| 2194 |
value={offset} |
| 2195 |
onChange={(e) => setOffset(Number(e.target.value) || 0)} |
| 2196 |
placeholder="0" |
| 2197 |
/> |
| 2198 |
</FormField> |
| 2199 |
</div> |
| 2200 |
|
| 2201 |
<div className="grid gap-4 md:grid-cols-2 pt-2 border-t border-gray-200 dark:border-gray-700"> |
| 2202 |
<ToggleRow |
| 2203 |
label={__("Sync inventory", "yatra")} |
| 2204 |
description={__( |
| 2205 |
"Push seat availability to this channel. Turn off if you're using the channel's own inventory system instead.", |
| 2206 |
"yatra", |
| 2207 |
)} |
| 2208 |
checked={syncInventory} |
| 2209 |
onChange={setSyncInventory} |
| 2210 |
/> |
| 2211 |
<ToggleRow |
| 2212 |
label={__("Sync pricing", "yatra")} |
| 2213 |
description={__( |
| 2214 |
"Push prices to this channel. Turn off when prices are managed on the channel side (e.g. for promotions you control there).", |
| 2215 |
"yatra", |
| 2216 |
)} |
| 2217 |
checked={syncPricing} |
| 2218 |
onChange={setSyncPricing} |
| 2219 |
/> |
| 2220 |
</div> |
| 2221 |
|
| 2222 |
<div className="flex justify-end gap-2"> |
| 2223 |
<Button variant="outline" onClick={onClose}> |
| 2224 |
{__("Cancel", "yatra")} |
| 2225 |
</Button> |
| 2226 |
<Button |
| 2227 |
onClick={() => create.mutate()} |
| 2228 |
disabled={ |
| 2229 |
create.isPending || |
| 2230 |
channelId <= 0 || |
| 2231 |
tripId <= 0 || |
| 2232 |
externalProductId.trim() === "" |
| 2233 |
} |
| 2234 |
> |
| 2235 |
{create.isPending ? ( |
| 2236 |
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" /> |
| 2237 |
) : ( |
| 2238 |
<Plus className="mr-1.5 h-4 w-4" /> |
| 2239 |
)} |
| 2240 |
{__("Create mapping", "yatra")} |
| 2241 |
</Button> |
| 2242 |
</div> |
| 2243 |
</CardContent> |
| 2244 |
</Card> |
| 2245 |
); |
| 2246 |
}; |
| 2247 |
|
| 2248 |
/** |
| 2249 |
* Full-edit form for an existing mapping. Backs the row-level |
| 2250 |
* "Edit" button — previously the operator could only delete + |
| 2251 |
* recreate, which is destructive and loses sync history. |
| 2252 |
* |
| 2253 |
* Editable fields mirror what the server's update endpoint |
| 2254 |
* accepts: external_product_id, external_url, price_offset_percent, |
| 2255 |
* is_active, sync_inventory, sync_pricing. Channel and trip IDs are |
| 2256 |
* intentionally immutable — re-pointing a mapping to a different |
| 2257 |
* trip is a different mapping; delete + create instead. |
| 2258 |
*/ |
| 2259 |
const MappingEditForm: React.FC<{ |
| 2260 |
channels: ChannelRow[]; |
| 2261 |
existing: MappingRow; |
| 2262 |
onClose: () => void; |
| 2263 |
}> = ({ channels, existing, onClose }) => { |
| 2264 |
const queryClient = useQueryClient(); |
| 2265 |
const { showToast } = useToast(); |
| 2266 |
|
| 2267 |
const [externalProductId, setExternalProductId] = useState( |
| 2268 |
existing.external_product_id ?? "", |
| 2269 |
); |
| 2270 |
const [externalUrl, setExternalUrl] = useState(existing.external_url ?? ""); |
| 2271 |
const [offset, setOffset] = useState<number>( |
| 2272 |
existing.price_offset_percent ?? 0, |
| 2273 |
); |
| 2274 |
const [isActive, setIsActive] = useState<boolean>(existing.is_active); |
| 2275 |
const [syncInventory, setSyncInventory] = useState<boolean>( |
| 2276 |
existing.sync_inventory, |
| 2277 |
); |
| 2278 |
const [syncPricing, setSyncPricing] = useState<boolean>( |
| 2279 |
existing.sync_pricing, |
| 2280 |
); |
| 2281 |
|
| 2282 |
const channel = channels.find((c) => c.id === existing.channel_id); |
| 2283 |
|
| 2284 |
const save = useMutation({ |
| 2285 |
mutationFn: () => |
| 2286 |
channelManagerApi.updateMapping(existing.id, { |
| 2287 |
external_product_id: externalProductId, |
| 2288 |
external_url: externalUrl, |
| 2289 |
price_offset_percent: offset, |
| 2290 |
is_active: isActive, |
| 2291 |
sync_inventory: syncInventory, |
| 2292 |
sync_pricing: syncPricing, |
| 2293 |
}), |
| 2294 |
onSuccess: () => { |
| 2295 |
queryClient.invalidateQueries({ queryKey: ["channel-manager-mappings"] }); |
| 2296 |
showToast(__("Mapping saved.", "yatra"), "success"); |
| 2297 |
onClose(); |
| 2298 |
}, |
| 2299 |
onError: (e: any) => showToast(extractError(e), "error"), |
| 2300 |
}); |
| 2301 |
|
| 2302 |
const sync = useMutation({ |
| 2303 |
mutationFn: () => channelManagerApi.syncMapping(existing.id), |
| 2304 |
onSuccess: (res) => |
| 2305 |
showToast( |
| 2306 |
res.ok |
| 2307 |
? __("Synced.", "yatra") |
| 2308 |
: res.error || __("Sync failed.", "yatra"), |
| 2309 |
res.ok ? "success" : "error", |
| 2310 |
), |
| 2311 |
onError: (e: any) => showToast(extractError(e), "error"), |
| 2312 |
}); |
| 2313 |
|
| 2314 |
return ( |
| 2315 |
<div className="space-y-6"> |
| 2316 |
<div className="flex items-start justify-between gap-4 flex-wrap"> |
| 2317 |
<div> |
| 2318 |
<h2 className="text-xl font-semibold text-gray-900 dark:text-white"> |
| 2319 |
{__("Edit mapping", "yatra")} |
| 2320 |
</h2> |
| 2321 |
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1"> |
| 2322 |
{__( |
| 2323 |
"Channel and trip are locked once a mapping exists. Delete + recreate if you need to re-point this mapping elsewhere.", |
| 2324 |
"yatra", |
| 2325 |
)} |
| 2326 |
</p> |
| 2327 |
</div> |
| 2328 |
<div className="flex gap-2 flex-wrap"> |
| 2329 |
<Button |
| 2330 |
variant="outline" |
| 2331 |
onClick={() => sync.mutate()} |
| 2332 |
disabled={sync.isPending} |
| 2333 |
> |
| 2334 |
{sync.isPending ? ( |
| 2335 |
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" /> |
| 2336 |
) : ( |
| 2337 |
<RefreshCw className="mr-1.5 h-4 w-4" /> |
| 2338 |
)} |
| 2339 |
{__("Sync now", "yatra")} |
| 2340 |
</Button> |
| 2341 |
<Button variant="outline" onClick={onClose}> |
| 2342 |
{__("Back to list", "yatra")} |
| 2343 |
</Button> |
| 2344 |
<Button onClick={() => save.mutate()} disabled={save.isPending}> |
| 2345 |
{save.isPending ? ( |
| 2346 |
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" /> |
| 2347 |
) : ( |
| 2348 |
<Save className="mr-1.5 h-4 w-4" /> |
| 2349 |
)} |
| 2350 |
{__("Save changes", "yatra")} |
| 2351 |
</Button> |
| 2352 |
</div> |
| 2353 |
</div> |
| 2354 |
|
| 2355 |
<Card> |
| 2356 |
<CardHeader> |
| 2357 |
<CardTitle>{__("Mapping link", "yatra")}</CardTitle> |
| 2358 |
<CardDescription> |
| 2359 |
{__( |
| 2360 |
"How this trip maps to the channel's product. Updating the external product ID re-points subsequent syncs but does not retroactively affect bookings already received.", |
| 2361 |
"yatra", |
| 2362 |
)} |
| 2363 |
</CardDescription> |
| 2364 |
</CardHeader> |
| 2365 |
<CardContent className="space-y-5"> |
| 2366 |
<div className="grid gap-5 md:grid-cols-2"> |
| 2367 |
<FormField |
| 2368 |
label={__("Channel", "yatra")} |
| 2369 |
description={__( |
| 2370 |
"Immutable. To move this trip to a different channel, delete this mapping and create a new one.", |
| 2371 |
"yatra", |
| 2372 |
)} |
| 2373 |
> |
| 2374 |
<Input |
| 2375 |
value={ |
| 2376 |
channel?.display_name || |
| 2377 |
channel?.channel_type || |
| 2378 |
`#${existing.channel_id}` |
| 2379 |
} |
| 2380 |
disabled |
| 2381 |
/> |
| 2382 |
</FormField> |
| 2383 |
|
| 2384 |
<FormField |
| 2385 |
label={__("Trip ID", "yatra")} |
| 2386 |
description={__("Immutable. One trip per channel.", "yatra")} |
| 2387 |
> |
| 2388 |
<Input value={`#${existing.trip_id}`} disabled /> |
| 2389 |
</FormField> |
| 2390 |
|
| 2391 |
<FormField |
| 2392 |
label={__("Channel product ID", "yatra")} |
| 2393 |
description={__( |
| 2394 |
"The product's ID on the channel side. Editing this re-points the mapping — all future syncs target the new product. Existing bookings already received are not modified.", |
| 2395 |
"yatra", |
| 2396 |
)} |
| 2397 |
> |
| 2398 |
<Input |
| 2399 |
value={externalProductId} |
| 2400 |
onChange={(e) => setExternalProductId(e.target.value)} |
| 2401 |
/> |
| 2402 |
</FormField> |
| 2403 |
|
| 2404 |
<FormField |
| 2405 |
label={__("Public listing URL (optional)", "yatra")} |
| 2406 |
description={__( |
| 2407 |
"Quick-jump link shown in the mappings table.", |
| 2408 |
"yatra", |
| 2409 |
)} |
| 2410 |
> |
| 2411 |
<Input |
| 2412 |
value={externalUrl} |
| 2413 |
onChange={(e) => setExternalUrl(e.target.value)} |
| 2414 |
/> |
| 2415 |
</FormField> |
| 2416 |
|
| 2417 |
<FormField |
| 2418 |
label={__("Per-mapping price offset (%)", "yatra")} |
| 2419 |
description={__( |
| 2420 |
"Overrides the channel's default offset for this trip only. 0 inherits the channel default.", |
| 2421 |
"yatra", |
| 2422 |
)} |
| 2423 |
> |
| 2424 |
<Input |
| 2425 |
type="number" |
| 2426 |
step="0.01" |
| 2427 |
value={offset} |
| 2428 |
onChange={(e) => setOffset(Number(e.target.value) || 0)} |
| 2429 |
/> |
| 2430 |
</FormField> |
| 2431 |
</div> |
| 2432 |
</CardContent> |
| 2433 |
</Card> |
| 2434 |
|
| 2435 |
<Card> |
| 2436 |
<CardHeader> |
| 2437 |
<CardTitle>{__("Sync controls", "yatra")}</CardTitle> |
| 2438 |
<CardDescription> |
| 2439 |
{__( |
| 2440 |
"Pause or partially-sync this mapping without losing its configuration. Pausing keeps all settings; flipping it back resumes from where it left off.", |
| 2441 |
"yatra", |
| 2442 |
)} |
| 2443 |
</CardDescription> |
| 2444 |
</CardHeader> |
| 2445 |
<CardContent className="space-y-4"> |
| 2446 |
<ToggleRow |
| 2447 |
label={__("Active", "yatra")} |
| 2448 |
description={__( |
| 2449 |
"When off, this mapping won't push inventory or pricing, and bookings received for it are still ingested (so cancellations of pre-existing bookings keep working).", |
| 2450 |
"yatra", |
| 2451 |
)} |
| 2452 |
checked={isActive} |
| 2453 |
onChange={setIsActive} |
| 2454 |
/> |
| 2455 |
<ToggleRow |
| 2456 |
label={__("Sync inventory", "yatra")} |
| 2457 |
description={__( |
| 2458 |
"Push seat availability to this channel. Turn off when the channel manages its own inventory.", |
| 2459 |
"yatra", |
| 2460 |
)} |
| 2461 |
checked={syncInventory} |
| 2462 |
onChange={setSyncInventory} |
| 2463 |
/> |
| 2464 |
<ToggleRow |
| 2465 |
label={__("Sync pricing", "yatra")} |
| 2466 |
description={__( |
| 2467 |
"Push prices to this channel. Turn off for channels that run their own promotions.", |
| 2468 |
"yatra", |
| 2469 |
)} |
| 2470 |
checked={syncPricing} |
| 2471 |
onChange={setSyncPricing} |
| 2472 |
/> |
| 2473 |
</CardContent> |
| 2474 |
</Card> |
| 2475 |
</div> |
| 2476 |
); |
| 2477 |
}; |
| 2478 |
|
| 2479 |
/* -------------------------------------------------------------------------- */ |
| 2480 |
/* Bookings tab */ |
| 2481 |
/* -------------------------------------------------------------------------- */ |
| 2482 |
|
| 2483 |
const BookingsSection: React.FC = () => { |
| 2484 |
const { data, isLoading } = useQuery({ |
| 2485 |
queryKey: ["channel-manager-bookings"], |
| 2486 |
queryFn: () => channelManagerApi.listBookings({ per_page: 100 }), |
| 2487 |
refetchInterval: 30000, |
| 2488 |
}); |
| 2489 |
|
| 2490 |
if (isLoading) { |
| 2491 |
return ( |
| 2492 |
<div className="flex justify-center py-12"> |
| 2493 |
<Loader2 className="h-6 w-6 animate-spin text-gray-400" /> |
| 2494 |
</div> |
| 2495 |
); |
| 2496 |
} |
| 2497 |
const rows = data?.data ?? []; |
| 2498 |
|
| 2499 |
return ( |
| 2500 |
<div className="space-y-4"> |
| 2501 |
<div> |
| 2502 |
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2"> |
| 2503 |
<Inbox className="h-5 w-5 text-blue-500" /> |
| 2504 |
{__("Bookings inbox", "yatra")} |
| 2505 |
</h3> |
| 2506 |
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1 max-w-3xl"> |
| 2507 |
{__( |
| 2508 |
"Every booking received from connected OTAs lands here. The flow is two-phase for safety: bookings are first staged (received), then promoted into the main booking list. Promoted bookings go through the same notifications, payments and reporting as direct sales.", |
| 2509 |
"yatra", |
| 2510 |
)} |
| 2511 |
</p> |
| 2512 |
<div className="mt-3 flex flex-wrap gap-2 text-xs"> |
| 2513 |
<StatusLegend |
| 2514 |
color="blue" |
| 2515 |
label={__("received", "yatra")} |
| 2516 |
description={__("staged — not yet a real booking", "yatra")} |
| 2517 |
/> |
| 2518 |
<StatusLegend |
| 2519 |
color="green" |
| 2520 |
label={__("promoted", "yatra")} |
| 2521 |
description={__("live booking, visible everywhere", "yatra")} |
| 2522 |
/> |
| 2523 |
<StatusLegend |
| 2524 |
color="red" |
| 2525 |
label={__("failed", "yatra")} |
| 2526 |
description={__( |
| 2527 |
"promotion failed — check the error column", |
| 2528 |
"yatra", |
| 2529 |
)} |
| 2530 |
/> |
| 2531 |
</div> |
| 2532 |
</div> |
| 2533 |
<Card> |
| 2534 |
<CardContent className="p-0"> |
| 2535 |
{rows.length === 0 ? ( |
| 2536 |
<div className="p-10 text-center"> |
| 2537 |
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30"> |
| 2538 |
<Inbox className="h-7 w-7 text-blue-600 dark:text-blue-400" /> |
| 2539 |
</div> |
| 2540 |
<h4 className="text-base font-semibold text-gray-900 dark:text-white"> |
| 2541 |
{__("No channel bookings yet", "yatra")} |
| 2542 |
</h4> |
| 2543 |
<p className="mx-auto mt-1 max-w-md text-sm text-gray-500 dark:text-gray-400"> |
| 2544 |
{__( |
| 2545 |
"When an OTA sends a booking webhook, it'll appear here within seconds. The list auto-refreshes every 30 seconds while this tab is open.", |
| 2546 |
"yatra", |
| 2547 |
)} |
| 2548 |
</p> |
| 2549 |
</div> |
| 2550 |
) : ( |
| 2551 |
<div className="overflow-x-auto"> |
| 2552 |
<table className="w-full"> |
| 2553 |
<thead className="bg-gray-50 dark:bg-gray-800"> |
| 2554 |
<tr> |
| 2555 |
<Th>{__("Channel ref", "yatra")}</Th> |
| 2556 |
<Th>{__("Customer", "yatra")}</Th> |
| 2557 |
<Th>{__("Departure", "yatra")}</Th> |
| 2558 |
<Th>{__("Travelers", "yatra")}</Th> |
| 2559 |
<Th>{__("Amount", "yatra")}</Th> |
| 2560 |
<Th>{__("Status", "yatra")}</Th> |
| 2561 |
<Th>{__("Booking ID", "yatra")}</Th> |
| 2562 |
<Th>{__("Received", "yatra")}</Th> |
| 2563 |
</tr> |
| 2564 |
</thead> |
| 2565 |
<tbody className="divide-y divide-gray-200 dark:divide-gray-700"> |
| 2566 |
{rows.map((b) => ( |
| 2567 |
<tr |
| 2568 |
key={b.id} |
| 2569 |
className="hover:bg-gray-50 dark:hover:bg-gray-800/50" |
| 2570 |
> |
| 2571 |
<td className="px-4 py-3 text-xs font-mono"> |
| 2572 |
{b.external_booking_id} |
| 2573 |
</td> |
| 2574 |
<td className="px-4 py-3 text-sm"> |
| 2575 |
<div className="text-gray-900 dark:text-white"> |
| 2576 |
{b.customer_name || "—"} |
| 2577 |
</div> |
| 2578 |
<div className="text-xs text-gray-500 dark:text-gray-400"> |
| 2579 |
{b.customer_email} |
| 2580 |
</div> |
| 2581 |
</td> |
| 2582 |
<td className="px-4 py-3 text-sm text-gray-700 dark:text-gray-200"> |
| 2583 |
{b.departure_date || "—"} |
| 2584 |
</td> |
| 2585 |
<td className="px-4 py-3 text-sm">{b.travelers_total}</td> |
| 2586 |
<td className="px-4 py-3 text-sm"> |
| 2587 |
{b.currency} {Number(b.total_amount).toFixed(2)} |
| 2588 |
</td> |
| 2589 |
<td className="px-4 py-3"> |
| 2590 |
<Badge |
| 2591 |
className={ |
| 2592 |
b.processing_status === "promoted" |
| 2593 |
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" |
| 2594 |
: b.processing_status === "failed" |
| 2595 |
? "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400" |
| 2596 |
: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400" |
| 2597 |
} |
| 2598 |
> |
| 2599 |
{b.processing_status} |
| 2600 |
</Badge> |
| 2601 |
{b.error_message && ( |
| 2602 |
<div className="mt-1 text-xs text-red-600 dark:text-red-400 max-w-xs truncate"> |
| 2603 |
{b.error_message} |
| 2604 |
</div> |
| 2605 |
)} |
| 2606 |
</td> |
| 2607 |
<td className="px-4 py-3 text-xs"> |
| 2608 |
{b.yatra_booking_id ? `#${b.yatra_booking_id}` : "—"} |
| 2609 |
</td> |
| 2610 |
<td className="px-4 py-3 text-xs text-gray-500 dark:text-gray-400"> |
| 2611 |
{new Date(b.received_at).toLocaleString()} |
| 2612 |
</td> |
| 2613 |
</tr> |
| 2614 |
))} |
| 2615 |
</tbody> |
| 2616 |
</table> |
| 2617 |
</div> |
| 2618 |
)} |
| 2619 |
</CardContent> |
| 2620 |
</Card> |
| 2621 |
</div> |
| 2622 |
); |
| 2623 |
}; |
| 2624 |
|
| 2625 |
/* -------------------------------------------------------------------------- */ |
| 2626 |
/* Reconciliation tab */ |
| 2627 |
/* */ |
| 2628 |
/* Cross-channel "where do I have unfinished business?" view. Pre-aggregated */ |
| 2629 |
/* on the server so this is one HTTP call, not channel-by-channel polling. */ |
| 2630 |
/* Drives the "needs_attention" badges on each channel card. */ |
| 2631 |
/* -------------------------------------------------------------------------- */ |
| 2632 |
|
| 2633 |
const ReconciliationSection: React.FC = () => { |
| 2634 |
const { data, isLoading, refetch, isFetching } = useQuery({ |
| 2635 |
queryKey: ["cm-reconciliation"], |
| 2636 |
queryFn: () => channelManagerApi.getReconciliation(), |
| 2637 |
refetchOnWindowFocus: false, |
| 2638 |
}); |
| 2639 |
|
| 2640 |
if (isLoading) { |
| 2641 |
return ( |
| 2642 |
<div className="flex justify-center py-12"> |
| 2643 |
<Loader2 className="h-6 w-6 animate-spin text-gray-400" /> |
| 2644 |
</div> |
| 2645 |
); |
| 2646 |
} |
| 2647 |
const report = data?.data; |
| 2648 |
if (!report) return null; |
| 2649 |
const t = report.totals; |
| 2650 |
|
| 2651 |
return ( |
| 2652 |
<div className="space-y-6"> |
| 2653 |
<div className="flex flex-wrap items-start justify-between gap-3"> |
| 2654 |
<div> |
| 2655 |
<h3 className="text-lg font-semibold text-gray-900 dark:text-white"> |
| 2656 |
{__("Reconciliation report", "yatra")} |
| 2657 |
</h3> |
| 2658 |
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1"> |
| 2659 |
{__( |
| 2660 |
"Generated on demand from the sync log + mapping + booking tables. Refresh to recompute.", |
| 2661 |
"yatra", |
| 2662 |
)} |
| 2663 |
</p> |
| 2664 |
<p className="text-xs text-gray-400 mt-0.5"> |
| 2665 |
{sprintf( |
| 2666 |
/* translators: %s: timestamp */ |
| 2667 |
__("Generated at %s", "yatra"), |
| 2668 |
report.generated_at, |
| 2669 |
)} |
| 2670 |
</p> |
| 2671 |
</div> |
| 2672 |
<Button |
| 2673 |
variant="outline" |
| 2674 |
onClick={() => refetch()} |
| 2675 |
disabled={isFetching} |
| 2676 |
> |
| 2677 |
<RefreshCw |
| 2678 |
className={`h-4 w-4 mr-1 ${isFetching ? "animate-spin" : ""}`} |
| 2679 |
/> |
| 2680 |
{__("Refresh", "yatra")} |
| 2681 |
</Button> |
| 2682 |
</div> |
| 2683 |
|
| 2684 |
{/* Global totals */} |
| 2685 |
<div className="grid grid-cols-2 md:grid-cols-4 gap-3"> |
| 2686 |
<KpiCard |
| 2687 |
label={__("Channels enabled", "yatra")} |
| 2688 |
value={`${t.channels.enabled} / ${t.channels.total}`} |
| 2689 |
icon={<Network className="h-4 w-4 text-blue-500" />} |
| 2690 |
/> |
| 2691 |
<KpiCard |
| 2692 |
label={__("Active mappings", "yatra")} |
| 2693 |
value={t.mappings_active} |
| 2694 |
icon={<LinkIcon className="h-4 w-4 text-indigo-500" />} |
| 2695 |
/> |
| 2696 |
<KpiCard |
| 2697 |
label={__("Stale mappings", "yatra")} |
| 2698 |
value={t.inventory_stale} |
| 2699 |
icon={<AlertTriangle className="h-4 w-4 text-amber-500" />} |
| 2700 |
tone={t.inventory_stale > 0 ? "warn" : "ok"} |
| 2701 |
/> |
| 2702 |
<KpiCard |
| 2703 |
label={__("Open breakers", "yatra")} |
| 2704 |
value={t.breakers_open} |
| 2705 |
icon={<PowerOff className="h-4 w-4 text-red-500" />} |
| 2706 |
tone={t.breakers_open > 0 ? "bad" : "ok"} |
| 2707 |
/> |
| 2708 |
<KpiCard |
| 2709 |
label={__("Pending booking promotions", "yatra")} |
| 2710 |
value={t.bookings_pending} |
| 2711 |
icon={<Inbox className="h-4 w-4 text-purple-500" />} |
| 2712 |
tone={t.bookings_pending > 0 ? "warn" : "ok"} |
| 2713 |
/> |
| 2714 |
<KpiCard |
| 2715 |
label={__("Failed booking promotions (7d)", "yatra")} |
| 2716 |
value={t.bookings_failed_7d} |
| 2717 |
icon={<AlertTriangle className="h-4 w-4 text-red-500" />} |
| 2718 |
tone={t.bookings_failed_7d > 0 ? "bad" : "ok"} |
| 2719 |
/> |
| 2720 |
<KpiCard |
| 2721 |
label={__("Syncs OK (24h)", "yatra")} |
| 2722 |
value={t.syncs_24h.success} |
| 2723 |
icon={<CheckCircle2 className="h-4 w-4 text-green-500" />} |
| 2724 |
/> |
| 2725 |
<KpiCard |
| 2726 |
label={__("Syncs failed (24h)", "yatra")} |
| 2727 |
value={t.syncs_24h.failed} |
| 2728 |
icon={<AlertTriangle className="h-4 w-4 text-amber-500" />} |
| 2729 |
tone={t.syncs_24h.failed > 0 ? "warn" : "ok"} |
| 2730 |
/> |
| 2731 |
</div> |
| 2732 |
|
| 2733 |
{/* Per-channel breakdown */} |
| 2734 |
<Card> |
| 2735 |
<CardHeader> |
| 2736 |
<CardTitle className="text-base"> |
| 2737 |
{__("Per channel", "yatra")} |
| 2738 |
</CardTitle> |
| 2739 |
<CardDescription> |
| 2740 |
{__( |
| 2741 |
"Channels marked “Needs attention” have stale mappings, pending bookings, recent failures, or a tripped breaker.", |
| 2742 |
"yatra", |
| 2743 |
)} |
| 2744 |
</CardDescription> |
| 2745 |
</CardHeader> |
| 2746 |
<CardContent className="p-0"> |
| 2747 |
<div className="overflow-x-auto"> |
| 2748 |
<table className="min-w-full text-sm"> |
| 2749 |
<thead className="bg-gray-50 dark:bg-gray-800/40 border-b border-gray-200 dark:border-gray-700"> |
| 2750 |
<tr> |
| 2751 |
<th className="px-3 py-2 text-left font-medium text-gray-700 dark:text-gray-300"> |
| 2752 |
{__("Channel", "yatra")} |
| 2753 |
</th> |
| 2754 |
<th className="px-3 py-2 text-left font-medium text-gray-700 dark:text-gray-300"> |
| 2755 |
{__("Mappings", "yatra")} |
| 2756 |
</th> |
| 2757 |
<th className="px-3 py-2 text-left font-medium text-gray-700 dark:text-gray-300"> |
| 2758 |
{__("Bookings", "yatra")} |
| 2759 |
</th> |
| 2760 |
<th className="px-3 py-2 text-left font-medium text-gray-700 dark:text-gray-300"> |
| 2761 |
{__("Syncs (24h)", "yatra")} |
| 2762 |
</th> |
| 2763 |
<th className="px-3 py-2 text-left font-medium text-gray-700 dark:text-gray-300"> |
| 2764 |
{__("Breaker", "yatra")} |
| 2765 |
</th> |
| 2766 |
<th className="px-3 py-2 text-left font-medium text-gray-700 dark:text-gray-300"> |
| 2767 |
{__("Latest failure", "yatra")} |
| 2768 |
</th> |
| 2769 |
<th className="px-3 py-2 text-left font-medium text-gray-700 dark:text-gray-300"> |
| 2770 |
{__("Status", "yatra")} |
| 2771 |
</th> |
| 2772 |
</tr> |
| 2773 |
</thead> |
| 2774 |
<tbody className="divide-y divide-gray-200 dark:divide-gray-700"> |
| 2775 |
{report.channels.map((c) => ( |
| 2776 |
<tr |
| 2777 |
key={c.channel.id} |
| 2778 |
className={ |
| 2779 |
c.needs_attention |
| 2780 |
? "bg-amber-50/40 dark:bg-amber-900/10" |
| 2781 |
: "" |
| 2782 |
} |
| 2783 |
> |
| 2784 |
<td className="px-3 py-2"> |
| 2785 |
<div className="font-medium text-gray-900 dark:text-white"> |
| 2786 |
{c.channel.display_name || `#${c.channel.id}`} |
| 2787 |
</div> |
| 2788 |
<div className="text-xs text-gray-500 font-mono"> |
| 2789 |
{c.channel.type} |
| 2790 |
</div> |
| 2791 |
</td> |
| 2792 |
<td className="px-3 py-2 text-xs"> |
| 2793 |
{sprintf(__("%d active", "yatra"), c.mappings.active)} |
| 2794 |
{c.mappings.stale > 0 && ( |
| 2795 |
<div className="text-amber-600 dark:text-amber-400 mt-0.5"> |
| 2796 |
{sprintf(__("%d stale", "yatra"), c.mappings.stale)} |
| 2797 |
</div> |
| 2798 |
)} |
| 2799 |
</td> |
| 2800 |
<td className="px-3 py-2 text-xs"> |
| 2801 |
{c.bookings.pending > 0 ? ( |
| 2802 |
<div className="text-amber-600 dark:text-amber-400"> |
| 2803 |
{sprintf( |
| 2804 |
__("%d pending", "yatra"), |
| 2805 |
c.bookings.pending, |
| 2806 |
)} |
| 2807 |
</div> |
| 2808 |
) : ( |
| 2809 |
<span className="text-gray-400">—</span> |
| 2810 |
)} |
| 2811 |
{c.bookings.failed_7d > 0 && ( |
| 2812 |
<div className="text-red-600 dark:text-red-400"> |
| 2813 |
{sprintf( |
| 2814 |
__("%d failed (7d)", "yatra"), |
| 2815 |
c.bookings.failed_7d, |
| 2816 |
)} |
| 2817 |
</div> |
| 2818 |
)} |
| 2819 |
</td> |
| 2820 |
<td className="px-3 py-2 text-xs"> |
| 2821 |
<span className="text-green-700 dark:text-green-400"> |
| 2822 |
{c.syncs.success_24h} OK |
| 2823 |
</span> |
| 2824 |
{c.syncs.failed_24h > 0 && ( |
| 2825 |
<> |
| 2826 |
{" / "} |
| 2827 |
<span className="text-red-700 dark:text-red-400"> |
| 2828 |
{c.syncs.failed_24h} fail |
| 2829 |
</span> |
| 2830 |
</> |
| 2831 |
)} |
| 2832 |
</td> |
| 2833 |
<td className="px-3 py-2 text-xs"> |
| 2834 |
<Badge |
| 2835 |
className={ |
| 2836 |
c.breaker.state === "closed" |
| 2837 |
? "bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-300" |
| 2838 |
: "bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-300" |
| 2839 |
} |
| 2840 |
> |
| 2841 |
{c.breaker.state} |
| 2842 |
</Badge> |
| 2843 |
</td> |
| 2844 |
<td className="px-3 py-2 text-xs text-gray-600 dark:text-gray-300 max-w-sm"> |
| 2845 |
{c.latest_failure ? ( |
| 2846 |
<> |
| 2847 |
<div className="font-mono"> |
| 2848 |
{c.latest_failure.operation} |
| 2849 |
{c.latest_failure.http_status !== null |
| 2850 |
? ` (${c.latest_failure.http_status})` |
| 2851 |
: ""} |
| 2852 |
</div> |
| 2853 |
<div className="text-gray-500 truncate"> |
| 2854 |
{c.latest_failure.error_message} |
| 2855 |
</div> |
| 2856 |
<div className="text-gray-400 text-[10px] mt-0.5"> |
| 2857 |
{c.latest_failure.occurred_at} |
| 2858 |
</div> |
| 2859 |
</> |
| 2860 |
) : ( |
| 2861 |
<span className="text-gray-400">—</span> |
| 2862 |
)} |
| 2863 |
</td> |
| 2864 |
<td className="px-3 py-2"> |
| 2865 |
{c.needs_attention ? ( |
| 2866 |
<Badge className="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"> |
| 2867 |
<AlertTriangle className="h-3 w-3 mr-1" /> |
| 2868 |
{__("Needs attention", "yatra")} |
| 2869 |
</Badge> |
| 2870 |
) : ( |
| 2871 |
<Badge className="bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-300"> |
| 2872 |
<CheckCircle2 className="h-3 w-3 mr-1" /> |
| 2873 |
{__("Healthy", "yatra")} |
| 2874 |
</Badge> |
| 2875 |
)} |
| 2876 |
</td> |
| 2877 |
</tr> |
| 2878 |
))} |
| 2879 |
</tbody> |
| 2880 |
</table> |
| 2881 |
</div> |
| 2882 |
</CardContent> |
| 2883 |
</Card> |
| 2884 |
</div> |
| 2885 |
); |
| 2886 |
}; |
| 2887 |
|
| 2888 |
const KpiCard: React.FC<{ |
| 2889 |
label: string; |
| 2890 |
value: string | number; |
| 2891 |
icon: React.ReactNode; |
| 2892 |
tone?: "ok" | "warn" | "bad"; |
| 2893 |
}> = ({ label, value, icon, tone = "ok" }) => { |
| 2894 |
const ring = |
| 2895 |
tone === "bad" |
| 2896 |
? "border-red-200 dark:border-red-800" |
| 2897 |
: tone === "warn" |
| 2898 |
? "border-amber-200 dark:border-amber-800" |
| 2899 |
: "border-gray-200 dark:border-gray-700"; |
| 2900 |
return ( |
| 2901 |
<div className={`rounded-md border ${ring} bg-white dark:bg-gray-900 p-3`}> |
| 2902 |
<div className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400"> |
| 2903 |
{icon} |
| 2904 |
{label} |
| 2905 |
</div> |
| 2906 |
<div className="mt-1 text-2xl font-semibold text-gray-900 dark:text-white"> |
| 2907 |
{value} |
| 2908 |
</div> |
| 2909 |
</div> |
| 2910 |
); |
| 2911 |
}; |
| 2912 |
|
| 2913 |
/* -------------------------------------------------------------------------- */ |
| 2914 |
/* Latency tab — avg / p50 / p95 / p99 across windows */ |
| 2915 |
/* -------------------------------------------------------------------------- */ |
| 2916 |
|
| 2917 |
const LatencySection: React.FC = () => { |
| 2918 |
const { data, isLoading, refetch, isFetching } = useQuery({ |
| 2919 |
queryKey: ["cm-latency"], |
| 2920 |
queryFn: () => channelManagerApi.getLatency(), |
| 2921 |
refetchOnWindowFocus: false, |
| 2922 |
}); |
| 2923 |
|
| 2924 |
if (isLoading) { |
| 2925 |
return ( |
| 2926 |
<div className="flex justify-center py-12"> |
| 2927 |
<Loader2 className="h-6 w-6 animate-spin text-gray-400" /> |
| 2928 |
</div> |
| 2929 |
); |
| 2930 |
} |
| 2931 |
const report = data?.data; |
| 2932 |
if (!report) return null; |
| 2933 |
|
| 2934 |
return ( |
| 2935 |
<div className="space-y-6"> |
| 2936 |
<div className="flex flex-wrap items-start justify-between gap-3"> |
| 2937 |
<div> |
| 2938 |
<h3 className="text-lg font-semibold text-gray-900 dark:text-white"> |
| 2939 |
{__("Sync latency", "yatra")} |
| 2940 |
</h3> |
| 2941 |
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1"> |
| 2942 |
{__( |
| 2943 |
"Percentiles over the duration_ms column of the sync log. Nearest-rank percentiles capped at ", |
| 2944 |
"yatra", |
| 2945 |
)} |
| 2946 |
{report.sample_cap}{" "} |
| 2947 |
{__("recent samples per channel per window.", "yatra")} |
| 2948 |
</p> |
| 2949 |
</div> |
| 2950 |
<Button |
| 2951 |
variant="outline" |
| 2952 |
onClick={() => refetch()} |
| 2953 |
disabled={isFetching} |
| 2954 |
> |
| 2955 |
<RefreshCw |
| 2956 |
className={`h-4 w-4 mr-1 ${isFetching ? "animate-spin" : ""}`} |
| 2957 |
/> |
| 2958 |
{__("Refresh", "yatra")} |
| 2959 |
</Button> |
| 2960 |
</div> |
| 2961 |
|
| 2962 |
<LatencyTable |
| 2963 |
title={__("Global", "yatra")} |
| 2964 |
rows={[ |
| 2965 |
{ |
| 2966 |
label: __("Last 24h", "yatra"), |
| 2967 |
window: report.global.last_24h, |
| 2968 |
}, |
| 2969 |
{ |
| 2970 |
label: __("Last 7d", "yatra"), |
| 2971 |
window: report.global.last_7d, |
| 2972 |
}, |
| 2973 |
]} |
| 2974 |
/> |
| 2975 |
|
| 2976 |
<LatencyTable |
| 2977 |
title={__("Per provider", "yatra")} |
| 2978 |
rows={report.per_provider.flatMap((p) => [ |
| 2979 |
{ |
| 2980 |
label: `${p.provider} · ${__("24h", "yatra")}`, |
| 2981 |
window: p.last_24h, |
| 2982 |
}, |
| 2983 |
{ label: `${p.provider} · ${__("7d", "yatra")}`, window: p.last_7d }, |
| 2984 |
])} |
| 2985 |
/> |
| 2986 |
|
| 2987 |
<LatencyTable |
| 2988 |
title={__("Per channel", "yatra")} |
| 2989 |
rows={report.per_channel.flatMap((c) => [ |
| 2990 |
{ |
| 2991 |
label: `${c.channel.display_name || `#${c.channel.id}`} · ${__("24h", "yatra")}`, |
| 2992 |
window: c.last_24h, |
| 2993 |
}, |
| 2994 |
{ |
| 2995 |
label: `${c.channel.display_name || `#${c.channel.id}`} · ${__("7d", "yatra")}`, |
| 2996 |
window: c.last_7d, |
| 2997 |
}, |
| 2998 |
])} |
| 2999 |
/> |
| 3000 |
</div> |
| 3001 |
); |
| 3002 |
}; |
| 3003 |
|
| 3004 |
const LatencyTable: React.FC<{ |
| 3005 |
title: string; |
| 3006 |
rows: Array<{ |
| 3007 |
label: string; |
| 3008 |
window: { |
| 3009 |
samples: number; |
| 3010 |
avg_ms: number | null; |
| 3011 |
p50_ms: number | null; |
| 3012 |
p95_ms: number | null; |
| 3013 |
p99_ms: number | null; |
| 3014 |
success_count: number; |
| 3015 |
fail_count: number; |
| 3016 |
}; |
| 3017 |
}>; |
| 3018 |
}> = ({ title, rows }) => { |
| 3019 |
if (rows.length === 0) return null; |
| 3020 |
return ( |
| 3021 |
<Card> |
| 3022 |
<CardHeader> |
| 3023 |
<CardTitle className="text-base">{title}</CardTitle> |
| 3024 |
</CardHeader> |
| 3025 |
<CardContent className="p-0"> |
| 3026 |
<div className="overflow-x-auto"> |
| 3027 |
<table className="min-w-full text-sm"> |
| 3028 |
<thead className="bg-gray-50 dark:bg-gray-800/40 border-b border-gray-200 dark:border-gray-700"> |
| 3029 |
<tr> |
| 3030 |
<th className="px-3 py-2 text-left font-medium text-gray-700 dark:text-gray-300"> |
| 3031 |
{__("Scope", "yatra")} |
| 3032 |
</th> |
| 3033 |
<th className="px-3 py-2 text-right font-medium text-gray-700 dark:text-gray-300"> |
| 3034 |
{__("Samples", "yatra")} |
| 3035 |
</th> |
| 3036 |
<th className="px-3 py-2 text-right font-medium text-gray-700 dark:text-gray-300"> |
| 3037 |
{__("Avg", "yatra")} |
| 3038 |
</th> |
| 3039 |
<th className="px-3 py-2 text-right font-medium text-gray-700 dark:text-gray-300"> |
| 3040 |
p50 |
| 3041 |
</th> |
| 3042 |
<th className="px-3 py-2 text-right font-medium text-gray-700 dark:text-gray-300"> |
| 3043 |
p95 |
| 3044 |
</th> |
| 3045 |
<th className="px-3 py-2 text-right font-medium text-gray-700 dark:text-gray-300"> |
| 3046 |
p99 |
| 3047 |
</th> |
| 3048 |
<th className="px-3 py-2 text-right font-medium text-gray-700 dark:text-gray-300"> |
| 3049 |
{__("OK / Fail", "yatra")} |
| 3050 |
</th> |
| 3051 |
</tr> |
| 3052 |
</thead> |
| 3053 |
<tbody className="divide-y divide-gray-200 dark:divide-gray-700"> |
| 3054 |
{rows.map((r, idx) => ( |
| 3055 |
<tr key={`${r.label}-${idx}`}> |
| 3056 |
<td className="px-3 py-2 text-gray-900 dark:text-white"> |
| 3057 |
{r.label} |
| 3058 |
</td> |
| 3059 |
<td className="px-3 py-2 text-right tabular-nums"> |
| 3060 |
{r.window.samples} |
| 3061 |
</td> |
| 3062 |
<td className="px-3 py-2 text-right tabular-nums"> |
| 3063 |
{fmtMs(r.window.avg_ms)} |
| 3064 |
</td> |
| 3065 |
<td className="px-3 py-2 text-right tabular-nums"> |
| 3066 |
{fmtMs(r.window.p50_ms)} |
| 3067 |
</td> |
| 3068 |
<td className="px-3 py-2 text-right tabular-nums"> |
| 3069 |
{fmtMs(r.window.p95_ms)} |
| 3070 |
</td> |
| 3071 |
<td className="px-3 py-2 text-right tabular-nums"> |
| 3072 |
{fmtMs(r.window.p99_ms)} |
| 3073 |
</td> |
| 3074 |
<td className="px-3 py-2 text-right text-xs"> |
| 3075 |
<span className="text-green-700 dark:text-green-400"> |
| 3076 |
{r.window.success_count} |
| 3077 |
</span> |
| 3078 |
{" / "} |
| 3079 |
<span className="text-red-700 dark:text-red-400"> |
| 3080 |
{r.window.fail_count} |
| 3081 |
</span> |
| 3082 |
</td> |
| 3083 |
</tr> |
| 3084 |
))} |
| 3085 |
</tbody> |
| 3086 |
</table> |
| 3087 |
</div> |
| 3088 |
</CardContent> |
| 3089 |
</Card> |
| 3090 |
); |
| 3091 |
}; |
| 3092 |
|
| 3093 |
function fmtMs(v: number | null): string { |
| 3094 |
if (v === null || v === undefined) return "—"; |
| 3095 |
if (v >= 1000) return `${(v / 1000).toFixed(2)}s`; |
| 3096 |
return `${v} ms`; |
| 3097 |
} |
| 3098 |
|
| 3099 |
/* -------------------------------------------------------------------------- */ |
| 3100 |
/* Logs tab */ |
| 3101 |
/* -------------------------------------------------------------------------- */ |
| 3102 |
|
| 3103 |
const LogsSection: React.FC = () => { |
| 3104 |
const { data, isLoading } = useQuery({ |
| 3105 |
queryKey: ["channel-manager-logs"], |
| 3106 |
queryFn: () => channelManagerApi.listLogs({ per_page: 200 }), |
| 3107 |
refetchInterval: 15000, |
| 3108 |
}); |
| 3109 |
|
| 3110 |
if (isLoading) { |
| 3111 |
return ( |
| 3112 |
<div className="flex justify-center py-12"> |
| 3113 |
<Loader2 className="h-6 w-6 animate-spin text-gray-400" /> |
| 3114 |
</div> |
| 3115 |
); |
| 3116 |
} |
| 3117 |
const rows = data?.data ?? []; |
| 3118 |
|
| 3119 |
return ( |
| 3120 |
<div className="space-y-4"> |
| 3121 |
<div> |
| 3122 |
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2"> |
| 3123 |
<Clock className="h-5 w-5 text-blue-500" /> |
| 3124 |
{__("Sync activity", "yatra")} |
| 3125 |
</h3> |
| 3126 |
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1 max-w-3xl"> |
| 3127 |
{__( |
| 3128 |
"Every push (you → channel), pull (channel → you), and inbound webhook from the last 90 days. Filter by failed status when debugging a sync; the error column carries the exact response from the OTA's API.", |
| 3129 |
"yatra", |
| 3130 |
)} |
| 3131 |
</p> |
| 3132 |
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2 flex items-start gap-1.5"> |
| 3133 |
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0" /> |
| 3134 |
<span> |
| 3135 |
{__( |
| 3136 |
"Auto-refreshes every 15 seconds while open. Rows older than 90 days are purged automatically by a daily cron.", |
| 3137 |
"yatra", |
| 3138 |
)} |
| 3139 |
</span> |
| 3140 |
</p> |
| 3141 |
</div> |
| 3142 |
<Card> |
| 3143 |
<CardContent className="p-0"> |
| 3144 |
{rows.length === 0 ? ( |
| 3145 |
<div className="p-8 text-center text-gray-500 dark:text-gray-400"> |
| 3146 |
{__( |
| 3147 |
"No activity yet — enable a channel and create a mapping to start seeing events here.", |
| 3148 |
"yatra", |
| 3149 |
)} |
| 3150 |
</div> |
| 3151 |
) : ( |
| 3152 |
<div className="overflow-x-auto"> |
| 3153 |
<table className="w-full"> |
| 3154 |
<thead className="bg-gray-50 dark:bg-gray-800"> |
| 3155 |
<tr> |
| 3156 |
<Th>{__("When", "yatra")}</Th> |
| 3157 |
<Th>{__("Channel", "yatra")}</Th> |
| 3158 |
<Th>{__("Operation", "yatra")}</Th> |
| 3159 |
<Th>{__("Status", "yatra")}</Th> |
| 3160 |
<Th>{__("Duration", "yatra")}</Th> |
| 3161 |
<Th>{__("Summary / Error", "yatra")}</Th> |
| 3162 |
</tr> |
| 3163 |
</thead> |
| 3164 |
<tbody className="divide-y divide-gray-200 dark:divide-gray-700"> |
| 3165 |
{rows.map((row) => ( |
| 3166 |
<tr |
| 3167 |
key={row.id} |
| 3168 |
className="hover:bg-gray-50 dark:hover:bg-gray-800/50" |
| 3169 |
> |
| 3170 |
<td className="px-4 py-3 text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap"> |
| 3171 |
{new Date(row.created_at).toLocaleString()} |
| 3172 |
</td> |
| 3173 |
<td className="px-4 py-3 text-sm"> |
| 3174 |
{row.channel_id ? `#${row.channel_id}` : "—"} |
| 3175 |
</td> |
| 3176 |
<td className="px-4 py-3"> |
| 3177 |
<Badge className="bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400"> |
| 3178 |
{row.direction}/{row.operation} |
| 3179 |
</Badge> |
| 3180 |
</td> |
| 3181 |
<td className="px-4 py-3"> |
| 3182 |
<Badge |
| 3183 |
className={ |
| 3184 |
row.status === "success" |
| 3185 |
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" |
| 3186 |
: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400" |
| 3187 |
} |
| 3188 |
> |
| 3189 |
{row.status} |
| 3190 |
</Badge> |
| 3191 |
</td> |
| 3192 |
<td className="px-4 py-3 text-xs text-gray-500 dark:text-gray-400"> |
| 3193 |
{row.duration_ms != null ? `${row.duration_ms}ms` : "—"} |
| 3194 |
</td> |
| 3195 |
<td className="px-4 py-3 text-xs text-gray-700 dark:text-gray-200 max-w-md"> |
| 3196 |
<div className="truncate"> |
| 3197 |
{row.error_message || row.summary || "—"} |
| 3198 |
</div> |
| 3199 |
{row.http_status != null && ( |
| 3200 |
<div className="mt-0.5 text-[10px] text-gray-400"> |
| 3201 |
HTTP {row.http_status} |
| 3202 |
</div> |
| 3203 |
)} |
| 3204 |
</td> |
| 3205 |
</tr> |
| 3206 |
))} |
| 3207 |
</tbody> |
| 3208 |
</table> |
| 3209 |
</div> |
| 3210 |
)} |
| 3211 |
</CardContent> |
| 3212 |
</Card> |
| 3213 |
</div> |
| 3214 |
); |
| 3215 |
}; |
| 3216 |
|
| 3217 |
/* -------------------------------------------------------------------------- */ |
| 3218 |
/* Reusable form pieces */ |
| 3219 |
/* -------------------------------------------------------------------------- */ |
| 3220 |
|
| 3221 |
const FormField: React.FC<{ |
| 3222 |
label: string; |
| 3223 |
description?: string; |
| 3224 |
children: React.ReactNode; |
| 3225 |
}> = ({ label, description, children }) => ( |
| 3226 |
<div className="space-y-1.5"> |
| 3227 |
<Label>{label}</Label> |
| 3228 |
{children} |
| 3229 |
{description && ( |
| 3230 |
<p className="text-xs leading-relaxed text-gray-500 dark:text-gray-400 flex items-start gap-1.5"> |
| 3231 |
<Info className="h-3.5 w-3.5 mt-0.5 flex-shrink-0 text-gray-400" /> |
| 3232 |
<span>{description}</span> |
| 3233 |
</p> |
| 3234 |
)} |
| 3235 |
</div> |
| 3236 |
); |
| 3237 |
|
| 3238 |
const ToggleRow: React.FC<{ |
| 3239 |
label: string; |
| 3240 |
description: string; |
| 3241 |
checked: boolean; |
| 3242 |
onChange: (next: boolean) => void; |
| 3243 |
tone?: "default" | "amber"; |
| 3244 |
}> = ({ label, description, checked, onChange, tone = "default" }) => { |
| 3245 |
const baseBg = |
| 3246 |
tone === "amber" && checked |
| 3247 |
? "border-amber-300 bg-amber-50/60 dark:border-amber-700 dark:bg-amber-950/30" |
| 3248 |
: "border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-900"; |
| 3249 |
return ( |
| 3250 |
<label |
| 3251 |
className={`flex items-start gap-3 cursor-pointer rounded-md border px-3 py-2.5 transition-colors ${baseBg}`} |
| 3252 |
> |
| 3253 |
<input |
| 3254 |
type="checkbox" |
| 3255 |
checked={checked} |
| 3256 |
onChange={(e) => onChange(e.target.checked)} |
| 3257 |
className="mt-0.5" |
| 3258 |
/> |
| 3259 |
<span className="flex-1"> |
| 3260 |
<span className="block text-sm font-medium text-gray-900 dark:text-white"> |
| 3261 |
{label} |
| 3262 |
</span> |
| 3263 |
<span className="block text-xs text-gray-500 dark:text-gray-400 mt-0.5"> |
| 3264 |
{description} |
| 3265 |
</span> |
| 3266 |
</span> |
| 3267 |
</label> |
| 3268 |
); |
| 3269 |
}; |
| 3270 |
|
| 3271 |
const StatusLegend: React.FC<{ |
| 3272 |
color: "blue" | "green" | "red"; |
| 3273 |
label: string; |
| 3274 |
description: string; |
| 3275 |
}> = ({ color, label, description }) => { |
| 3276 |
const cls = |
| 3277 |
color === "green" |
| 3278 |
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" |
| 3279 |
: color === "red" |
| 3280 |
? "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400" |
| 3281 |
: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"; |
| 3282 |
return ( |
| 3283 |
<span className="inline-flex items-center gap-1.5"> |
| 3284 |
<Badge className={cls}>{label}</Badge> |
| 3285 |
<span className="text-gray-500 dark:text-gray-400">{description}</span> |
| 3286 |
</span> |
| 3287 |
); |
| 3288 |
}; |
| 3289 |
|
| 3290 |
const Th: React.FC<{ |
| 3291 |
children: React.ReactNode; |
| 3292 |
align?: "left" | "right"; |
| 3293 |
}> = ({ children, align = "left" }) => ( |
| 3294 |
<th |
| 3295 |
className={`px-4 py-3 text-${align} text-xs font-medium text-gray-500 dark:text-gray-400 uppercase`} |
| 3296 |
> |
| 3297 |
{children} |
| 3298 |
</th> |
| 3299 |
); |
| 3300 |
|
| 3301 |
/** |
| 3302 |
* Per-channel health pill. Status comes from the server combining |
| 3303 |
* the last-10 log outcomes with the circuit-breaker state. Tooltip |
| 3304 |
* surfaces the raw counts + breaker reason so the operator can |
| 3305 |
* diagnose without digging into the Logs tab. |
| 3306 |
*/ |
| 3307 |
const HealthBadge: React.FC<{ health?: ChannelHealth }> = ({ health }) => { |
| 3308 |
if (!health) { |
| 3309 |
return <span className="text-xs text-gray-400 dark:text-gray-500">—</span>; |
| 3310 |
} |
| 3311 |
const { status, breaker, recent } = health; |
| 3312 |
const cls = |
| 3313 |
status === "green" |
| 3314 |
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" |
| 3315 |
: status === "amber" |
| 3316 |
? "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400" |
| 3317 |
: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"; |
| 3318 |
|
| 3319 |
const breakerLabel = |
| 3320 |
breaker.state === "open" |
| 3321 |
? __("circuit open", "yatra") |
| 3322 |
: breaker.state === "half_open" |
| 3323 |
? __("recovering", "yatra") |
| 3324 |
: ""; |
| 3325 |
|
| 3326 |
const tooltip = [ |
| 3327 |
recent.total > 0 |
| 3328 |
? __("Last {total}: {ok} OK, {fail} failed.", "yatra") |
| 3329 |
.replace("{total}", String(recent.total)) |
| 3330 |
.replace("{ok}", String(recent.ok)) |
| 3331 |
.replace("{fail}", String(recent.fail)) |
| 3332 |
: __("No recent activity.", "yatra"), |
| 3333 |
breaker.state !== "closed" |
| 3334 |
? __("Circuit: {state}.", "yatra").replace("{state}", breaker.state) + |
| 3335 |
(breaker.last_failure ? ` — ${breaker.last_failure}` : "") |
| 3336 |
: "", |
| 3337 |
] |
| 3338 |
.filter(Boolean) |
| 3339 |
.join(" "); |
| 3340 |
|
| 3341 |
const label = |
| 3342 |
status === "green" |
| 3343 |
? __("Healthy", "yatra") |
| 3344 |
: status === "amber" |
| 3345 |
? __("Degraded", "yatra") |
| 3346 |
: __("Failing", "yatra"); |
| 3347 |
|
| 3348 |
return ( |
| 3349 |
<span className="inline-flex items-center gap-1.5" title={tooltip}> |
| 3350 |
<Badge className={cls}> |
| 3351 |
<Activity className="mr-1 h-3 w-3" /> |
| 3352 |
{label} |
| 3353 |
</Badge> |
| 3354 |
{breakerLabel && ( |
| 3355 |
<span className="text-[10px] text-red-600 dark:text-red-400"> |
| 3356 |
{breakerLabel} |
| 3357 |
</span> |
| 3358 |
)} |
| 3359 |
</span> |
| 3360 |
); |
| 3361 |
}; |
| 3362 |
|
| 3363 |
function extractError(e: any): string { |
| 3364 |
if (!e) return "Request failed."; |
| 3365 |
const data = e?.response?.data ?? e?.data ?? null; |
| 3366 |
if ( |
| 3367 |
data && |
| 3368 |
typeof data === "object" && |
| 3369 |
typeof (data as any).message === "string" |
| 3370 |
) { |
| 3371 |
return (data as any).message; |
| 3372 |
} |
| 3373 |
return e?.message || "Request failed."; |
| 3374 |
} |
| 3375 |
|
| 3376 |
export default ChannelManager; |
| 3377 |
|