PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.3
Yatra – Travel Booking & Tour Operator Software v3.0.3
3.0.16 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 All 84 releases
yatra / resources / js / pages / Categories.tsx

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

1,231 lines 40.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Categories Page
3 * Clean, minimal SaaS-style categories management page with subcategory support
4 */
5
6 import React, { useState, useMemo, useEffect } from "react";
7 import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
8 import { Plus, Edit, Trash2, ExternalLink } from "lucide-react";
9 import {
10 SearchFilterToolbar,
11 Table as SharedTable,
12 BulkActionToolbar,
13 Pagination,
14 } from "../components/shared";
15 import { getDefaultBulkStatusOptions } from "../components/shared/bulkStatusOptions";
16 import { __ } from "../lib/i18n";
17 import { usePermissions } from "../hooks/usePermissions";
18 import { useToast } from "../components/ui/toast";
19 import { fetchSettings } from "../api/settings-api";
20 import { apiClient } from "../lib/api-client";
21 import { getErrorContext } from "../lib/errors";
22 import {
23 buildYatraSinglePublicUrls,
24 isWordPressPlainPermalink,
25 } from "../lib/frontend-permalink-urls";
26 import { Button } from "../components/ui/button";
27 import { Select } from "../components/ui/select";
28 import { PageHeader } from "../components/common/PageHeader";
29 import { Card, CardContent } from "../components/ui/card";
30 import { ConfirmationDialog } from "../components/ui/confirmation-dialog";
31 import { IconSelector } from "../components/ui/icon-selector";
32 import type { IconPickerValue } from "../components/ui/icon-picker";
33
34 interface Category {
35 id: number;
36 name: string;
37 slug: string;
38 description?: string;
39 icon?: IconPickerValue | null;
40 trip_count?: number;
41 parent_id?: number | null;
42 parent_name?: string | null;
43 status: string;
44 metadata?: {
45 seo_title?: string;
46 seo_description?: string;
47 seo_keywords?: string;
48 [key: string]: any;
49 };
50 created_at: string;
51 updated_at: string;
52 created_by: number;
53 updated_by: number;
54 created_by_name?: string;
55 updated_by_name?: string;
56 subcategories?: Category[];
57 __subOnly?: boolean;
58 }
59
60 export const Categories: React.FC = () => {
61 const { can } = usePermissions();
62 const [searchTerm, setSearchTerm] = useState("");
63 const [statusFilter, setStatusFilter] = useState("all");
64 const [parentFilter, setParentFilter] = useState<
65 "all" | "top-level" | "subcategories"
66 >("all");
67 const [sortBy, setSortBy] = useState("name");
68 const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
69 const [page, setPage] = useState(1);
70 const [expandedCategories, setExpandedCategories] = useState<Set<number>>(
71 new Set(),
72 );
73 const [deleteConfirm, setDeleteConfirm] = useState<{
74 isOpen: boolean;
75 category: Category | null;
76 }>({
77 isOpen: false,
78 category: null,
79 });
80 // Bulk action state
81 const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]);
82 const [bulkAction, setBulkAction] = useState("");
83 const [showColumnsDropdown, setShowColumnsDropdown] = useState(false);
84 const [visibleColumns, setVisibleColumns] = useState(() => {
85 const defaultColumns = {
86 name: true,
87 description: true,
88 trips: true,
89 status: true,
90 seo_title: false,
91 seo_description: false,
92 seo_keywords: false,
93 created_at: true,
94 };
95 const saved = localStorage.getItem("yatra_categories_columns_v2");
96 return saved ? { ...defaultColumns, ...JSON.parse(saved) } : defaultColumns;
97 });
98
99 const queryClient = useQueryClient();
100 const { showToast } = useToast();
101
102 // Build query params
103 const queryParams = useMemo(() => {
104 const params: Record<string, any> = {
105 page,
106 per_page: 50, // Get more items to handle hierarchical display
107 orderby: sortBy,
108 order: sortOrder,
109 hierarchical: true, // Request hierarchical data
110 };
111
112 if (searchTerm) {
113 params.search = searchTerm;
114 }
115
116 if (statusFilter !== "all") {
117 params.status = statusFilter;
118 }
119
120 if (parentFilter === "top-level") {
121 params.parent_id = null;
122 } else if (parentFilter === "subcategories") {
123 // We'll filter client-side for subcategories
124 }
125
126 return params;
127 }, [searchTerm, statusFilter, parentFilter, sortBy, sortOrder, page]);
128
129 // Fetch categories from API
130 const { data, isLoading, error } = useQuery({
131 queryKey: ["trip-categories", queryParams],
132 queryFn: async () => {
133 try {
134 const response = await apiClient.get("/trip-categories", {
135 params: queryParams,
136 });
137 return response;
138 } catch (error: any) {
139 showToast(
140 error?.message || __("Failed to load categories", "yatra"),
141 "error",
142 );
143 throw error;
144 }
145 },
146 enabled: can("yatra_view_trips"),
147 });
148
149 // Delete mutation
150 const deleteMutation = useMutation({
151 mutationFn: async (id: number) => {
152 return await apiClient.delete(`/trip-categories/${id}`);
153 },
154 onSuccess: () => {
155 queryClient.invalidateQueries({ queryKey: ["trip-categories"] });
156 showToast(__("Category deleted successfully", "yatra"), "success");
157 setDeleteConfirm({ isOpen: false, category: null });
158 },
159 onError: (error: any) => {
160 showToast(
161 error?.message || __("Failed to delete category", "yatra"),
162 "error",
163 );
164 },
165 });
166
167 const categories = data?.data || [];
168 const total = data?.total || 0;
169 const totalPages = Math.ceil(total / 50);
170 const errorContext = getErrorContext(error);
171
172 // Toggle column visibility
173 const toggleColumn = (columnKey: string) => {
174 const newVisibleColumns = {
175 ...visibleColumns,
176 [columnKey]: !visibleColumns[columnKey],
177 };
178
179 setVisibleColumns(newVisibleColumns);
180 localStorage.setItem(
181 "yatra_categories_columns_v2",
182 JSON.stringify(newVisibleColumns),
183 );
184 };
185
186 // Status counts for bulk toolbar - count both parents and children
187 const statusCounts = useMemo(() => {
188 const counts = {
189 all: 0,
190 publish: 0,
191 draft: 0,
192 trash: 0,
193 };
194
195 const countCategory = (cat: Category) => {
196 counts.all++;
197 if (cat.status === "publish") counts.publish++;
198 else if (cat.status === "draft") counts.draft++;
199 else if (cat.status === "trash") counts.trash++;
200 };
201
202 categories.forEach((cat: Category) => {
203 // Count parent category
204 countCategory(cat);
205
206 // Count subcategories
207 if (cat.subcategories && Array.isArray(cat.subcategories)) {
208 cat.subcategories.forEach((sub: Category) => {
209 countCategory(sub);
210 });
211 }
212 });
213
214 return counts;
215 }, [categories]);
216
217 // Bulk mutation for status changes and deletes
218 const bulkMutation = useMutation({
219 mutationFn: async ({
220 action,
221 ids,
222 }: {
223 action: string;
224 ids: (string | number)[];
225 }) => {
226 if (action === "delete") {
227 await Promise.all(
228 ids.map((id) => apiClient.delete(`/trip-categories/${id}`)),
229 );
230 } else {
231 // Status change - fetch each category and update with full payload
232 await Promise.all(
233 ids.map(async (id) => {
234 try {
235 const categoryResponse = await apiClient.get(
236 `/trip-categories/${id}`,
237 );
238 const category = categoryResponse;
239 if (!category || !category.name) {
240 return;
241 }
242
243 await apiClient.put(`/trip-categories/${id}`, {
244 name: category.name,
245 slug: category.slug,
246 description: category.description,
247 icon: category.icon,
248 parent_id: category.parent_id,
249 status: action,
250 });
251 } catch {
252 throw new Error("bulk_item_failed");
253 }
254 }),
255 );
256 }
257 },
258 onSuccess: (_data, variables) => {
259 queryClient.invalidateQueries({ queryKey: ["trip-categories"] });
260 const msgMap: Record<string, string> = {
261 delete: __("Selected categories deleted successfully", "yatra"),
262 trash: __("Selected categories moved to trash", "yatra"),
263 draft: __("Selected categories marked as draft", "yatra"),
264 publish: __("Selected categories published", "yatra"),
265 };
266 showToast(
267 msgMap[variables.action] || __("Bulk action completed", "yatra"),
268 "success",
269 );
270 setSelectedIds([]);
271 setBulkAction("");
272 },
273 onError: (error: any, variables) => {
274 const msgMapError: Record<string, string> = {
275 delete: __("Failed to delete selected categories", "yatra"),
276 trash: __("Failed to move categories to trash", "yatra"),
277 draft: __("Failed to mark categories as draft", "yatra"),
278 publish: __("Failed to publish categories", "yatra"),
279 };
280 showToast(
281 error?.message ||
282 msgMapError[variables.action] ||
283 __("Bulk action failed", "yatra"),
284 "error",
285 );
286 },
287 });
288
289 const handleBulkApply = () => {
290 if (!bulkAction) {
291 showToast(__("Select a bulk action first.", "yatra"), "warning");
292 return;
293 }
294
295 if (selectedIds.length === 0) {
296 showToast(__("Select at least one category.", "yatra"), "warning");
297 return;
298 }
299
300 bulkMutation.mutate({ action: bulkAction, ids: selectedIds });
301 };
302
303 // Process categories for hierarchical display
304 const processedCategories = useMemo(() => {
305 if (!Array.isArray(categories) || categories.length === 0) {
306 return [];
307 }
308
309 const normalized = categories.map((cat: Category) => ({
310 ...cat,
311 subcategories: Array.isArray(cat.subcategories) ? cat.subcategories : [],
312 }));
313
314 const term = searchTerm.trim().toLowerCase();
315
316 const matchesSearch = (cat: Category): boolean => {
317 if (!term) return true;
318 const name = cat.name?.toLowerCase() || "";
319 const slug = cat.slug?.toLowerCase() || "";
320 const desc = cat.description?.toLowerCase() || "";
321 return name.includes(term) || slug.includes(term) || desc.includes(term);
322 };
323
324 // Apply status + search filtering to both parents and children
325 const filterByStatusAndSearch = (items: Category[]): Category[] => {
326 return items
327 .map((cat: Category) => {
328 // Filter subcategories by status + search
329 const filteredSubcategories = cat.subcategories
330 ? cat.subcategories.filter((sub: Category) => {
331 const statusOk =
332 statusFilter === "all" || sub.status === statusFilter;
333 const searchOk = matchesSearch(sub);
334 return statusOk && searchOk;
335 })
336 : [];
337
338 return {
339 ...cat,
340 subcategories: filteredSubcategories,
341 };
342 })
343 .filter((cat: Category) => {
344 // When no status/search filters, keep all
345 if (statusFilter === "all" && !term) return true;
346
347 const statusOk =
348 statusFilter === "all" || cat.status === statusFilter;
349 const searchOk = matchesSearch(cat);
350 const hasMatchingChildren =
351 cat.subcategories && cat.subcategories.length > 0;
352
353 // Show parent if it itself matches filters OR has children that match
354 return (statusOk && searchOk) || hasMatchingChildren;
355 });
356 };
357
358 const filtered = filterByStatusAndSearch(normalized);
359
360 if (parentFilter === "subcategories") {
361 const onlySubs: Category[] = filtered.flatMap((cat: Category) =>
362 (cat.subcategories || []).map((sub) => ({
363 ...sub,
364 parent_name: cat.name,
365 __subOnly: true,
366 subcategories: [],
367 })),
368 );
369 return onlySubs;
370 }
371
372 if (parentFilter === "top-level") {
373 return filtered.map((cat) => ({
374 ...cat,
375 subcategories: [], // Don't show subcategories when filtering for top-level only
376 }));
377 }
378
379 return filtered;
380 }, [categories, parentFilter, statusFilter, searchTerm]);
381
382 // Fetch settings for permalink handling
383 const { data: settings } = useQuery({
384 queryKey: ["settings"],
385 queryFn: async () => {
386 try {
387 const response = await fetchSettings();
388 return response;
389 } catch (error) {
390 return null;
391 }
392 },
393 enabled: can("manage_yatra"),
394 staleTime: 5 * 60 * 1000, // Cache for 5 minutes
395 });
396
397 // Expand all parent categories that have subcategories by default
398 useEffect(() => {
399 if (expandedCategories.size > 0) return;
400
401 const initialExpanded = new Set<number>();
402 processedCategories.forEach((cat: Category) => {
403 if (!cat.__subOnly && cat.subcategories && cat.subcategories.length > 0) {
404 initialExpanded.add(cat.id);
405 }
406 });
407
408 if (initialExpanded.size > 0) {
409 setExpandedCategories(initialExpanded);
410 }
411 }, [processedCategories, expandedCategories.size]);
412
413 const formatDate = (dateString: string) => {
414 if (!dateString) return __("N/A", "yatra");
415 try {
416 const date = new Date(dateString);
417 return date.toLocaleDateString("en-US", {
418 month: "short",
419 day: "numeric",
420 year: "numeric",
421 hour: "2-digit",
422 minute: "2-digit",
423 });
424 } catch (e) {
425 return dateString;
426 }
427 };
428
429 const getStatusBadge = (status: string) => {
430 const statusMap: Record<string, { className: string; label: string }> = {
431 publish: {
432 className:
433 "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400",
434 label: __("Publish", "yatra"),
435 },
436 draft: {
437 className:
438 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
439 label: __("Draft", "yatra"),
440 },
441 trash: {
442 className:
443 "bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400",
444 label: __("Trash", "yatra"),
445 },
446 };
447
448 const statusInfo = statusMap[status] || {
449 className:
450 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
451 label: status,
452 };
453
454 return (
455 <span
456 className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-medium ${statusInfo.className}`}
457 >
458 {statusInfo.label}
459 </span>
460 );
461 };
462
463 const renderIcon = (icon: IconPickerValue | null | undefined) => {
464 if (!icon) {
465 return (
466 <div className="w-8 h-8 rounded bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
467 <span className="text-xs text-gray-400 dark:text-gray-500">—</span>
468 </div>
469 );
470 }
471
472 if (icon.type === "image") {
473 return (
474 <img
475 src={icon.value}
476 alt=""
477 className="w-8 h-8 rounded object-cover"
478 onError={(e) => {
479 const target = e.target as HTMLImageElement;
480 target.style.display = "none";
481 if (target.parentElement) {
482 target.parentElement.innerHTML =
483 '<div class="w-8 h-8 rounded bg-gray-100 dark:bg-gray-800 flex items-center justify-center"><span class="text-xs text-gray-400 dark:text-gray-500">—</span></div>';
484 }
485 }}
486 />
487 );
488 }
489
490 return (
491 <div className="w-8 h-8 rounded bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
492 <IconSelector
493 iconName={icon.value}
494 provider={icon.provider ?? "yatra"}
495 className="w-5 h-5 text-gray-600 dark:text-gray-400"
496 />
497 </div>
498 );
499 };
500
501 const toggleExpand = (categoryId: string | number) => {
502 const id =
503 typeof categoryId === "string" ? parseInt(categoryId, 10) : categoryId;
504 setExpandedCategories((prev) => {
505 const newSet = new Set(prev);
506 if (newSet.has(id)) {
507 newSet.delete(id);
508 } else {
509 newSet.add(id);
510 }
511 return newSet;
512 });
513 };
514
515 const handleEdit = (category: Category) => {
516 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&tab=categories&action=edit&id=${category.id}`;
517 };
518
519 const handleView = async (category: Category) => {
520 const categorySlug = category.slug || "";
521 const { plainUrl, prettyUrl } = buildYatraSinglePublicUrls({
522 entity: "category",
523 slug: categorySlug,
524 bases: settings as Record<string, unknown> | null,
525 });
526
527 if (isWordPressPlainPermalink()) {
528 if (!categorySlug) {
529 showToast(__("Category slug is missing", "yatra"), "error");
530 return;
531 }
532 window.open(plainUrl, "_blank", "noopener,noreferrer");
533 return;
534 }
535
536 let apiPermalink = (category as any)?.permalink || (category as any)?.url;
537
538 if (!apiPermalink && category.id) {
539 try {
540 const detail = await apiClient.get(`/trip-categories/${category.id}`);
541 apiPermalink =
542 (detail as any)?.permalink || (detail as any)?.url || apiPermalink;
543 } catch (e) {
544 // fall through to prettyUrl
545 }
546 }
547
548 if (apiPermalink) {
549 window.open(apiPermalink, "_blank", "noopener,noreferrer");
550 return;
551 }
552
553 if (!categorySlug) {
554 showToast(__("Category slug is missing", "yatra"), "error");
555 return;
556 }
557
558 window.open(prettyUrl, "_blank", "noopener,noreferrer");
559 };
560
561 const confirmDelete = () => {
562 if (deleteConfirm.category) {
563 deleteMutation.mutate(deleteConfirm.category.id);
564 }
565 };
566
567 const handleCreateCategory = () => {
568 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&tab=categories&action=create`;
569 };
570
571 const handleResetFilters = () => {
572 setSearchTerm("");
573 setStatusFilter("all");
574 setParentFilter("all");
575 setSortBy("name");
576 setSortOrder("asc");
577 setPage(1);
578 };
579
580 const hasFilters =
581 searchTerm ||
582 statusFilter !== "all" ||
583 parentFilter !== "all" ||
584 sortBy !== "name" ||
585 sortOrder !== "asc";
586
587 // Define columns for the shared table
588 const columns = useMemo(
589 () => [
590 {
591 key: "name",
592 label: __("Name", "yatra"),
593 sortable: true,
594 visible: visibleColumns.name,
595 render: (category: Category) => {
596 const editUrl = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&tab=categories&action=edit&id=${category.id}`;
597 return (
598 <div className="flex items-start gap-2">
599 <div className="flex-1 min-w-0">
600 <div className="flex items-center gap-1">
601 <a
602 href={editUrl}
603 onClick={(e) => {
604 e.preventDefault();
605 e.stopPropagation();
606 handleEdit(category);
607 }}
608 className="font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 underline-offset-2 hover:underline truncate"
609 >
610 {category.name}
611 </a>
612 {can("yatra_view_trips") && category.status !== "trash" && (
613 <button
614 type="button"
615 onClick={(e) => {
616 e.stopPropagation();
617 handleView(category);
618 }}
619 className="ml-1 inline-flex items-center justify-center rounded-full p-1 text-gray-400 hover:text-gray-700 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-800"
620 title={__("View category in new tab", "yatra")}
621 >
622 <ExternalLink className="w-3.5 h-3.5" />
623 </button>
624 )}
625 </div>
626 <div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
627 {category.slug}
628 <span className="ml-1 text-[11px] text-gray-400 dark:text-gray-500">
629 (ID: {category.id})
630 </span>
631 </div>
632 </div>
633 </div>
634 );
635 },
636 },
637 {
638 key: "trips",
639 label: __("Trips", "yatra"),
640 visible: visibleColumns.trips,
641 render: (category: Category) => (
642 <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300">
643 {typeof category.trip_count === "number" ? category.trip_count : 0}
644 </span>
645 ),
646 },
647 {
648 key: "description",
649 label: __("Description", "yatra"),
650 visible: visibleColumns.description,
651 render: (category: Category) => (
652 <span
653 className={`block max-w-[300px] truncate ${
654 category.status === "trash" || statusFilter === "trash"
655 ? "text-gray-400 dark:text-gray-600"
656 : "text-gray-600 dark:text-gray-400"
657 }`}
658 title={category.description || ""}
659 >
660 {category.description || __("No description", "yatra")}
661 </span>
662 ),
663 },
664 {
665 key: "seo_title",
666 label: __("SEO Title", "yatra"),
667 visible: visibleColumns.seo_title,
668 render: (category: Category) => {
669 return (
670 <span
671 className={
672 category.status === "trash"
673 ? "text-gray-400 dark:text-gray-600"
674 : "text-gray-600 dark:text-gray-400"
675 }
676 >
677 {category.metadata?.seo_title || __("Not set", "yatra")}
678 </span>
679 );
680 },
681 },
682 {
683 key: "seo_description",
684 label: __("SEO Description", "yatra"),
685 visible: visibleColumns.seo_description,
686 render: (category: Category) => {
687 return (
688 <span
689 className={
690 category.status === "trash"
691 ? "text-gray-400 dark:text-gray-600"
692 : "text-gray-600 dark:text-gray-400"
693 }
694 title={category.metadata?.seo_description || ""}
695 >
696 {category.metadata?.seo_description
697 ? category.metadata.seo_description.length > 50
698 ? category.metadata.seo_description.substring(0, 50) + "..."
699 : category.metadata.seo_description
700 : __("Not set", "yatra")}
701 </span>
702 );
703 },
704 },
705 {
706 key: "seo_keywords",
707 label: __("SEO Keywords", "yatra"),
708 visible: visibleColumns.seo_keywords,
709 render: (category: Category) => {
710 return (
711 <span
712 className={
713 category.status === "trash"
714 ? "text-gray-400 dark:text-gray-600"
715 : "text-gray-600 dark:text-gray-400"
716 }
717 title={category.metadata?.seo_keywords || ""}
718 >
719 {category.metadata?.seo_keywords
720 ? category.metadata.seo_keywords.length > 30
721 ? category.metadata.seo_keywords.substring(0, 30) + "..."
722 : category.metadata.seo_keywords
723 : __("Not set", "yatra")}
724 </span>
725 );
726 },
727 },
728 {
729 key: "status",
730 label: __("Status", "yatra"),
731 sortable: true,
732 visible: visibleColumns.status,
733 render: (category: Category) => {
734 return (
735 <span
736 className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
737 category.status === "publish"
738 ? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300"
739 : category.status === "draft"
740 ? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300"
741 : "bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300"
742 }`}
743 >
744 {category.status === "publish"
745 ? __("Published", "yatra")
746 : category.status === "draft"
747 ? __("Draft", "yatra")
748 : category.status === "trash"
749 ? __("Trash", "yatra")
750 : category.status}
751 </span>
752 );
753 },
754 },
755 {
756 key: "created_at",
757 label: __("Created", "yatra"),
758 sortable: true,
759 visible: visibleColumns.created_at,
760 },
761 ],
762 [visibleColumns],
763 );
764
765 // Status mutation for individual actions
766 const statusMutation = useMutation({
767 mutationFn: async ({ id, status }: { id: number; status: string }) => {
768 const categoryResponse = await apiClient.get(`/trip-categories/${id}`);
769 const category = categoryResponse as Category;
770
771 return apiClient.put(`/trip-categories/${id}`, {
772 name: category.name,
773 slug: category.slug,
774 description: category.description,
775 icon: category.icon,
776 parent_id: category.parent_id,
777 status,
778 });
779 },
780 onSuccess: (_data, variables) => {
781 queryClient.invalidateQueries({ queryKey: ["trip-categories"] });
782 const msgMap: Record<string, string> = {
783 trash: __("Category moved to trash", "yatra"),
784 draft: __("Category marked as draft", "yatra"),
785 publish: __("Category published", "yatra"),
786 };
787 const msg =
788 msgMap[variables.status] ||
789 __("Category updated successfully", "yatra");
790 showToast(msg, "success");
791 },
792 onError: (error: any, variables) => {
793 const msgMapError: Record<string, string> = {
794 trash: __("Failed to move category to trash", "yatra"),
795 draft: __("Failed to mark category as draft", "yatra"),
796 publish: __("Failed to publish category", "yatra"),
797 };
798 const fallback =
799 msgMapError[variables.status] ||
800 __("Failed to update category", "yatra");
801 showToast(error?.message || fallback, "error");
802 },
803 });
804
805 // Define actions for the shared table - dynamic based on item status
806 const actions = [
807 {
808 key: "view",
809 label: __("View (frontend)", "yatra"),
810 icon: <ExternalLink className="w-4 h-4" />,
811 onClick: (category: Category) => {
812 // Open category in new tab
813 const categoryUrl = `${window.yatraAdmin?.siteUrl || ""}/trip-category/${category.slug}`;
814 window.open(categoryUrl, "_blank");
815 },
816 condition: (category: Category) => category.status !== "trash", // Hide for trash categories
817 },
818 {
819 key: "edit",
820 label: __("Edit", "yatra"),
821 icon: <Edit className="w-4 h-4" />,
822 onClick: (category: Category) => handleEdit(category),
823 condition: () => can("yatra_edit_trips"),
824 },
825 {
826 key: "publish",
827 label: __("Make Published", "yatra"),
828 icon: <Edit className="w-4 h-4" />,
829 onClick: (category: Category) =>
830 statusMutation.mutate({ id: category.id, status: "publish" }),
831 condition: (category: Category) =>
832 can("yatra_edit_trips") && category.status !== "publish",
833 },
834 {
835 key: "draft",
836 label: __("Make Draft", "yatra"),
837 icon: <Edit className="w-4 h-4" />,
838 onClick: (category: Category) =>
839 statusMutation.mutate({ id: category.id, status: "draft" }),
840 condition: (category: Category) =>
841 can("yatra_edit_trips") && category.status !== "draft",
842 },
843 {
844 key: "trash",
845 label: __("Move to Trash", "yatra"),
846 icon: <Trash2 className="w-4 h-4" />,
847 onClick: (category: Category) =>
848 statusMutation.mutate({ id: category.id, status: "trash" }),
849 variant: "destructive" as const,
850 condition: (category: Category) =>
851 can("yatra_edit_trips") && category.status !== "trash",
852 },
853 {
854 key: "delete",
855 label: __("Delete Permanently", "yatra"),
856 icon: <Trash2 className="w-4 h-4" />,
857 onClick: (category: Category) =>
858 setDeleteConfirm({ isOpen: true, category }),
859 variant: "destructive" as const,
860 condition: (category: Category) =>
861 can("yatra_edit_trips") && category.status === "trash",
862 },
863 ];
864
865 // Custom row content renderer for hierarchical display
866 const renderRowContent = (
867 category: Category,
868 _index: number,
869 isChild = false,
870 ): React.ReactNode[] => {
871 // Get visible columns and render their content
872 const visibleColumns = columns.filter((col) => col.visible !== false);
873
874 return visibleColumns.map((column) => {
875 // Handle each column based on its key
876 switch (column.key) {
877 case "name":
878 return (
879 <div className="flex items-center gap-2">
880 {renderIcon(category.icon)}
881 <div>
882 <div className="font-medium text-gray-900 dark:text-white flex items-center gap-1">
883 {isChild && <span className="text-gray-400 mr-1">└─</span>}
884 <a
885 href={`${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&tab=categories&action=edit&id=${category.id}`}
886 className="font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:underline transition-colors cursor-pointer"
887 >
888 {category.name}
889 </a>
890 {can("yatra_view_trips") && category.status !== "trash" && (
891 <button
892 type="button"
893 onClick={(e) => {
894 e.stopPropagation();
895 handleView(category);
896 }}
897 className="ml-1 inline-flex items-center justify-center rounded-full p-1 text-gray-400 hover:text-gray-700 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-white dark:hover:bg-gray-800"
898 title={__("View category in new tab", "yatra")}
899 >
900 <ExternalLink className="w-3.5 h-3.5" />
901 </button>
902 )}
903 </div>
904 <div className="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-2">
905 <span>{category.slug}</span>
906 <span className="text-[11px] text-gray-400 dark:text-gray-500">
907 ({__("ID:", "yatra")} {category.id})
908 </span>
909 </div>
910 {category.parent_name && (
911 <div className="text-xs text-gray-500 dark:text-gray-400">
912 {__("Parent:", "yatra")} {category.parent_name}
913 </div>
914 )}
915 </div>
916 </div>
917 );
918
919 case "trips":
920 return (
921 <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300">
922 {typeof category.trip_count === "number"
923 ? category.trip_count
924 : 0}
925 </span>
926 );
927
928 case "description":
929 return (
930 <div className="max-w-xs truncate text-sm text-gray-600 dark:text-gray-400">
931 {category.description || "—"}
932 </div>
933 );
934
935 case "status":
936 return getStatusBadge(category.status);
937
938 case "created_at":
939 return (
940 <span className="text-sm text-gray-600 dark:text-gray-400">
941 {formatDate(category.created_at)}
942 </span>
943 );
944
945 // SEO columns - use their render functions
946 case "seo_title":
947 case "seo_description":
948 case "seo_keywords":
949 return column.render ? column.render(category) : null;
950
951 default:
952 // Fallback for any other columns
953 return column.render
954 ? column.render(category)
955 : (category as any)[column.key];
956 }
957 });
958 };
959
960 return (
961 <div className="space-y-3">
962 <ConfirmationDialog
963 isOpen={deleteConfirm.isOpen}
964 onClose={() => setDeleteConfirm({ isOpen: false, category: null })}
965 onConfirm={confirmDelete}
966 title={__("Delete Category", "yatra")}
967 message={
968 deleteConfirm.category
969 ? __(
970 'Are you sure you want to delete "{name}"? This action cannot be undone.',
971 "yatra",
972 ).replace("{name}", deleteConfirm.category.name)
973 : __(
974 "Are you sure you want to delete this category? This action cannot be undone.",
975 "yatra",
976 )
977 }
978 confirmText={__("Delete Permanently", "yatra")}
979 cancelText={__("Cancel", "yatra")}
980 variant="danger"
981 isLoading={deleteMutation.isPending}
982 />
983
984 <PageHeader
985 title={__("Categories", "yatra")}
986 description={__("Manage trip categories and subcategories", "yatra")}
987 actionCapability="yatra_edit_trips"
988 actions={
989 <Button
990 onClick={handleCreateCategory}
991 className="flex items-center gap-2"
992 >
993 <Plus className="w-4 h-4" />
994 {__("Add New Category", "yatra")}
995 </Button>
996 }
997 />
998
999 {/* Filters */}
1000 <Card>
1001 <CardContent className="p-3">
1002 <div className="flex flex-col gap-3 lg:flex-row lg:items-center">
1003 {/* Shared search + status + sort toolbar - give it more space on large screens */}
1004 <div className="min-w-0 w-full lg:flex-[4]">
1005 <SearchFilterToolbar
1006 searchTerm={searchTerm}
1007 onSearchChange={setSearchTerm}
1008 statusFilter={statusFilter}
1009 onStatusChange={(value) => {
1010 setStatusFilter(value);
1011 setPage(1);
1012 }}
1013 statusOptions={[
1014 { value: "all", label: __("All Status", "yatra") },
1015 { value: "publish", label: __("Publish", "yatra") },
1016 { value: "draft", label: __("Draft", "yatra") },
1017 { value: "trash", label: __("Trash", "yatra") },
1018 ]}
1019 sortBy={sortBy}
1020 onSortByChange={(value) => {
1021 setSortBy(value);
1022 setSortOrder("asc");
1023 setPage(1);
1024 }}
1025 sortOrder={sortOrder}
1026 onSortOrderChange={(order) => {
1027 setSortOrder(order);
1028 setPage(1);
1029 }}
1030 sortOptions={[
1031 { value: "name", label: __("Name", "yatra") },
1032 { value: "status", label: __("Status", "yatra") },
1033 { value: "created_at", label: __("Created", "yatra") },
1034 ]}
1035 onResetFilters={handleResetFilters}
1036 hasFilters={!!hasFilters}
1037 placeholder={__("Search categories", "yatra")}
1038 />
1039 </div>
1040
1041 {/* Parent filter - constrained width on large screens */}
1042 <div className="w-full lg:w-48 xl:w-56 lg:flex-none">
1043 <Select
1044 value={parentFilter}
1045 onChange={(e) =>
1046 setParentFilter(
1047 e.target.value as "all" | "top-level" | "subcategories",
1048 )
1049 }
1050 className="w-full text-sm truncate"
1051 >
1052 <option value="all">{__("All Categories", "yatra")}</option>
1053 <option value="top-level">
1054 {__("Top Level Only", "yatra")}
1055 </option>
1056 <option value="subcategories">
1057 {__("Subcategories Only", "yatra")}
1058 </option>
1059 </Select>
1060 </div>
1061 </div>
1062 </CardContent>
1063 </Card>
1064
1065 {/* Bulk Actions */}
1066 <BulkActionToolbar
1067 selectedIds={selectedIds}
1068 bulkAction={bulkAction}
1069 setBulkAction={setBulkAction}
1070 onApply={handleBulkApply}
1071 onClearSelection={() => setSelectedIds([])}
1072 statusFilter={statusFilter}
1073 setStatusFilter={(value) => {
1074 setStatusFilter(value);
1075 setPage(1);
1076 setSelectedIds([]);
1077 setBulkAction("");
1078 }}
1079 statusOptions={[
1080 { key: "all", label: __("All", "yatra"), count: statusCounts.all },
1081 {
1082 key: "publish",
1083 label: __("Published", "yatra"),
1084 count: statusCounts.publish,
1085 },
1086 {
1087 key: "draft",
1088 label: __("Draft", "yatra"),
1089 count: statusCounts.draft,
1090 },
1091 {
1092 key: "trash",
1093 label: __("Trash", "yatra"),
1094 count: statusCounts.trash,
1095 },
1096 ]}
1097 showColumnsDropdown={showColumnsDropdown}
1098 setShowColumnsDropdown={setShowColumnsDropdown}
1099 columnOptions={[
1100 {
1101 key: "name",
1102 label: __("Category", "yatra"),
1103 visible: visibleColumns.name,
1104 },
1105 {
1106 key: "description",
1107 label: __("Description", "yatra"),
1108 visible: visibleColumns.description,
1109 },
1110 {
1111 key: "trips",
1112 label: __("Trips", "yatra"),
1113 visible: visibleColumns.trips,
1114 },
1115 {
1116 key: "seo_title",
1117 label: __("SEO Title", "yatra"),
1118 visible: visibleColumns.seo_title,
1119 },
1120 {
1121 key: "seo_description",
1122 label: __("SEO Description", "yatra"),
1123 visible: visibleColumns.seo_description,
1124 },
1125 {
1126 key: "seo_keywords",
1127 label: __("SEO Keywords", "yatra"),
1128 visible: visibleColumns.seo_keywords,
1129 },
1130 {
1131 key: "status",
1132 label: __("Status", "yatra"),
1133 visible: visibleColumns.status,
1134 },
1135 {
1136 key: "created_at",
1137 label: __("Created Date", "yatra"),
1138 visible: visibleColumns.created_at,
1139 },
1140 ]}
1141 onToggleColumn={toggleColumn}
1142 bulkMutationPending={bulkMutation.isPending}
1143 totalItems={processedCategories.length}
1144 bulkActionOptions={getDefaultBulkStatusOptions(statusFilter)}
1145 />
1146
1147 {/* Table */}
1148 <Card>
1149 <CardContent className="p-0">
1150 {null}
1151
1152 <SharedTable
1153 key={`categories-table-${JSON.stringify(visibleColumns)}`}
1154 data={processedCategories}
1155 columns={columns}
1156 actions={actions}
1157 isLoading={isLoading}
1158 isError={!!error}
1159 errorText={__("Error loading categories", "yatra")}
1160 errorDescription={__(
1161 "We couldn’t connect to the categories service. Please refresh or try again shortly.",
1162 "We couldn’t connect to the categories service. Please refresh or try again shortly.",
1163 )}
1164 onRetry={() =>
1165 queryClient.invalidateQueries({ queryKey: ["trip-categories"] })
1166 }
1167 errorDetails={errorContext.details}
1168 errorRequestInfo={errorContext.requestInfo}
1169 emptyText={__("No categories found", "yatra")}
1170 emptyDescription={__(
1171 "Organize trips into categories to help travelers find the perfect fit.",
1172 "Organize trips into categories to help travelers find the perfect fit.",
1173 )}
1174 onCreateClick={handleCreateCategory}
1175 selectedItemIds={selectedIds}
1176 onSelectItem={(id: string | number, checked: boolean) => {
1177 if (checked) {
1178 setSelectedIds([...selectedIds, id]);
1179 } else {
1180 setSelectedIds(
1181 selectedIds.filter((selectedId) => selectedId !== id),
1182 );
1183 }
1184 }}
1185 onSelectAll={(checked: boolean) => {
1186 if (checked) {
1187 setSelectedIds(
1188 processedCategories.map((cat: Category) => cat.id),
1189 );
1190 } else {
1191 setSelectedIds([]);
1192 }
1193 }}
1194 isAllSelected={
1195 processedCategories.length > 0 &&
1196 selectedIds.length === processedCategories.length
1197 }
1198 getItemId={(category: Category) => category.id}
1199 getItemStatus={(category: Category) => category.status}
1200 statusFilter={statusFilter}
1201 skeletonRows={5}
1202 capability="yatra_view_trips"
1203 // Hierarchical props
1204 isHierarchical={true}
1205 expandedIds={expandedCategories}
1206 onToggleExpand={toggleExpand}
1207 getChildren={(category: Category) => category.subcategories || []}
1208 renderRowContent={renderRowContent}
1209 />
1210 </CardContent>
1211 </Card>
1212
1213 {/* Pagination */}
1214 {total > 0 && (
1215 <div className="mt-4">
1216 <Pagination
1217 currentPage={page}
1218 totalPages={totalPages}
1219 totalItems={total}
1220 itemsPerPage={10}
1221 onPageChange={(newPage) => setPage(newPage)}
1222 itemName={__("categories", "yatra")}
1223 />
1224 </div>
1225 )}
1226 </div>
1227 );
1228 };
1229
1230 export default Categories;
1231