PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.10
Yatra – Travel Booking & Tour Operator Software v3.0.10
3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 2.0.10 All 83 releases
yatra / resources / js / pages / ChannelManager.tsx

ChannelManager.tsx in Yatra – Travel Booking & Tour Operator Software 3.0.10, at resources/js/pages/ChannelManager.tsx

3,377 lines 125.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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