PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.2.9
Yatra – Travel Booking & Tour Operator Software v3.0.2.9
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.2.9, at resources/js/pages/Categories.tsx

1,230 lines 40.3 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 className="w-5 h-5 text-gray-600 dark:text-gray-400"
495 />
496 </div>
497 );
498 };
499
500 const toggleExpand = (categoryId: string | number) => {
501 const id =
502 typeof categoryId === "string" ? parseInt(categoryId, 10) : categoryId;
503 setExpandedCategories((prev) => {
504 const newSet = new Set(prev);
505 if (newSet.has(id)) {
506 newSet.delete(id);
507 } else {
508 newSet.add(id);
509 }
510 return newSet;
511 });
512 };
513
514 const handleEdit = (category: Category) => {
515 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&tab=categories&action=edit&id=${category.id}`;
516 };
517
518 const handleView = async (category: Category) => {
519 const categorySlug = category.slug || "";
520 const { plainUrl, prettyUrl } = buildYatraSinglePublicUrls({
521 entity: "category",
522 slug: categorySlug,
523 bases: settings as Record<string, unknown> | null,
524 });
525
526 if (isWordPressPlainPermalink()) {
527 if (!categorySlug) {
528 showToast(__("Category slug is missing", "yatra"), "error");
529 return;
530 }
531 window.open(plainUrl, "_blank", "noopener,noreferrer");
532 return;
533 }
534
535 let apiPermalink = (category as any)?.permalink || (category as any)?.url;
536
537 if (!apiPermalink && category.id) {
538 try {
539 const detail = await apiClient.get(`/trip-categories/${category.id}`);
540 apiPermalink =
541 (detail as any)?.permalink || (detail as any)?.url || apiPermalink;
542 } catch (e) {
543 // fall through to prettyUrl
544 }
545 }
546
547 if (apiPermalink) {
548 window.open(apiPermalink, "_blank", "noopener,noreferrer");
549 return;
550 }
551
552 if (!categorySlug) {
553 showToast(__("Category slug is missing", "yatra"), "error");
554 return;
555 }
556
557 window.open(prettyUrl, "_blank", "noopener,noreferrer");
558 };
559
560 const confirmDelete = () => {
561 if (deleteConfirm.category) {
562 deleteMutation.mutate(deleteConfirm.category.id);
563 }
564 };
565
566 const handleCreateCategory = () => {
567 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&tab=categories&action=create`;
568 };
569
570 const handleResetFilters = () => {
571 setSearchTerm("");
572 setStatusFilter("all");
573 setParentFilter("all");
574 setSortBy("name");
575 setSortOrder("asc");
576 setPage(1);
577 };
578
579 const hasFilters =
580 searchTerm ||
581 statusFilter !== "all" ||
582 parentFilter !== "all" ||
583 sortBy !== "name" ||
584 sortOrder !== "asc";
585
586 // Define columns for the shared table
587 const columns = useMemo(
588 () => [
589 {
590 key: "name",
591 label: __("Name", "yatra"),
592 sortable: true,
593 visible: visibleColumns.name,
594 render: (category: Category) => {
595 const editUrl = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&tab=categories&action=edit&id=${category.id}`;
596 return (
597 <div className="flex items-start gap-2">
598 <div className="flex-1 min-w-0">
599 <div className="flex items-center gap-1">
600 <a
601 href={editUrl}
602 onClick={(e) => {
603 e.preventDefault();
604 e.stopPropagation();
605 handleEdit(category);
606 }}
607 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"
608 >
609 {category.name}
610 </a>
611 {can("yatra_view_trips") && category.status !== "trash" && (
612 <button
613 type="button"
614 onClick={(e) => {
615 e.stopPropagation();
616 handleView(category);
617 }}
618 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"
619 title={__("View category in new tab", "yatra")}
620 >
621 <ExternalLink className="w-3.5 h-3.5" />
622 </button>
623 )}
624 </div>
625 <div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
626 {category.slug}
627 <span className="ml-1 text-[11px] text-gray-400 dark:text-gray-500">
628 (ID: {category.id})
629 </span>
630 </div>
631 </div>
632 </div>
633 );
634 },
635 },
636 {
637 key: "trips",
638 label: __("Trips", "yatra"),
639 visible: visibleColumns.trips,
640 render: (category: Category) => (
641 <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">
642 {typeof category.trip_count === "number" ? category.trip_count : 0}
643 </span>
644 ),
645 },
646 {
647 key: "description",
648 label: __("Description", "yatra"),
649 visible: visibleColumns.description,
650 render: (category: Category) => (
651 <span
652 className={`block max-w-[300px] truncate ${
653 category.status === "trash" || statusFilter === "trash"
654 ? "text-gray-400 dark:text-gray-600"
655 : "text-gray-600 dark:text-gray-400"
656 }`}
657 title={category.description || ""}
658 >
659 {category.description || __("No description", "yatra")}
660 </span>
661 ),
662 },
663 {
664 key: "seo_title",
665 label: __("SEO Title", "yatra"),
666 visible: visibleColumns.seo_title,
667 render: (category: Category) => {
668 return (
669 <span
670 className={
671 category.status === "trash"
672 ? "text-gray-400 dark:text-gray-600"
673 : "text-gray-600 dark:text-gray-400"
674 }
675 >
676 {category.metadata?.seo_title || __("Not set", "yatra")}
677 </span>
678 );
679 },
680 },
681 {
682 key: "seo_description",
683 label: __("SEO Description", "yatra"),
684 visible: visibleColumns.seo_description,
685 render: (category: Category) => {
686 return (
687 <span
688 className={
689 category.status === "trash"
690 ? "text-gray-400 dark:text-gray-600"
691 : "text-gray-600 dark:text-gray-400"
692 }
693 title={category.metadata?.seo_description || ""}
694 >
695 {category.metadata?.seo_description
696 ? category.metadata.seo_description.length > 50
697 ? category.metadata.seo_description.substring(0, 50) + "..."
698 : category.metadata.seo_description
699 : __("Not set", "yatra")}
700 </span>
701 );
702 },
703 },
704 {
705 key: "seo_keywords",
706 label: __("SEO Keywords", "yatra"),
707 visible: visibleColumns.seo_keywords,
708 render: (category: Category) => {
709 return (
710 <span
711 className={
712 category.status === "trash"
713 ? "text-gray-400 dark:text-gray-600"
714 : "text-gray-600 dark:text-gray-400"
715 }
716 title={category.metadata?.seo_keywords || ""}
717 >
718 {category.metadata?.seo_keywords
719 ? category.metadata.seo_keywords.length > 30
720 ? category.metadata.seo_keywords.substring(0, 30) + "..."
721 : category.metadata.seo_keywords
722 : __("Not set", "yatra")}
723 </span>
724 );
725 },
726 },
727 {
728 key: "status",
729 label: __("Status", "yatra"),
730 sortable: true,
731 visible: visibleColumns.status,
732 render: (category: Category) => {
733 return (
734 <span
735 className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
736 category.status === "publish"
737 ? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300"
738 : category.status === "draft"
739 ? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300"
740 : "bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300"
741 }`}
742 >
743 {category.status === "publish"
744 ? __("Published", "yatra")
745 : category.status === "draft"
746 ? __("Draft", "yatra")
747 : category.status === "trash"
748 ? __("Trash", "yatra")
749 : category.status}
750 </span>
751 );
752 },
753 },
754 {
755 key: "created_at",
756 label: __("Created", "yatra"),
757 sortable: true,
758 visible: visibleColumns.created_at,
759 },
760 ],
761 [visibleColumns],
762 );
763
764 // Status mutation for individual actions
765 const statusMutation = useMutation({
766 mutationFn: async ({ id, status }: { id: number; status: string }) => {
767 const categoryResponse = await apiClient.get(`/trip-categories/${id}`);
768 const category = categoryResponse as Category;
769
770 return apiClient.put(`/trip-categories/${id}`, {
771 name: category.name,
772 slug: category.slug,
773 description: category.description,
774 icon: category.icon,
775 parent_id: category.parent_id,
776 status,
777 });
778 },
779 onSuccess: (_data, variables) => {
780 queryClient.invalidateQueries({ queryKey: ["trip-categories"] });
781 const msgMap: Record<string, string> = {
782 trash: __("Category moved to trash", "yatra"),
783 draft: __("Category marked as draft", "yatra"),
784 publish: __("Category published", "yatra"),
785 };
786 const msg =
787 msgMap[variables.status] ||
788 __("Category updated successfully", "yatra");
789 showToast(msg, "success");
790 },
791 onError: (error: any, variables) => {
792 const msgMapError: Record<string, string> = {
793 trash: __("Failed to move category to trash", "yatra"),
794 draft: __("Failed to mark category as draft", "yatra"),
795 publish: __("Failed to publish category", "yatra"),
796 };
797 const fallback =
798 msgMapError[variables.status] ||
799 __("Failed to update category", "yatra");
800 showToast(error?.message || fallback, "error");
801 },
802 });
803
804 // Define actions for the shared table - dynamic based on item status
805 const actions = [
806 {
807 key: "view",
808 label: __("View (frontend)", "yatra"),
809 icon: <ExternalLink className="w-4 h-4" />,
810 onClick: (category: Category) => {
811 // Open category in new tab
812 const categoryUrl = `${window.yatraAdmin?.siteUrl || ""}/trip-category/${category.slug}`;
813 window.open(categoryUrl, "_blank");
814 },
815 condition: (category: Category) => category.status !== "trash", // Hide for trash categories
816 },
817 {
818 key: "edit",
819 label: __("Edit", "yatra"),
820 icon: <Edit className="w-4 h-4" />,
821 onClick: (category: Category) => handleEdit(category),
822 condition: () => can("yatra_edit_trips"),
823 },
824 {
825 key: "publish",
826 label: __("Make Published", "yatra"),
827 icon: <Edit className="w-4 h-4" />,
828 onClick: (category: Category) =>
829 statusMutation.mutate({ id: category.id, status: "publish" }),
830 condition: (category: Category) =>
831 can("yatra_edit_trips") && category.status !== "publish",
832 },
833 {
834 key: "draft",
835 label: __("Make Draft", "yatra"),
836 icon: <Edit className="w-4 h-4" />,
837 onClick: (category: Category) =>
838 statusMutation.mutate({ id: category.id, status: "draft" }),
839 condition: (category: Category) =>
840 can("yatra_edit_trips") && category.status !== "draft",
841 },
842 {
843 key: "trash",
844 label: __("Move to Trash", "yatra"),
845 icon: <Trash2 className="w-4 h-4" />,
846 onClick: (category: Category) =>
847 statusMutation.mutate({ id: category.id, status: "trash" }),
848 variant: "destructive" as const,
849 condition: (category: Category) =>
850 can("yatra_edit_trips") && category.status !== "trash",
851 },
852 {
853 key: "delete",
854 label: __("Delete Permanently", "yatra"),
855 icon: <Trash2 className="w-4 h-4" />,
856 onClick: (category: Category) =>
857 setDeleteConfirm({ isOpen: true, category }),
858 variant: "destructive" as const,
859 condition: (category: Category) =>
860 can("yatra_edit_trips") && category.status === "trash",
861 },
862 ];
863
864 // Custom row content renderer for hierarchical display
865 const renderRowContent = (
866 category: Category,
867 _index: number,
868 isChild = false,
869 ): React.ReactNode[] => {
870 // Get visible columns and render their content
871 const visibleColumns = columns.filter((col) => col.visible !== false);
872
873 return visibleColumns.map((column) => {
874 // Handle each column based on its key
875 switch (column.key) {
876 case "name":
877 return (
878 <div className="flex items-center gap-2">
879 {renderIcon(category.icon)}
880 <div>
881 <div className="font-medium text-gray-900 dark:text-white flex items-center gap-1">
882 {isChild && <span className="text-gray-400 mr-1">└─</span>}
883 <a
884 href={`${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&tab=categories&action=edit&id=${category.id}`}
885 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"
886 >
887 {category.name}
888 </a>
889 {can("yatra_view_trips") && category.status !== "trash" && (
890 <button
891 type="button"
892 onClick={(e) => {
893 e.stopPropagation();
894 handleView(category);
895 }}
896 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"
897 title={__("View category in new tab", "yatra")}
898 >
899 <ExternalLink className="w-3.5 h-3.5" />
900 </button>
901 )}
902 </div>
903 <div className="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-2">
904 <span>{category.slug}</span>
905 <span className="text-[11px] text-gray-400 dark:text-gray-500">
906 ({__("ID:", "yatra")} {category.id})
907 </span>
908 </div>
909 {category.parent_name && (
910 <div className="text-xs text-gray-500 dark:text-gray-400">
911 {__("Parent:", "yatra")} {category.parent_name}
912 </div>
913 )}
914 </div>
915 </div>
916 );
917
918 case "trips":
919 return (
920 <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">
921 {typeof category.trip_count === "number"
922 ? category.trip_count
923 : 0}
924 </span>
925 );
926
927 case "description":
928 return (
929 <div className="max-w-xs truncate text-sm text-gray-600 dark:text-gray-400">
930 {category.description || "—"}
931 </div>
932 );
933
934 case "status":
935 return getStatusBadge(category.status);
936
937 case "created_at":
938 return (
939 <span className="text-sm text-gray-600 dark:text-gray-400">
940 {formatDate(category.created_at)}
941 </span>
942 );
943
944 // SEO columns - use their render functions
945 case "seo_title":
946 case "seo_description":
947 case "seo_keywords":
948 return column.render ? column.render(category) : null;
949
950 default:
951 // Fallback for any other columns
952 return column.render
953 ? column.render(category)
954 : (category as any)[column.key];
955 }
956 });
957 };
958
959 return (
960 <div className="space-y-3">
961 <ConfirmationDialog
962 isOpen={deleteConfirm.isOpen}
963 onClose={() => setDeleteConfirm({ isOpen: false, category: null })}
964 onConfirm={confirmDelete}
965 title={__("Delete Category", "yatra")}
966 message={
967 deleteConfirm.category
968 ? __(
969 'Are you sure you want to delete "{name}"? This action cannot be undone.',
970 "yatra",
971 ).replace("{name}", deleteConfirm.category.name)
972 : __(
973 "Are you sure you want to delete this category? This action cannot be undone.",
974 "yatra",
975 )
976 }
977 confirmText={__("Delete Permanently", "yatra")}
978 cancelText={__("Cancel", "yatra")}
979 variant="danger"
980 isLoading={deleteMutation.isPending}
981 />
982
983 <PageHeader
984 title={__("Categories", "yatra")}
985 description={__("Manage trip categories and subcategories", "yatra")}
986 actionCapability="yatra_edit_trips"
987 actions={
988 <Button
989 onClick={handleCreateCategory}
990 className="flex items-center gap-2"
991 >
992 <Plus className="w-4 h-4" />
993 {__("Add New Category", "yatra")}
994 </Button>
995 }
996 />
997
998 {/* Filters */}
999 <Card>
1000 <CardContent className="p-3">
1001 <div className="flex flex-col gap-3 lg:flex-row lg:items-center">
1002 {/* Shared search + status + sort toolbar - give it more space on large screens */}
1003 <div className="min-w-0 w-full lg:flex-[4]">
1004 <SearchFilterToolbar
1005 searchTerm={searchTerm}
1006 onSearchChange={setSearchTerm}
1007 statusFilter={statusFilter}
1008 onStatusChange={(value) => {
1009 setStatusFilter(value);
1010 setPage(1);
1011 }}
1012 statusOptions={[
1013 { value: "all", label: __("All Status", "yatra") },
1014 { value: "publish", label: __("Publish", "yatra") },
1015 { value: "draft", label: __("Draft", "yatra") },
1016 { value: "trash", label: __("Trash", "yatra") },
1017 ]}
1018 sortBy={sortBy}
1019 onSortByChange={(value) => {
1020 setSortBy(value);
1021 setSortOrder("asc");
1022 setPage(1);
1023 }}
1024 sortOrder={sortOrder}
1025 onSortOrderChange={(order) => {
1026 setSortOrder(order);
1027 setPage(1);
1028 }}
1029 sortOptions={[
1030 { value: "name", label: __("Name", "yatra") },
1031 { value: "status", label: __("Status", "yatra") },
1032 { value: "created_at", label: __("Created", "yatra") },
1033 ]}
1034 onResetFilters={handleResetFilters}
1035 hasFilters={!!hasFilters}
1036 placeholder={__("Search categories", "yatra")}
1037 />
1038 </div>
1039
1040 {/* Parent filter - constrained width on large screens */}
1041 <div className="w-full lg:w-48 xl:w-56 lg:flex-none">
1042 <Select
1043 value={parentFilter}
1044 onChange={(e) =>
1045 setParentFilter(
1046 e.target.value as "all" | "top-level" | "subcategories",
1047 )
1048 }
1049 className="w-full text-sm truncate"
1050 >
1051 <option value="all">{__("All Categories", "yatra")}</option>
1052 <option value="top-level">
1053 {__("Top Level Only", "yatra")}
1054 </option>
1055 <option value="subcategories">
1056 {__("Subcategories Only", "yatra")}
1057 </option>
1058 </Select>
1059 </div>
1060 </div>
1061 </CardContent>
1062 </Card>
1063
1064 {/* Bulk Actions */}
1065 <BulkActionToolbar
1066 selectedIds={selectedIds}
1067 bulkAction={bulkAction}
1068 setBulkAction={setBulkAction}
1069 onApply={handleBulkApply}
1070 onClearSelection={() => setSelectedIds([])}
1071 statusFilter={statusFilter}
1072 setStatusFilter={(value) => {
1073 setStatusFilter(value);
1074 setPage(1);
1075 setSelectedIds([]);
1076 setBulkAction("");
1077 }}
1078 statusOptions={[
1079 { key: "all", label: __("All", "yatra"), count: statusCounts.all },
1080 {
1081 key: "publish",
1082 label: __("Published", "yatra"),
1083 count: statusCounts.publish,
1084 },
1085 {
1086 key: "draft",
1087 label: __("Draft", "yatra"),
1088 count: statusCounts.draft,
1089 },
1090 {
1091 key: "trash",
1092 label: __("Trash", "yatra"),
1093 count: statusCounts.trash,
1094 },
1095 ]}
1096 showColumnsDropdown={showColumnsDropdown}
1097 setShowColumnsDropdown={setShowColumnsDropdown}
1098 columnOptions={[
1099 {
1100 key: "name",
1101 label: __("Category", "yatra"),
1102 visible: visibleColumns.name,
1103 },
1104 {
1105 key: "description",
1106 label: __("Description", "yatra"),
1107 visible: visibleColumns.description,
1108 },
1109 {
1110 key: "trips",
1111 label: __("Trips", "yatra"),
1112 visible: visibleColumns.trips,
1113 },
1114 {
1115 key: "seo_title",
1116 label: __("SEO Title", "yatra"),
1117 visible: visibleColumns.seo_title,
1118 },
1119 {
1120 key: "seo_description",
1121 label: __("SEO Description", "yatra"),
1122 visible: visibleColumns.seo_description,
1123 },
1124 {
1125 key: "seo_keywords",
1126 label: __("SEO Keywords", "yatra"),
1127 visible: visibleColumns.seo_keywords,
1128 },
1129 {
1130 key: "status",
1131 label: __("Status", "yatra"),
1132 visible: visibleColumns.status,
1133 },
1134 {
1135 key: "created_at",
1136 label: __("Created Date", "yatra"),
1137 visible: visibleColumns.created_at,
1138 },
1139 ]}
1140 onToggleColumn={toggleColumn}
1141 bulkMutationPending={bulkMutation.isPending}
1142 totalItems={processedCategories.length}
1143 bulkActionOptions={getDefaultBulkStatusOptions(statusFilter)}
1144 />
1145
1146 {/* Table */}
1147 <Card>
1148 <CardContent className="p-0">
1149 {null}
1150
1151 <SharedTable
1152 key={`categories-table-${JSON.stringify(visibleColumns)}`}
1153 data={processedCategories}
1154 columns={columns}
1155 actions={actions}
1156 isLoading={isLoading}
1157 isError={!!error}
1158 errorText={__("Error loading categories", "yatra")}
1159 errorDescription={__(
1160 "We couldn’t connect to the categories service. Please refresh or try again shortly.",
1161 "We couldn’t connect to the categories service. Please refresh or try again shortly.",
1162 )}
1163 onRetry={() =>
1164 queryClient.invalidateQueries({ queryKey: ["trip-categories"] })
1165 }
1166 errorDetails={errorContext.details}
1167 errorRequestInfo={errorContext.requestInfo}
1168 emptyText={__("No categories found", "yatra")}
1169 emptyDescription={__(
1170 "Organize trips into categories to help travelers find the perfect fit.",
1171 "Organize trips into categories to help travelers find the perfect fit.",
1172 )}
1173 onCreateClick={handleCreateCategory}
1174 selectedItemIds={selectedIds}
1175 onSelectItem={(id: string | number, checked: boolean) => {
1176 if (checked) {
1177 setSelectedIds([...selectedIds, id]);
1178 } else {
1179 setSelectedIds(
1180 selectedIds.filter((selectedId) => selectedId !== id),
1181 );
1182 }
1183 }}
1184 onSelectAll={(checked: boolean) => {
1185 if (checked) {
1186 setSelectedIds(
1187 processedCategories.map((cat: Category) => cat.id),
1188 );
1189 } else {
1190 setSelectedIds([]);
1191 }
1192 }}
1193 isAllSelected={
1194 processedCategories.length > 0 &&
1195 selectedIds.length === processedCategories.length
1196 }
1197 getItemId={(category: Category) => category.id}
1198 getItemStatus={(category: Category) => category.status}
1199 statusFilter={statusFilter}
1200 skeletonRows={5}
1201 capability="yatra_view_trips"
1202 // Hierarchical props
1203 isHierarchical={true}
1204 expandedIds={expandedCategories}
1205 onToggleExpand={toggleExpand}
1206 getChildren={(category: Category) => category.subcategories || []}
1207 renderRowContent={renderRowContent}
1208 />
1209 </CardContent>
1210 </Card>
1211
1212 {/* Pagination */}
1213 {total > 0 && (
1214 <div className="mt-4">
1215 <Pagination
1216 currentPage={page}
1217 totalPages={totalPages}
1218 totalItems={total}
1219 itemsPerPage={10}
1220 onPageChange={(newPage) => setPage(newPage)}
1221 itemName={__("categories", "yatra")}
1222 />
1223 </div>
1224 )}
1225 </div>
1226 );
1227 };
1228
1229 export default Categories;
1230