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 / WhiteLabel.tsx

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

1,578 lines 53.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import React, { useCallback, useEffect, useMemo, useState } from "react";
2 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3 import {
4 Crown,
5 ExternalLink,
6 Save,
7 Loader2,
8 Lock,
9 Image as ImageIcon,
10 X,
11 Eye,
12 EyeOff,
13 Palette,
14 Sparkles,
15 Layout as LayoutIcon,
16 PanelLeft,
17 GripVertical,
18 Wrench,
19 Building2,
20 ChevronDown,
21 ChevronRight,
22 } from "lucide-react";
23 import { apiClient } from "../lib/api-client";
24 import { __ } from "../lib/i18n";
25 import { useToast } from "../components/ui/toast";
26 import { useWordPressMedia } from "../hooks/useWordPressMedia";
27 import { Button } from "../components/ui/button";
28 import { Input } from "../components/ui/input";
29 import { ModulePageSkeleton } from "../components/ui/module-skeleton";
30 import { Label } from "../components/ui/label";
31 import { IconPicker } from "../components/ui/icon-picker";
32 import type { IconPickerValue } from "../lib/icon-picker-types";
33 import { PageHeader } from "../components/common/PageHeader";
34 import { MenuIcon } from "../lib/menu-icon";
35 import {
36 Card,
37 CardContent,
38 CardDescription,
39 CardHeader,
40 CardTitle,
41 } from "../components/ui/card";
42 import {
43 DEFAULT_MENU_ITEMS,
44 type MenuIconValue,
45 type MenuItemOverride,
46 type MenuOrderMap,
47 type MenuOverrides,
48 type UiChromeFlags,
49 } from "../lib/sidebar-menu-defaults";
50 import { ICON_MAP } from "../lib/icon-map";
51
52 /**
53 * Coerce the stored override icon into an IconPickerValue for the picker.
54 * Lucide-string overrides display as the IconPicker's empty state — users
55 * see the default Lucide icon in the row's preview but the picker itself
56 * starts blank, which is the expected behavior since IconPicker doesn't
57 * know about Lucide.
58 */
59 function toPickerValue(
60 icon: MenuIconValue | undefined,
61 ): IconPickerValue | null {
62 if (!icon) return null;
63 if (typeof icon === "string") return null;
64 return icon;
65 }
66
67 interface ThemeSurfaces {
68 sidebar_bg?: string;
69 sidebar_text?: string;
70 topbar_bg?: string;
71 topbar_text?: string;
72 }
73
74 interface WhiteLabelSettings {
75 plugin_name: string;
76 company_name: string;
77 website_url: string;
78 support_url: string;
79 logo_url: string;
80 primary_color: string;
81 menu_overrides: MenuOverrides;
82 /**
83 * Per-parent ordered list. The "" key is the top-level order; each
84 * top-level slug key holds that parent's ordered children. Backwards
85 * compatible with the older flat array form (handled on read).
86 */
87 menu_order: MenuOrderMap | string[];
88 ui_chrome: UiChromeFlags;
89 /**
90 * Soft "theme" overrides — sidebar + topbar background and text
91 * colors. Empty / missing keys fall back to Yatra's default
92 * light/dark palette.
93 */
94 theme_surfaces: ThemeSurfaces;
95 }
96
97 /** Quick-pick theme palettes for the Theme card. */
98 const THEME_PRESETS: Array<{
99 label: string;
100 surfaces: Required<ThemeSurfaces>;
101 }> = [
102 {
103 label: "Light",
104 surfaces: {
105 sidebar_bg: "#ffffff",
106 sidebar_text: "#374151",
107 topbar_bg: "#ffffff",
108 topbar_text: "#111827",
109 },
110 },
111 {
112 label: "Dark",
113 surfaces: {
114 sidebar_bg: "#111827",
115 sidebar_text: "#e5e7eb",
116 topbar_bg: "#111827",
117 topbar_text: "#f9fafb",
118 },
119 },
120 {
121 label: "Midnight",
122 surfaces: {
123 sidebar_bg: "#0f172a",
124 sidebar_text: "#94a3b8",
125 topbar_bg: "#1e293b",
126 topbar_text: "#e2e8f0",
127 },
128 },
129 {
130 label: "Ocean",
131 surfaces: {
132 sidebar_bg: "#0c4a6e",
133 sidebar_text: "#e0f2fe",
134 topbar_bg: "#075985",
135 topbar_text: "#f0f9ff",
136 },
137 },
138 {
139 label: "Sand",
140 surfaces: {
141 sidebar_bg: "#fef3c7",
142 sidebar_text: "#78350f",
143 topbar_bg: "#fef9c3",
144 topbar_text: "#854d0e",
145 },
146 },
147 {
148 label: "Forest",
149 surfaces: {
150 sidebar_bg: "#064e3b",
151 sidebar_text: "#d1fae5",
152 topbar_bg: "#065f46",
153 topbar_text: "#ecfdf5",
154 },
155 },
156 ];
157
158 interface WhiteLabelMeta {
159 is_pro_active: boolean;
160 is_agency_active: boolean;
161 is_module_enabled: boolean;
162 agency: {
163 is_agency?: boolean;
164 tier?: string;
165 price_id?: number | null;
166 agency_price_ids?: number[];
167 };
168 upgrade_url: string;
169 license_page_url: string;
170 }
171
172 interface WhiteLabelResponse {
173 data: WhiteLabelSettings;
174 meta: WhiteLabelMeta;
175 }
176
177 type SectionId = "brand" | "theme" | "menu" | "chrome";
178
179 interface SectionDef {
180 id: SectionId;
181 label: string;
182 icon: React.ComponentType<{ className?: string }>;
183 description: string;
184 }
185
186 const SECTIONS: SectionDef[] = [
187 {
188 id: "brand",
189 label: "Brand Identity",
190 icon: Building2,
191 description:
192 "Plugin name, company, URLs, and logo shown across the admin and PDFs.",
193 },
194 {
195 id: "theme",
196 label: "Theme",
197 icon: Palette,
198 description:
199 "Brand accent color, plus surface theme — sidebar and top bar background + text colors. Pick a preset or fine-tune each color.",
200 },
201 {
202 id: "menu",
203 label: "Sidebar Menu",
204 icon: PanelLeft,
205 description:
206 "Reorder, rename, re-icon, or hide entries in the Yatra sidebar.",
207 },
208 {
209 id: "chrome",
210 label: "UI Chrome",
211 icon: LayoutIcon,
212 description:
213 "Show or hide the version number, Back to WordPress link, and Join Community button.",
214 },
215 ];
216
217 const DEFAULT_SETTINGS: WhiteLabelSettings = {
218 plugin_name: "",
219 company_name: "",
220 website_url: "",
221 support_url: "",
222 logo_url: "",
223 primary_color: "",
224 menu_overrides: {},
225 menu_order: {},
226 ui_chrome: {},
227 theme_surfaces: {},
228 };
229
230 /** Normalize stored order (flat array OR map) to a map keyed by parent. */
231 function toOrderMap(raw: MenuOrderMap | string[] | undefined): MenuOrderMap {
232 if (!raw) return {};
233 if (Array.isArray(raw)) return { "": [...raw] };
234 return Object.fromEntries(
235 Object.entries(raw).map(([k, v]) => [k, Array.isArray(v) ? [...v] : []]),
236 );
237 }
238
239 /* -------------------------------------------------------------------------- */
240 /* Plan tier badge */
241 /* -------------------------------------------------------------------------- */
242
243 /**
244 * Pill rendered in the White Label page header so operators see at a
245 * glance which license tier unlocks this page. Mirrors the badge style
246 * used on the Modules page card for visual consistency.
247 */
248 const PlanBadge: React.FC = () => (
249 <span className="inline-flex items-center gap-1 rounded-full bg-gradient-to-r from-purple-600 to-indigo-500 px-2.5 py-1 text-xs font-medium text-white shadow-sm">
250 <Crown className="h-3 w-3" />
251 {__("Scale plan", "yatra")}
252 </span>
253 );
254
255 /* -------------------------------------------------------------------------- */
256 /* Upgrade card (non-Agency) */
257 /* -------------------------------------------------------------------------- */
258
259 const UpgradeCard: React.FC<{ meta?: WhiteLabelMeta }> = ({ meta }) => {
260 const upgradeUrl =
261 meta?.upgrade_url || "https://wpyatra.com/pricing?module=white-label";
262 const licensePageUrl =
263 meta?.license_page_url || "admin.php?page=yatra&subpage=license";
264
265 return (
266 <Card className="max-w-3xl">
267 <CardHeader>
268 <div className="flex items-start gap-3">
269 <div className="rounded-md bg-amber-100 p-2 text-amber-600 dark:bg-amber-900/30 dark:text-amber-400">
270 <Crown className="h-5 w-5" />
271 </div>
272 <div className="flex-1">
273 <div className="flex flex-wrap items-center gap-2">
274 <CardTitle>{__("White Label", "yatra")}</CardTitle>
275 <PlanBadge />
276 </div>
277 <CardDescription className="mt-1">
278 {__(
279 "White Label is exclusive to the Scale plan (Yearly or Lifetime). Upgrade to remove all Yatra / MantraBrain references from your clients' admin.",
280 "yatra",
281 )}
282 </CardDescription>
283 </div>
284 </div>
285 </CardHeader>
286 <CardContent className="space-y-4">
287 <p className="text-sm text-gray-600 dark:text-gray-300">
288 {__(
289 "Rebrand the entire Yatra plugin as your own product — admin menu, plugin list, and PDFs — and remove every reference to Yatra or MantraBrain from what your clients see.",
290 "yatra",
291 )}
292 </p>
293 <div className="flex flex-wrap items-center gap-3 pt-2">
294 <Button asChild>
295 <a href={upgradeUrl} target="_blank" rel="noopener noreferrer">
296 {meta?.is_pro_active
297 ? __("Upgrade to Scale", "yatra")
298 : __("Get Yatra Pro Scale", "yatra")}
299 <ExternalLink className="ml-2 h-4 w-4" />
300 </a>
301 </Button>
302 {meta?.is_pro_active && (
303 <Button variant="outline" asChild>
304 <a href={licensePageUrl}>{__("Manage License", "yatra")}</a>
305 </Button>
306 )}
307 </div>
308 </CardContent>
309 </Card>
310 );
311 };
312
313 /* -------------------------------------------------------------------------- */
314 /* Menu customization row (top-level + submenu) */
315 /* -------------------------------------------------------------------------- */
316
317 interface MenuRowProps {
318 slug: string;
319 defaultLabel: string;
320 defaultIconName: string;
321 override: MenuItemOverride;
322 isSubmenu?: boolean;
323 draggable?: boolean;
324 isDragging?: boolean;
325 /** Where this row would receive the drop relative to itself. */
326 dropPosition?: "above" | "into" | "below" | null;
327 onDragStart?: (e: React.DragEvent) => void;
328 onDragOver?: (e: React.DragEvent) => void;
329 onDragEnd?: () => void;
330 onDragLeave?: () => void;
331 onDrop?: (e: React.DragEvent) => void;
332 onPatch: (patch: Partial<MenuItemOverride>) => void;
333 }
334
335 const MenuRow: React.FC<MenuRowProps> = ({
336 slug,
337 defaultLabel,
338 defaultIconName,
339 override,
340 isSubmenu = false,
341 draggable = false,
342 isDragging = false,
343 dropPosition = null,
344 onDragStart,
345 onDragOver,
346 onDragEnd,
347 onDragLeave,
348 onDrop,
349 onPatch,
350 }) => {
351 const FallbackIcon = ICON_MAP[defaultIconName] || ICON_MAP.Settings;
352 const hidden = Boolean(override.hidden);
353
354 return (
355 <div
356 className="relative"
357 onDragOver={onDragOver}
358 onDragLeave={onDragLeave}
359 onDrop={onDrop}
360 >
361 {/* Drop indicator: thin bar above */}
362 {dropPosition === "above" && (
363 <div
364 className="pointer-events-none absolute -top-1 left-0 right-0 z-10 h-1 rounded bg-blue-500 shadow"
365 aria-hidden="true"
366 />
367 )}
368 {/* Drop indicator: thin bar below */}
369 {dropPosition === "below" && (
370 <div
371 className="pointer-events-none absolute -bottom-1 left-0 right-0 z-10 h-1 rounded bg-blue-500 shadow"
372 aria-hidden="true"
373 />
374 )}
375
376 <div
377 draggable={draggable}
378 onDragStart={onDragStart}
379 onDragEnd={onDragEnd}
380 className={`flex items-center gap-3 rounded-md border px-3 py-2 transition-shadow ${
381 isDragging
382 ? "border-blue-400 bg-blue-50 opacity-60 dark:border-blue-500 dark:bg-blue-900/20"
383 : dropPosition === "into"
384 ? "border-blue-400 bg-blue-50 ring-2 ring-blue-400 dark:border-blue-500 dark:bg-blue-900/30"
385 : isSubmenu
386 ? "border-gray-100 bg-gray-50 dark:border-gray-800 dark:bg-gray-900/40"
387 : "border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"
388 }`}
389 >
390 {draggable ? (
391 <button
392 type="button"
393 className="cursor-grab touch-none text-gray-400 hover:text-gray-600 active:cursor-grabbing dark:hover:text-gray-200"
394 aria-label={__("Drag to reorder", "yatra")}
395 onMouseDown={(e) => e.stopPropagation()}
396 >
397 <GripVertical className="h-4 w-4" />
398 </button>
399 ) : (
400 <span className="inline-block w-4" aria-hidden="true" />
401 )}
402
403 <MenuIcon
404 icon={override.icon}
405 fallback={FallbackIcon}
406 className="h-4 w-4 text-gray-500"
407 />
408
409 <div className="min-w-[140px]">
410 <div className="text-sm font-medium text-gray-900 dark:text-white">
411 {defaultLabel}
412 </div>
413 <div className="text-[10px] uppercase tracking-wide text-gray-400">
414 {slug}
415 </div>
416 </div>
417
418 <Input
419 value={override.label ?? ""}
420 placeholder={defaultLabel}
421 disabled={hidden}
422 className="max-w-[180px]"
423 onChange={(e) => onPatch({ label: e.target.value })}
424 />
425
426 <div className="min-w-[180px]">
427 <IconPicker
428 value={toPickerValue(override.icon)}
429 onChange={(val) => onPatch({ icon: val ?? undefined })}
430 size="sm"
431 allowImageUpload
432 allowIconSelection
433 />
434 </div>
435
436 <button
437 type="button"
438 onClick={() => onPatch({ hidden: !hidden })}
439 className={`ml-auto inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium ${
440 hidden
441 ? "bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-300"
442 : "bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-300"
443 }`}
444 aria-label={
445 hidden
446 ? __("Show this menu item", "yatra")
447 : __("Hide this menu item", "yatra")
448 }
449 >
450 {hidden ? (
451 <>
452 <EyeOff className="h-3 w-3" />
453 {__("Hidden", "yatra")}
454 </>
455 ) : (
456 <>
457 <Eye className="h-3 w-3" />
458 {__("Visible", "yatra")}
459 </>
460 )}
461 </button>
462 </div>
463 </div>
464 );
465 };
466
467 /* -------------------------------------------------------------------------- */
468 /* Main page */
469 /* -------------------------------------------------------------------------- */
470
471 const WhiteLabel: React.FC = () => {
472 const { showToast } = useToast();
473 const queryClient = useQueryClient();
474 const [form, setForm] = useState<WhiteLabelSettings>(DEFAULT_SETTINGS);
475 const [activeSection, setActiveSection] = useState<SectionId>("brand");
476 const [draggingSlug, setDraggingSlug] = useState<string | null>(null);
477 /**
478 * Where the dragged row would land if dropped right now. `position` is
479 * derived from the mouse Y offset within the hovered row:
480 * - `above` — insert just above the hovered row (same indent)
481 * - `into` — make the hovered row a parent, dragged row becomes its
482 * last child (only valid when hovering a top-level row)
483 * - `below` — insert just below the hovered row (same indent)
484 */
485 const [dropTarget, setDropTarget] = useState<{
486 key: string;
487 position: "above" | "into" | "below";
488 } | null>(null);
489 // Start with every parent expanded so users see / can manage children
490 // without an extra interaction. Collapsing remains on a per-row click.
491 const [expandedSubmenus, setExpandedSubmenus] = useState<Set<string>>(
492 () =>
493 new Set(DEFAULT_MENU_ITEMS.filter((m) => m.submenu).map((m) => m.slug)),
494 );
495
496 const { openMediaLibrary } = useWordPressMedia({
497 title: __("Select brand logo", "yatra"),
498 buttonText: __("Use this image", "yatra"),
499 multiple: false,
500 library: { type: "image" },
501 });
502
503 const { data, isLoading } = useQuery<WhiteLabelResponse>({
504 queryKey: ["white-label-settings"],
505 queryFn: async () => {
506 const response = await apiClient.get("/white-label/settings");
507 return response as WhiteLabelResponse;
508 },
509 });
510
511 useEffect(() => {
512 if (data?.data) {
513 setForm({
514 ...DEFAULT_SETTINGS,
515 ...data.data,
516 menu_overrides: data.data.menu_overrides ?? {},
517 menu_order: toOrderMap(data.data.menu_order),
518 ui_chrome: data.data.ui_chrome ?? {},
519 theme_surfaces: data.data.theme_surfaces ?? {},
520 });
521 }
522 }, [data]);
523
524 const saveMutation = useMutation({
525 mutationFn: async (payload: WhiteLabelSettings) => {
526 const response = await apiClient.put("/white-label/settings", payload);
527 return response as WhiteLabelResponse;
528 },
529 onSuccess: () => {
530 showToast(__("White Label settings saved.", "yatra"), "success");
531 queryClient.invalidateQueries({ queryKey: ["white-label-settings"] });
532 },
533 onError: (error: any) => {
534 const msg =
535 error?.response?.data?.message ||
536 __("Failed to save settings.", "yatra");
537 showToast(msg, "error");
538 },
539 });
540
541 const meta = data?.meta;
542 const isAgency = Boolean(meta?.is_agency_active);
543 const isModuleEnabled = Boolean(meta?.is_module_enabled);
544
545 const updateField = useCallback(
546 <K extends keyof WhiteLabelSettings>(
547 key: K,
548 value: WhiteLabelSettings[K],
549 ) => setForm((prev) => ({ ...prev, [key]: value })),
550 [],
551 );
552
553 const updateMenuOverride = useCallback(
554 (slug: string, patch: Partial<MenuItemOverride>) => {
555 setForm((prev) => {
556 const next = { ...(prev.menu_overrides ?? {}) };
557 const current = next[slug] ?? {};
558 const merged: MenuItemOverride = { ...current, ...patch };
559 if (merged.label === "") delete merged.label;
560 if (merged.icon === "") delete merged.icon;
561 if (merged.hidden === false) delete merged.hidden;
562 if (Object.keys(merged).length === 0) {
563 delete next[slug];
564 } else {
565 next[slug] = merged;
566 }
567 return { ...prev, menu_overrides: next };
568 });
569 },
570 [],
571 );
572
573 const toggleUiChrome = useCallback((key: keyof UiChromeFlags) => {
574 setForm((prev) => {
575 const next = { ...(prev.ui_chrome ?? {}) };
576 if (next[key]) {
577 delete next[key];
578 } else {
579 next[key] = true;
580 }
581 return { ...prev, ui_chrome: next };
582 });
583 }, []);
584
585 const handlePickLogo = useCallback(() => {
586 openMediaLibrary((attachment) => {
587 if (attachment && !Array.isArray(attachment)) {
588 updateField("logo_url", attachment.url);
589 }
590 });
591 }, [openMediaLibrary, updateField]);
592
593 /**
594 * Compute the ordered list of top-level slugs to display in the menu
595 * customization tab. Honors any saved menu_order, then appends defaults
596 * the user hasn't touched yet (so newly-added Yatra menus surface
597 * automatically without requiring a settings rewrite).
598 */
599 /**
600 * Flat registry of every known menu atom (top-level + submenu),
601 * each with its default parent. The effective tree is computed by
602 * overlaying menu_overrides[key].parent and menu_order on this.
603 */
604 interface MenuAtom {
605 key: string;
606 defaultParent: string;
607 defaultLabel: string;
608 defaultIconName: string;
609 }
610
611 const atoms = useMemo<MenuAtom[]>(() => {
612 const list: MenuAtom[] = [];
613 for (const item of DEFAULT_MENU_ITEMS) {
614 list.push({
615 key: item.slug,
616 defaultParent: "",
617 defaultLabel: item.label,
618 defaultIconName: item.iconName,
619 });
620 if (item.submenu) {
621 for (const sub of item.submenu) {
622 list.push({
623 key: sub.key,
624 defaultParent: item.slug,
625 defaultLabel: sub.label,
626 defaultIconName: sub.iconName,
627 });
628 }
629 }
630 }
631 return list;
632 }, []);
633
634 const atomMap = useMemo(() => new Map(atoms.map((a) => [a.key, a])), [atoms]);
635
636 /** Effective parent for an atom (override.parent wins, else default). */
637 const effectiveParent = useCallback(
638 (key: string): string => {
639 const ov = form.menu_overrides?.[key];
640 if (ov && typeof ov.parent === "string") {
641 if (ov.parent === "") return "";
642 if (atomMap.get(ov.parent)?.defaultParent === "") return ov.parent;
643 }
644 return atomMap.get(key)?.defaultParent ?? "";
645 },
646 [form.menu_overrides, atomMap],
647 );
648
649 /**
650 * Compute the ordered tree the table renders. Returns a flat list of
651 * { atom, parent, depth } in render order. Reordering happens by
652 * mutating form.menu_order[parent]; cross-parent moves go through
653 * the Move-to dropdown which updates menu_overrides[key].parent.
654 */
655 const renderRows = useMemo(() => {
656 const orderMap = (form.menu_order ?? {}) as MenuOrderMap;
657
658 const groups = new Map<string, string[]>();
659 for (const atom of atoms) {
660 const parent = effectiveParent(atom.key);
661 if (parent === atom.key) continue;
662 const list = groups.get(parent) ?? [];
663 list.push(atom.key);
664 groups.set(parent, list);
665 }
666
667 const orderGroup = (parent: string, group: string[]): string[] => {
668 const saved = orderMap[parent] ?? [];
669 if (saved.length === 0) return group;
670 const set = new Set(group);
671 const seen = new Set<string>();
672 const ordered: string[] = [];
673 for (const k of saved) {
674 if (set.has(k) && !seen.has(k)) {
675 ordered.push(k);
676 seen.add(k);
677 }
678 }
679 for (const k of group) if (!seen.has(k)) ordered.push(k);
680 return ordered;
681 };
682
683 type Row = { atom: MenuAtom; depth: number; parent: string };
684 const rows: Row[] = [];
685 for (const topKey of orderGroup("", groups.get("") ?? [])) {
686 const topAtom = atomMap.get(topKey);
687 if (!topAtom) continue;
688 rows.push({ atom: topAtom, depth: 0, parent: "" });
689 if (expandedSubmenus.has(topKey)) {
690 for (const childKey of orderGroup(topKey, groups.get(topKey) ?? [])) {
691 const childAtom = atomMap.get(childKey);
692 if (childAtom)
693 rows.push({ atom: childAtom, depth: 1, parent: topKey });
694 }
695 }
696 }
697 return rows;
698 }, [atoms, atomMap, effectiveParent, form.menu_order, expandedSubmenus]);
699
700 /** Whether a top-level atom currently has any children rendered under it. */
701 const hasChildrenByParent = useMemo(() => {
702 const map = new Map<string, boolean>();
703 for (const atom of atoms) {
704 const parent = effectiveParent(atom.key);
705 if (parent === "") continue;
706 map.set(parent, true);
707 }
708 return map;
709 }, [atoms, effectiveParent]);
710
711 const handleDragStart = useCallback((key: string, e: React.DragEvent) => {
712 setDraggingSlug(key);
713 if (e.dataTransfer) {
714 e.dataTransfer.effectAllowed = "move";
715 }
716 }, []);
717
718 /**
719 * Decide the drop zone based on cursor Y within the hovered row.
720 *
721 * Top-level rows (can accept children):
722 * 0–25% → above
723 * 25–75% → into (becomes last child of this row)
724 * 75–100% → below
725 *
726 * Submenu rows (cannot have grandchildren):
727 * 0–50% → above
728 * 50–100% → below
729 *
730 * The above/below decision uses the hovered row's own parent group, so
731 * a submenu row's "above" drops into its parent's children list — which
732 * is the natural place for the dragged item even when it came from a
733 * different parent (in that case the move also reparents).
734 */
735 const handleRowDragOver = useCallback(
736 (key: string, e: React.DragEvent) => {
737 if (!draggingSlug || draggingSlug === key) return;
738 e.preventDefault();
739 if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
740
741 const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
742 const y = e.clientY - rect.top;
743 const h = rect.height || 1;
744 const ratio = Math.max(0, Math.min(1, y / h));
745
746 const isHoveredTopLevel = effectiveParent(key) === "";
747 // Don't allow dropping a parent INTO one of its own descendants —
748 // that would create a cycle. We resolve to above/below in that case.
749 const draggedAtom = atomMap.get(draggingSlug);
750 const isDescendantOfDragged =
751 draggedAtom &&
752 draggedAtom.defaultParent === "" &&
753 effectiveParent(key) === draggingSlug;
754
755 let position: "above" | "into" | "below";
756 if (isHoveredTopLevel && !isDescendantOfDragged) {
757 if (ratio < 0.25) position = "above";
758 else if (ratio > 0.75) position = "below";
759 else position = "into";
760 } else {
761 position = ratio < 0.5 ? "above" : "below";
762 }
763
764 setDropTarget((prev) =>
765 prev && prev.key === key && prev.position === position
766 ? prev
767 : { key, position },
768 );
769 },
770 [draggingSlug, effectiveParent, atomMap],
771 );
772
773 const handleRowDragLeave = useCallback(() => {
774 // Don't clear on every leave — sibling enter immediately overwrites.
775 // We only clear on dragend / drop. This keeps the indicator stable.
776 }, []);
777
778 const handleDragEnd = useCallback(() => {
779 setDraggingSlug(null);
780 setDropTarget(null);
781 }, []);
782
783 /**
784 * The single drop handler — replaces the old reorder-only handler AND
785 * the Move-to dropdown. Reorders within the same parent and reparents
786 * across parents in one interaction (WordPress menus-style).
787 */
788 const handleRowDrop = useCallback(
789 (targetKey: string, e: React.DragEvent) => {
790 e.preventDefault();
791 const src = draggingSlug;
792 const drop = dropTarget;
793 setDraggingSlug(null);
794 setDropTarget(null);
795 if (!src || src === targetKey || !drop || drop.key !== targetKey) return;
796
797 const srcAtom = atomMap.get(src);
798 const targetAtom = atomMap.get(targetKey);
799 if (!srcAtom || !targetAtom) return;
800
801 // Compute the destination parent based on drop position.
802 let newParent: string;
803 if (drop.position === "into") {
804 // Only top-level rows accept "into" — guard handled in dragOver
805 // but recheck here so a stale dropTarget can't sneak through.
806 if (targetAtom.defaultParent !== "") return;
807 newParent = targetKey;
808 } else {
809 newParent = effectiveParent(targetKey);
810 }
811
812 // Block one-level-deeper nesting (no grandchildren).
813 const parentAtom = atomMap.get(newParent);
814 if (newParent !== "" && parentAtom && parentAtom.defaultParent !== "") {
815 return;
816 }
817
818 // Block making an atom its own ancestor.
819 if (newParent === src) return;
820 // Block making a top-level parent a child of one of its own
821 // descendants (cycle prevention).
822 const parentEffective =
823 newParent === "" ? "" : effectiveParent(newParent);
824 if (parentEffective === src) return;
825
826 setForm((prev) => {
827 const overrides: MenuOverrides = { ...(prev.menu_overrides ?? {}) };
828 const orderMap = toOrderMap(prev.menu_order);
829
830 // 1. Update src's parent override (clear if it matches default).
831 {
832 const ov: MenuItemOverride = { ...(overrides[src] ?? {}) };
833 if (newParent === srcAtom.defaultParent) {
834 delete ov.parent;
835 } else {
836 ov.parent = newParent;
837 }
838 if (Object.keys(ov).length === 0) {
839 delete overrides[src];
840 } else {
841 overrides[src] = ov;
842 }
843 }
844
845 // Helper: with the just-updated overrides, what's an atom's parent?
846 const effectiveWithNew = (key: string): string => {
847 const ov = overrides[key];
848 if (ov && typeof ov.parent === "string") {
849 if (ov.parent === "") return "";
850 if (atomMap.get(ov.parent)?.defaultParent === "") return ov.parent;
851 }
852 return atomMap.get(key)?.defaultParent ?? "";
853 };
854
855 // 2. Strip src from every group it might appear in.
856 for (const [p, list] of Object.entries(orderMap)) {
857 orderMap[p] = list.filter((k) => k !== src);
858 }
859
860 // 3. Build the destination group's current effective list, then
861 // insert src at the right index (above/below/into-append).
862 const destGroup = atoms
863 .filter((a) => a.key !== src && effectiveWithNew(a.key) === newParent)
864 .map((a) => a.key);
865
866 const existing = orderMap[newParent] ?? [];
867 const seeded: string[] = [];
868 const seen = new Set<string>();
869 for (const k of existing) {
870 if (destGroup.includes(k) && !seen.has(k)) {
871 seeded.push(k);
872 seen.add(k);
873 }
874 }
875 for (const k of destGroup) {
876 if (!seen.has(k)) seeded.push(k);
877 }
878
879 if (drop.position === "into") {
880 seeded.push(src);
881 } else {
882 const targetIdx = seeded.indexOf(targetKey);
883 if (targetIdx === -1) {
884 seeded.push(src);
885 } else {
886 const insertIdx =
887 drop.position === "above" ? targetIdx : targetIdx + 1;
888 seeded.splice(insertIdx, 0, src);
889 }
890 }
891 orderMap[newParent] = seeded;
892
893 return {
894 ...prev,
895 menu_overrides: overrides,
896 menu_order: orderMap,
897 };
898 });
899 },
900 [draggingSlug, dropTarget, atomMap, effectiveParent, atoms],
901 );
902
903 const toggleSubmenuExpanded = useCallback((slug: string) => {
904 setExpandedSubmenus((prev) => {
905 const next = new Set(prev);
906 if (next.has(slug)) next.delete(slug);
907 else next.add(slug);
908 return next;
909 });
910 }, []);
911
912 /* ------------------------------- Renderers ------------------------------ */
913
914 if (isLoading) {
915 return <ModulePageSkeleton variant="form" />;
916 }
917
918 if (!isAgency) {
919 return (
920 <div className="space-y-6">
921 <PageHeader
922 description={__("Rebrand Yatra as your own product.", "yatra")}
923 />
924 <UpgradeCard meta={meta} />
925 </div>
926 );
927 }
928
929 const onSubmit = (e: React.FormEvent) => {
930 e.preventDefault();
931 saveMutation.mutate(form);
932 };
933
934 const renderBrand = () => (
935 <Card>
936 <CardHeader>
937 <CardTitle>{__("Brand identity", "yatra")}</CardTitle>
938 <CardDescription>
939 {__("Shown in the admin menu, plugin list, and PDFs.", "yatra")}
940 </CardDescription>
941 </CardHeader>
942 <CardContent className="grid gap-4 md:grid-cols-2">
943 <div className="space-y-1.5">
944 <Label htmlFor="plugin_name">{__("Plugin name", "yatra")}</Label>
945 <Input
946 id="plugin_name"
947 value={form.plugin_name}
948 placeholder="Yatra"
949 onChange={(e) => updateField("plugin_name", e.target.value)}
950 />
951 </div>
952 <div className="space-y-1.5">
953 <Label htmlFor="company_name">
954 {__("Company / Author", "yatra")}
955 </Label>
956 <Input
957 id="company_name"
958 value={form.company_name}
959 placeholder="MantraBrain"
960 onChange={(e) => updateField("company_name", e.target.value)}
961 />
962 </div>
963 <div className="space-y-1.5">
964 <Label htmlFor="website_url">{__("Website URL", "yatra")}</Label>
965 <Input
966 id="website_url"
967 type="url"
968 value={form.website_url}
969 placeholder="https://your-agency.com"
970 onChange={(e) => updateField("website_url", e.target.value)}
971 />
972 </div>
973 <div className="space-y-1.5">
974 <Label htmlFor="support_url">{__("Support URL", "yatra")}</Label>
975 <Input
976 id="support_url"
977 type="url"
978 value={form.support_url}
979 placeholder="https://your-agency.com/support"
980 onChange={(e) => updateField("support_url", e.target.value)}
981 />
982 </div>
983 <div className="space-y-1.5 md:col-span-2">
984 <Label htmlFor="logo_url">{__("Logo", "yatra")}</Label>
985 <div className="flex flex-wrap items-start gap-3">
986 {form.logo_url ? (
987 <div className="relative flex h-20 w-20 items-center justify-center overflow-hidden rounded-md border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
988 <img
989 src={form.logo_url}
990 alt={__("Brand logo preview", "yatra")}
991 className="max-h-full max-w-full object-contain"
992 />
993 <button
994 type="button"
995 onClick={() => updateField("logo_url", "")}
996 className="absolute right-1 top-1 rounded-full bg-white/90 p-0.5 text-gray-600 shadow hover:text-red-600 dark:bg-gray-900/80 dark:text-gray-300"
997 aria-label={__("Remove logo", "yatra")}
998 >
999 <X className="h-3.5 w-3.5" />
1000 </button>
1001 </div>
1002 ) : (
1003 <div className="flex h-20 w-20 items-center justify-center rounded-md border border-dashed border-gray-300 bg-gray-50 text-gray-400 dark:border-gray-700 dark:bg-gray-900/40">
1004 <ImageIcon className="h-6 w-6" />
1005 </div>
1006 )}
1007 <div className="flex flex-1 min-w-0 flex-col gap-2">
1008 <div className="flex flex-wrap gap-2">
1009 <Button
1010 type="button"
1011 variant="outline"
1012 onClick={handlePickLogo}
1013 >
1014 <ImageIcon className="mr-2 h-4 w-4" />
1015 {form.logo_url
1016 ? __("Change logo", "yatra")
1017 : __("Choose from media library", "yatra")}
1018 </Button>
1019 {form.logo_url && (
1020 <Button
1021 type="button"
1022 variant="ghost"
1023 onClick={() => updateField("logo_url", "")}
1024 >
1025 {__("Remove", "yatra")}
1026 </Button>
1027 )}
1028 </div>
1029 <Input
1030 id="logo_url"
1031 type="url"
1032 value={form.logo_url}
1033 placeholder="https://your-agency.com/logo.png"
1034 onChange={(e) => updateField("logo_url", e.target.value)}
1035 />
1036 <p className="text-xs text-gray-500 dark:text-gray-400">
1037 {__(
1038 "Used for the admin menu icon and PDF header. Square PNG, 64×64 or larger recommended.",
1039 "yatra",
1040 )}
1041 </p>
1042 </div>
1043 </div>
1044 </div>
1045 </CardContent>
1046 </Card>
1047 );
1048
1049 const renderTheme = () => {
1050 const updateSurface = (key: keyof ThemeSurfaces, value: string) => {
1051 setForm((prev) => {
1052 const next = { ...(prev.theme_surfaces ?? {}) };
1053 if (value === "") {
1054 delete next[key];
1055 } else {
1056 next[key] = value;
1057 }
1058 return { ...prev, theme_surfaces: next };
1059 });
1060 };
1061
1062 const applyPreset = (surfaces: Required<ThemeSurfaces>) => {
1063 setForm((prev) => ({ ...prev, theme_surfaces: { ...surfaces } }));
1064 };
1065
1066 const surfaceFields: Array<{
1067 key: keyof ThemeSurfaces;
1068 label: string;
1069 fallback: string;
1070 }> = [
1071 {
1072 key: "sidebar_bg",
1073 label: __("Sidebar background", "yatra"),
1074 fallback: "#ffffff",
1075 },
1076 {
1077 key: "sidebar_text",
1078 label: __("Sidebar text", "yatra"),
1079 fallback: "#374151",
1080 },
1081 {
1082 key: "topbar_bg",
1083 label: __("Top bar background", "yatra"),
1084 fallback: "#ffffff",
1085 },
1086 {
1087 key: "topbar_text",
1088 label: __("Top bar text", "yatra"),
1089 fallback: "#111827",
1090 },
1091 ];
1092
1093 const hasAnySurface = Object.values(form.theme_surfaces ?? {}).some(
1094 (v) => typeof v === "string" && v !== "",
1095 );
1096
1097 return (
1098 <div className="space-y-4">
1099 {/* ------ Color Scheme (brand accent) ------------------------- */}
1100 <Card>
1101 <CardHeader>
1102 <div className="flex items-center gap-2">
1103 <Palette className="h-4 w-4 text-gray-500" />
1104 <CardTitle>{__("Color scheme", "yatra")}</CardTitle>
1105 </div>
1106 <CardDescription>
1107 {__(
1108 "Brand accent color used for buttons, active menu items, and badges across the admin.",
1109 "yatra",
1110 )}
1111 </CardDescription>
1112 </CardHeader>
1113 <CardContent>
1114 <div className="flex flex-wrap items-center gap-3">
1115 <input
1116 type="color"
1117 value={form.primary_color || "#2563eb"}
1118 onChange={(e) => updateField("primary_color", e.target.value)}
1119 className="h-10 w-12 cursor-pointer rounded border border-gray-300 bg-white p-1 dark:border-gray-700 dark:bg-gray-800"
1120 aria-label={__("Pick brand accent color", "yatra")}
1121 />
1122 <Input
1123 value={form.primary_color}
1124 placeholder="#2563eb"
1125 onChange={(e) => updateField("primary_color", e.target.value)}
1126 className="max-w-[200px]"
1127 />
1128 {form.primary_color && (
1129 <Button
1130 type="button"
1131 variant="ghost"
1132 onClick={() => updateField("primary_color", "")}
1133 >
1134 {__("Reset to default", "yatra")}
1135 </Button>
1136 )}
1137 </div>
1138 <div className="mt-4 flex flex-wrap gap-2">
1139 {[
1140 { label: "Yatra Blue", color: "" },
1141 { label: "Emerald", color: "#10b981" },
1142 { label: "Indigo", color: "#6366f1" },
1143 { label: "Rose", color: "#f43f5e" },
1144 { label: "Slate", color: "#475569" },
1145 { label: "Amber", color: "#d97706" },
1146 ].map((preset) => (
1147 <button
1148 key={preset.label}
1149 type="button"
1150 onClick={() => updateField("primary_color", preset.color)}
1151 className="inline-flex items-center gap-2 rounded-md border border-gray-200 px-2 py-1 text-xs text-gray-700 hover:border-gray-300 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-800"
1152 >
1153 <span
1154 className="inline-block h-4 w-4 rounded-sm border border-gray-200 dark:border-gray-700"
1155 style={{ background: preset.color || "#2563eb" }}
1156 />
1157 {preset.label}
1158 </button>
1159 ))}
1160 </div>
1161 <p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
1162 {__("Leave blank to keep the default Yatra blue.", "yatra")}
1163 </p>
1164 </CardContent>
1165 </Card>
1166
1167 {/* ------ Theme (sidebar + topbar surfaces) ------------------- */}
1168 <Card>
1169 <CardHeader>
1170 <div className="flex items-center gap-2">
1171 <Sparkles className="h-4 w-4 text-gray-500" />
1172 <CardTitle>{__("Theme", "yatra")}</CardTitle>
1173 </div>
1174 <CardDescription>
1175 {__(
1176 "Like a custom light/dark mode — pick a preset to repaint the sidebar and top bar in one click, or fine-tune the four colors below.",
1177 "yatra",
1178 )}
1179 </CardDescription>
1180 </CardHeader>
1181 <CardContent>
1182 {/* Preset row */}
1183 <div className="mb-4 flex flex-wrap gap-2">
1184 {THEME_PRESETS.map((preset) => (
1185 <button
1186 key={preset.label}
1187 type="button"
1188 onClick={() => applyPreset(preset.surfaces)}
1189 className="inline-flex items-center gap-2 rounded-md border border-gray-200 px-2 py-1 text-xs font-medium text-gray-700 hover:border-gray-300 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-800"
1190 >
1191 {/* 4-dot swatch preview */}
1192 <span className="inline-flex h-4 w-4 overflow-hidden rounded-sm border border-gray-200 dark:border-gray-700">
1193 <span
1194 className="block w-1/2"
1195 style={{ background: preset.surfaces.sidebar_bg }}
1196 />
1197 <span
1198 className="block w-1/2"
1199 style={{ background: preset.surfaces.topbar_bg }}
1200 />
1201 </span>
1202 {preset.label}
1203 </button>
1204 ))}
1205 {hasAnySurface && (
1206 <button
1207 type="button"
1208 onClick={() => updateField("theme_surfaces", {})}
1209 className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-200"
1210 >
1211 {__("Reset theme", "yatra")}
1212 </button>
1213 )}
1214 </div>
1215
1216 {/* Per-color editors */}
1217 <div className="grid gap-3 md:grid-cols-2">
1218 {surfaceFields.map(({ key, label, fallback }) => {
1219 const value = (form.theme_surfaces ?? {})[key] ?? "";
1220 return (
1221 <div
1222 key={key}
1223 className="flex items-center gap-2 rounded-md border border-gray-200 p-2 dark:border-gray-700"
1224 >
1225 <input
1226 type="color"
1227 value={value || fallback}
1228 onChange={(e) => updateSurface(key, e.target.value)}
1229 className="h-9 w-10 cursor-pointer rounded border border-gray-300 bg-white p-1 dark:border-gray-600 dark:bg-gray-800"
1230 aria-label={label}
1231 />
1232 <div className="flex-1 min-w-0">
1233 <div className="text-xs font-medium text-gray-700 dark:text-gray-200">
1234 {label}
1235 </div>
1236 <Input
1237 value={value}
1238 placeholder={fallback}
1239 onChange={(e) => updateSurface(key, e.target.value)}
1240 className="mt-1 max-w-full"
1241 />
1242 </div>
1243 {value && (
1244 <button
1245 type="button"
1246 onClick={() => updateSurface(key, "")}
1247 className="text-xs text-gray-400 hover:text-red-600"
1248 aria-label={__("Clear this color", "yatra")}
1249 >
1250 <X className="h-3.5 w-3.5" />
1251 </button>
1252 )}
1253 </div>
1254 );
1255 })}
1256 </div>
1257 <p className="mt-3 text-xs text-gray-500 dark:text-gray-400">
1258 {__(
1259 "Each color is independent — clear one to fall back to its Yatra default while keeping the others.",
1260 "yatra",
1261 )}
1262 </p>
1263 </CardContent>
1264 </Card>
1265 </div>
1266 );
1267 };
1268
1269 const renderMenu = () => {
1270 const hasAnyOverrides =
1271 Object.keys(form.menu_order ?? {}).length > 0 ||
1272 Object.values(form.menu_overrides ?? {}).some(
1273 (ov) => ov && ov.parent !== undefined,
1274 );
1275
1276 return (
1277 <Card>
1278 <CardHeader>
1279 <CardTitle>{__("Sidebar menu", "yatra")}</CardTitle>
1280 <CardDescription>
1281 {__(
1282 "Drag items to reorder, nest, or promote — like the WordPress Menus screen. Drop on the top or bottom edge of a row to place above or below; drop on the middle of a top-level row to nest the item underneath. Nesting is one level deep.",
1283 "yatra",
1284 )}
1285 </CardDescription>
1286 </CardHeader>
1287 <CardContent>
1288 <div className="flex flex-col gap-2">
1289 {renderRows.map((row) => {
1290 const ov = form.menu_overrides?.[row.atom.key] ?? {};
1291 const effectiveOwnParent = effectiveParent(row.atom.key);
1292 const isTopLevel = effectiveOwnParent === "";
1293 const showsChildren =
1294 isTopLevel && hasChildrenByParent.has(row.atom.key);
1295 const expanded = expandedSubmenus.has(row.atom.key);
1296 const isDropTarget =
1297 dropTarget !== null &&
1298 dropTarget.key === row.atom.key &&
1299 draggingSlug !== row.atom.key;
1300 return (
1301 <div
1302 key={row.atom.key}
1303 style={row.depth > 0 ? { marginLeft: 32 } : undefined}
1304 >
1305 <div className="flex items-stretch gap-1">
1306 <div className="flex-1">
1307 <MenuRow
1308 slug={row.atom.key}
1309 defaultLabel={row.atom.defaultLabel}
1310 defaultIconName={row.atom.defaultIconName}
1311 override={ov}
1312 isSubmenu={row.depth > 0}
1313 draggable
1314 isDragging={draggingSlug === row.atom.key}
1315 dropPosition={
1316 isDropTarget ? dropTarget!.position : null
1317 }
1318 onDragStart={(e) => handleDragStart(row.atom.key, e)}
1319 onDragOver={(e) => handleRowDragOver(row.atom.key, e)}
1320 onDragEnd={handleDragEnd}
1321 onDragLeave={handleRowDragLeave}
1322 onDrop={(e) => handleRowDrop(row.atom.key, e)}
1323 onPatch={(patch) =>
1324 updateMenuOverride(row.atom.key, patch)
1325 }
1326 />
1327 </div>
1328 {showsChildren && (
1329 <button
1330 type="button"
1331 onClick={() => toggleSubmenuExpanded(row.atom.key)}
1332 className="inline-flex items-center justify-center rounded-md border border-gray-200 px-2 text-gray-500 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
1333 aria-expanded={expanded}
1334 aria-label={__("Toggle submenu", "yatra")}
1335 >
1336 {expanded ? (
1337 <ChevronDown className="h-4 w-4" />
1338 ) : (
1339 <ChevronRight className="h-4 w-4" />
1340 )}
1341 </button>
1342 )}
1343 </div>
1344 </div>
1345 );
1346 })}
1347 </div>
1348 {hasAnyOverrides && (
1349 <div className="mt-4 flex justify-end">
1350 <Button
1351 type="button"
1352 variant="ghost"
1353 onClick={() => {
1354 // Clear order + any parent overrides; keep label/icon/hidden.
1355 updateField("menu_order", {});
1356 setForm((prev) => {
1357 const next = { ...(prev.menu_overrides ?? {}) };
1358 for (const [key, ov] of Object.entries(next)) {
1359 if (ov && ov.parent !== undefined) {
1360 const stripped = { ...ov };
1361 delete stripped.parent;
1362 if (Object.keys(stripped).length === 0) {
1363 delete next[key];
1364 } else {
1365 next[key] = stripped;
1366 }
1367 }
1368 }
1369 return { ...prev, menu_overrides: next };
1370 });
1371 }}
1372 >
1373 {__("Reset structure to default", "yatra")}
1374 </Button>
1375 </div>
1376 )}
1377 </CardContent>
1378 </Card>
1379 );
1380 };
1381
1382 const renderChrome = () => {
1383 const items: Array<{
1384 key: keyof UiChromeFlags;
1385 label: string;
1386 description: string;
1387 }> = [
1388 {
1389 key: "hideVersion",
1390 label: __("Hide version number", "yatra"),
1391 description: __(
1392 'Removes the small "v3.0.4" tag shown next to the brand name in the sidebar header.',
1393 "yatra",
1394 ),
1395 },
1396 {
1397 key: "hideBackToWp",
1398 label: __('Hide "Back to WordPress" link', "yatra"),
1399 description: __(
1400 "Removes the link from the top bar AND the sidebar footer so clients don't see the wp-admin escape hatch.",
1401 "yatra",
1402 ),
1403 },
1404 {
1405 key: "hideJoinCommunity",
1406 label: __('Hide "Join Community" link', "yatra"),
1407 description: __(
1408 "Removes the Facebook community button from the top bar.",
1409 "yatra",
1410 ),
1411 },
1412 ];
1413
1414 return (
1415 <Card>
1416 <CardHeader>
1417 <CardTitle>{__("UI chrome", "yatra")}</CardTitle>
1418 <CardDescription>
1419 {__(
1420 "Hide or show specific pieces of admin chrome — useful when delivering the app to clients who shouldn't be reminded of the underlying platform.",
1421 "yatra",
1422 )}
1423 </CardDescription>
1424 </CardHeader>
1425 <CardContent className="space-y-3">
1426 {items.map((item) => {
1427 const checked = !form.ui_chrome?.[item.key];
1428 return (
1429 <label
1430 key={item.key}
1431 className="flex items-start gap-3 rounded-md border border-gray-200 p-3 dark:border-gray-700"
1432 >
1433 <input
1434 type="checkbox"
1435 className="mt-1"
1436 checked={!checked}
1437 onChange={() => toggleUiChrome(item.key)}
1438 aria-label={item.label}
1439 />
1440 <div className="flex-1">
1441 <div className="text-sm font-medium text-gray-900 dark:text-white">
1442 {item.label}
1443 </div>
1444 <div className="text-xs text-gray-500 dark:text-gray-400">
1445 {item.description}
1446 </div>
1447 </div>
1448 <span
1449 className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ${
1450 checked
1451 ? "bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-300"
1452 : "bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-300"
1453 }`}
1454 >
1455 {checked ? (
1456 <>
1457 <Eye className="h-3 w-3" />
1458 {__("Visible", "yatra")}
1459 </>
1460 ) : (
1461 <>
1462 <EyeOff className="h-3 w-3" />
1463 {__("Hidden", "yatra")}
1464 </>
1465 )}
1466 </span>
1467 </label>
1468 );
1469 })}
1470 </CardContent>
1471 </Card>
1472 );
1473 };
1474
1475 const currentSection =
1476 SECTIONS.find((s) => s.id === activeSection) ?? SECTIONS[0];
1477
1478 return (
1479 <div className="space-y-6">
1480 <PageHeader
1481 description={__(
1482 "Replace Yatra/MantraBrain branding shown in the admin and on PDFs.",
1483 "yatra",
1484 )}
1485 />
1486
1487 {!isModuleEnabled && (
1488 <Card>
1489 <CardContent className="flex items-start gap-3 py-4">
1490 <Lock className="mt-0.5 h-5 w-5 text-amber-500" />
1491 <div className="text-sm">
1492 <p className="font-medium">{__("Module disabled", "yatra")}</p>
1493 <p className="text-gray-600 dark:text-gray-300">
1494 {__(
1495 "Your Scale license is active, but the White Label module is not enabled yet. Enable it from the Modules page so branding overrides take effect.",
1496 "yatra",
1497 )}
1498 </p>
1499 <a
1500 className="mt-1 inline-block text-sm font-medium text-blue-600 hover:underline dark:text-blue-400"
1501 href="admin.php?page=yatra&subpage=modules"
1502 >
1503 {__("Go to Modules →", "yatra")}
1504 </a>
1505 </div>
1506 </CardContent>
1507 </Card>
1508 )}
1509
1510 <form onSubmit={onSubmit}>
1511 <div className="grid gap-6 lg:grid-cols-[240px_1fr]">
1512 {/* Section sidebar */}
1513 <aside className="lg:sticky lg:top-4 lg:self-start">
1514 <Card>
1515 <CardContent className="p-2">
1516 <nav className="flex flex-col gap-1">
1517 {SECTIONS.map((section) => {
1518 const Icon = section.icon;
1519 const active = section.id === activeSection;
1520 return (
1521 <button
1522 key={section.id}
1523 type="button"
1524 onClick={() => setActiveSection(section.id)}
1525 className={`flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors ${
1526 active
1527 ? "bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
1528 : "text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-800"
1529 }`}
1530 >
1531 <Icon className="h-4 w-4 shrink-0" />
1532 <span className="flex-1 truncate">{section.label}</span>
1533 </button>
1534 );
1535 })}
1536 </nav>
1537 </CardContent>
1538 </Card>
1539 <div className="mt-3 px-2 text-xs text-gray-500 dark:text-gray-400">
1540 {currentSection.description}
1541 </div>
1542 </aside>
1543
1544 {/* Section content */}
1545 <div className="space-y-4">
1546 {activeSection === "brand" && renderBrand()}
1547 {activeSection === "theme" && renderTheme()}
1548 {activeSection === "menu" && renderMenu()}
1549 {activeSection === "chrome" && renderChrome()}
1550
1551 <div className="flex items-center gap-3">
1552 <Button type="submit" disabled={saveMutation.isPending}>
1553 {saveMutation.isPending ? (
1554 <>
1555 <Loader2 className="mr-2 h-4 w-4 animate-spin" />
1556 {__("Saving…", "yatra")}
1557 </>
1558 ) : (
1559 <>
1560 <Save className="mr-2 h-4 w-4" />
1561 {__("Save White Label settings", "yatra")}
1562 </>
1563 )}
1564 </Button>
1565 <span className="text-xs text-gray-400">
1566 <Sparkles className="-mt-0.5 mr-1 inline h-3 w-3" />
1567 {__("Saves all sections at once.", "yatra")}
1568 </span>
1569 </div>
1570 </div>
1571 </div>
1572 </form>
1573 </div>
1574 );
1575 };
1576
1577 export default WhiteLabel;
1578