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

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

1,872 lines 60.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * All Trips Page
3 * Clean, minimal SaaS-style trips management page
4 */
5
6 import React, { useState, useMemo } from "react";
7 import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
8 import {
9 ArrowDown,
10 ArrowUp,
11 Archive,
12 Copy,
13 ExternalLink,
14 Calendar,
15 Users,
16 Activity,
17 Route,
18 FolderTree,
19 TrendingUp,
20 Plus,
21 Search,
22 X,
23 ArrowUpDown,
24 RefreshCw,
25 Sparkles,
26 } from "lucide-react";
27 import { CreateTripWithAiWizard } from "../components/ai/CreateTripWithAiWizard";
28 import { isAiEligible, isAiModuleEnabled } from "../lib/ai-availability";
29 import { __ } from "../lib/i18n";
30 import { usePermissions } from "../hooks/usePermissions";
31 import { Button } from "../components/ui/button";
32 import { Input } from "../components/ui/input";
33 import { Select } from "../components/ui/select";
34 import { PageHeader } from "../components/common/PageHeader";
35 import { Card, CardContent } from "../components/ui/card";
36 import { Badge } from "../components/ui/badge";
37 import { ConditionalRender } from "../components/ui/conditional-render";
38 import { Edit, Trash2 } from "lucide-react";
39 import { HelpText } from "../components/ui/help-text";
40 import { fetchSettings } from "../api/settings-api";
41 import { apiClient } from "../lib/api-client";
42 import { getErrorContext } from "../lib/errors";
43 import {
44 buildYatraSinglePublicUrls,
45 isWordPressPlainPermalink,
46 } from "../lib/frontend-permalink-urls";
47 import { useToast } from "../components/ui/toast";
48 import { generateSlug } from "../lib/slug";
49 import { formatYatraMoney } from "../lib/currency-display";
50 import { Pagination } from "../components/shared/Pagination";
51 import { Table as SharedTable } from "../components/shared/Table";
52 import { BulkActionToolbar } from "../components/shared/BulkActionToolbar";
53 import { ConfirmationDialog } from "../components/ui/confirmation-dialog";
54 import { Modal } from "../components/ui/modal";
55 import { useNavigate } from "../hooks/useNavigate";
56
57 interface Trip {
58 id: number;
59 title: string;
60 slug: string;
61 original_price?: number;
62 discounted_price?: number;
63 sale_price?: number;
64 pricing_type?: "regular" | "traveler_based" | string;
65 status: string;
66 created_at: string;
67 updated_at?: string;
68 bookings_count?: number;
69 featured?: boolean;
70 trip_type?: "single_day" | "multi_day" | "flexible";
71 featured_priority?: string;
72 duration_days?: number;
73 duration_nights?: number;
74 min_travelers?: number;
75 max_travelers?: number;
76 countries?: string[];
77 regions?: string[];
78 featured_image_url?: string;
79 availability_dates?: Array<{
80 id: number;
81 departure_date: string;
82 seats_remaining?: number;
83 status?: string;
84 }>;
85 attributes?: Record<number, any>;
86 trip_category?: Array<{
87 id: number;
88 name: string;
89 slug?: string;
90 is_primary?: boolean;
91 order?: number;
92 }>;
93 activity_types?: Array<{
94 id: number;
95 name: string;
96 slug?: string;
97 is_primary?: boolean;
98 order?: number;
99 }>;
100 difficulty_level?: string;
101 destinations?: Array<{
102 id: number;
103 name: string;
104 slug?: string;
105 is_primary?: boolean;
106 }>;
107 price_types?: Array<{
108 id?: number;
109 category_id: number;
110 category_label?: string;
111 category_slug?: string;
112 original_price: number;
113 discounted_price?: number | null;
114 sale_price?: number | null;
115 is_default?: boolean;
116 min_quantity?: number;
117 max_quantity?: number | null;
118 }>;
119 traveler_min_price?: number | null;
120 traveler_max_price?: number | null;
121 }
122
123 const Trips: React.FC = () => {
124 const [searchTerm, setSearchTerm] = useState("");
125 const [statusFilter, setStatusFilter] = useState("all");
126 const [sortBy, setSortBy] = useState("id");
127 const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
128 const [page, setPage] = useState(1);
129 const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
130 const [isAiWizardOpen, setIsAiWizardOpen] = useState(false);
131 const [newTripTitle, setNewTripTitle] = useState("");
132 const [createTripError, setCreateTripError] = useState<string | null>(null);
133 const [newTripSlug, setNewTripSlug] = useState("");
134 const [isSlugManuallyEdited, setIsSlugManuallyEdited] = useState(false);
135 const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]);
136 const [bulkAction, setBulkAction] = useState("");
137 const [showColumnsDropdown, setShowColumnsDropdown] = useState(false);
138 const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
139 const [tripToDelete, setTripToDelete] = useState<Trip | null>(null);
140 const queryClient = useQueryClient();
141 const { can, isPro } = usePermissions();
142 const { showToast } = useToast();
143 const { navigate } = useNavigate();
144
145 // Fetch global status counts (stable across filters)
146 const { data: statsData } = useQuery({
147 queryKey: ["trips-stats"],
148 queryFn: async () => {
149 try {
150 const response = await apiClient.get("/trips/stats");
151 return response;
152 } catch (error) {
153 return {
154 all: 0,
155 published: 0,
156 draft: 0,
157 review: 0,
158 approved: 0,
159 archived: 0,
160 trash: 0,
161 };
162 }
163 },
164 enabled: can("yatra_view_trips"),
165 });
166
167 // Build query params
168 const queryParams = useMemo(() => {
169 const params: Record<string, any> = {
170 page,
171 per_page: 10,
172 orderby: sortBy,
173 order: sortOrder,
174 };
175
176 if (searchTerm) {
177 params.search = searchTerm;
178 }
179
180 if (statusFilter !== "all") {
181 params.status = statusFilter;
182 }
183
184 return params;
185 }, [searchTerm, statusFilter, sortBy, sortOrder, page]);
186
187 // Fetch trips from API
188 const { data, isLoading, error, refetch } = useQuery({
189 queryKey: ["trips", queryParams],
190 queryFn: async () => {
191 const response = await apiClient.get("/trips", { params: queryParams });
192
193 return response;
194 },
195 enabled: can("yatra_view_trips"),
196 staleTime: 0, // Force fresh data
197 gcTime: 0, // Don't cache (new name for cacheTime)
198 });
199
200 // Delete mutation (permanent delete)
201 const deleteMutation = useMutation({
202 mutationFn: async (id: number) => {
203 return await apiClient.delete(`/trips/${id}/permanent-delete`);
204 },
205 onSuccess: () => {
206 queryClient.invalidateQueries({ queryKey: ["trips"] });
207 queryClient.invalidateQueries({ queryKey: ["trips-stats"] });
208 setIsDeleteDialogOpen(false);
209 setTripToDelete(null);
210 showToast(__("Trip deleted permanently.", "yatra"), "success");
211 },
212 onError: () => {
213 showToast(
214 __("Failed to delete trip. Please try again.", "yatra"),
215 "error",
216 );
217 },
218 });
219
220 const createTripMutation = useMutation({
221 mutationFn: async ({ title, slug }: { title: string; slug: string }) => {
222 const trimmedTitle = title.trim();
223 const trimmedSlug = slug.trim();
224 const payload = {
225 title: trimmedTitle,
226 slug: trimmedSlug,
227 status: "draft",
228 trip_type: "multi_day",
229 };
230 const response = await apiClient.post("/trips", payload);
231 return response?.data || response;
232 },
233 onSuccess: (data) => {
234 showToast(
235 __("Trip created as draft. Redirecting to builder...", "yatra"),
236 "success",
237 );
238 queryClient.invalidateQueries({ queryKey: ["trips"] });
239 queryClient.invalidateQueries({ queryKey: ["trips-stats"] });
240 setIsCreateModalOpen(false);
241 setNewTripTitle("");
242 setNewTripSlug("");
243 setIsSlugManuallyEdited(false);
244 setCreateTripError(null);
245 if (data?.id) {
246 setTimeout(() => {
247 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&action=edit&id=${data.id}`;
248 }, 600);
249 }
250 },
251 onError: (error: any) => {
252 const message =
253 error?.response?.data?.message ||
254 error?.message ||
255 __("Failed to create trip. Please try again.", "yatra");
256 setCreateTripError(message);
257 showToast(message, "error");
258 },
259 });
260
261 // List endpoint returns { data: Trip[], total, per_page }; meta.total / meta.per_page used by some collections.
262 const r = data as Record<string, any> | undefined;
263 const trips: Trip[] = Array.isArray(r?.data)
264 ? r.data
265 : Array.isArray(r?.data?.data)
266 ? r.data.data
267 : [];
268 const total = Number(r?.meta?.total ?? r?.total ?? 0);
269 const itemsPerPage = Number(r?.meta?.per_page ?? r?.per_page ?? 10) || 10;
270 const totalPages = Math.ceil(total / itemsPerPage);
271 const errorContext = getErrorContext(error);
272
273 // Get difficulty level data for lookup
274 const { data: difficultyLevelsData } = useQuery({
275 queryKey: ["difficulty-levels-lookup"],
276 queryFn: async () => {
277 try {
278 const response = await apiClient.get("/difficulty-levels", {
279 params: { per_page: 100 },
280 });
281 return response.data || [];
282 } catch (error) {
283 console.error("Failed to load difficulty levels", error);
284 return [];
285 }
286 },
287 staleTime: 5 * 60 * 1000, // 5 minutes
288 });
289
290 const difficultyLevels = useMemo(() => {
291 return (difficultyLevelsData || []).reduce(
292 (acc: Record<string, string>, level: any) => {
293 if (level && level.id && level.name) {
294 acc[level.id.toString()] = level.name;
295 }
296 return acc;
297 },
298 {},
299 );
300 }, [difficultyLevelsData]);
301
302 const formatLabel = (value?: string | null) => {
303 if (!value) return "";
304
305 // Only show difficulty if it's a numeric ID that exists in the difficulty levels data
306 if (value && /^\d+$/.test(value) && difficultyLevels[value]) {
307 return difficultyLevels[value];
308 }
309
310 // Don't show anything for non-numeric or invalid values
311 return "";
312 };
313
314 const summarizeDestinations = (trip: Trip) => {
315 const names = (trip.destinations || [])
316 .map((dest) => dest.name)
317 .filter(Boolean);
318 if (!names.length) return null;
319 const summary = names.slice(0, 2).join(", ");
320 const remaining = names.length - 2;
321 return remaining > 0 ? `${summary} +${remaining}` : summary;
322 };
323
324 const summarizeActivities = (trip: Trip) => {
325 const names = (trip.activity_types || [])
326 .map((act) => act.name)
327 .filter(Boolean);
328 if (!names.length) return null;
329 const summary = names.slice(0, 2).join(", ");
330 const remaining = names.length - 2;
331 return remaining > 0 ? `${summary} +${remaining}` : summary;
332 };
333
334 const summarizeCategories = (trip: Trip) => {
335 const names = (trip.trip_category || [])
336 .map((cat) => cat.name)
337 .filter(Boolean);
338 if (!names.length) return null;
339 const summary = names.slice(0, 2).join(", ");
340 const remaining = names.length - 2;
341 return remaining > 0 ? `${summary} +${remaining}` : summary;
342 };
343
344 const summarizeTravelers = (trip: Trip) => {
345 const { min_travelers, max_travelers } = trip;
346 if (min_travelers && max_travelers) {
347 return `${min_travelers}-${max_travelers} ${__("pax", "yatra")}`;
348 }
349 if (min_travelers) {
350 return `${__("Min", "yatra")} ${min_travelers} ${__("pax", "yatra")}`;
351 }
352 if (max_travelers) {
353 return `${__("Up to", "yatra")} ${max_travelers} ${__("pax", "yatra")}`;
354 }
355 return null;
356 };
357
358 const handleNewTripTitleChange = (value: string) => {
359 setNewTripTitle(value);
360 if (!isSlugManuallyEdited) {
361 setNewTripSlug(generateSlug(value));
362 }
363 };
364
365 const handleNewTripSlugChange = (value: string) => {
366 if (!isSlugManuallyEdited) {
367 setIsSlugManuallyEdited(true);
368 }
369 const sanitized = value
370 .toLowerCase()
371 .replace(/[^\p{L}\p{N}-]/gu, "-")
372 .replace(/-+/g, "-")
373 .replace(/^-|-$/g, "");
374 setNewTripSlug(sanitized);
375 };
376
377 const enableSlugEditing = () => {
378 if (!isSlugManuallyEdited) {
379 if (!newTripSlug.trim()) {
380 setNewTripSlug(generateSlug(newTripTitle || "new-trip"));
381 }
382 setIsSlugManuallyEdited(true);
383 }
384 };
385
386 const formatDate = (dateString: string) => {
387 return new Date(dateString).toLocaleDateString("en-US", {
388 month: "short",
389 day: "numeric",
390 year: "numeric",
391 });
392 };
393
394 const formatPrice = (trip: Trip) => {
395 const currencyCode = defaultCurrency;
396
397 // Traveler-based pricing: show min - max range
398 if (trip.pricing_type === "traveler_based") {
399 let minPrice: number | null = null;
400 let maxPrice: number | null = null;
401
402 if (Array.isArray(trip.price_types) && trip.price_types.length > 0) {
403 const pricesFromTypes = trip.price_types
404 .map(
405 (pt) =>
406 pt.sale_price || pt.discounted_price || pt.original_price || 0,
407 )
408 .filter((val) => typeof val === "number" && val > 0);
409
410 if (pricesFromTypes.length > 0) {
411 minPrice = Math.min(...pricesFromTypes);
412 maxPrice = Math.max(...pricesFromTypes);
413 }
414 } else {
415 // List endpoint does not hydrate price_types; use backend-computed fields
416 if (
417 typeof trip.traveler_min_price === "number" &&
418 trip.traveler_min_price > 0
419 ) {
420 minPrice = trip.traveler_min_price;
421 }
422 if (
423 typeof trip.traveler_max_price === "number" &&
424 trip.traveler_max_price > 0
425 ) {
426 maxPrice = trip.traveler_max_price;
427 }
428 }
429
430 if (minPrice !== null && maxPrice !== null) {
431 if (minPrice === maxPrice) {
432 return `${formatYatraMoney(minPrice, currencyCode, { zeroAsUnknown: false })} \n`;
433 }
434 return `${formatYatraMoney(minPrice, currencyCode, { zeroAsUnknown: false })} - ${formatYatraMoney(maxPrice, currencyCode, { zeroAsUnknown: false })}`;
435 }
436 }
437
438 // Fallback / regular pricing: use sale_price, then discounted_price, then original_price
439 const price =
440 trip.sale_price || trip.discounted_price || trip.original_price || 0;
441
442 if (!price) {
443 return "-";
444 }
445
446 return formatYatraMoney(Number(price), currencyCode, {
447 zeroAsUnknown: false,
448 });
449 };
450
451 const getStatusBadge = (status: string) => {
452 const statusMap: Record<string, { className: string; label: string }> = {
453 publish: {
454 className:
455 "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400",
456 label: __("Published", "yatra"),
457 },
458 draft: {
459 className:
460 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
461 label: __("Draft", "yatra"),
462 },
463 archived: {
464 className:
465 "bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400",
466 label: __("Archived", "yatra"),
467 },
468 review: {
469 className:
470 "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400",
471 label: __("Review", "yatra"),
472 },
473 approved: {
474 className:
475 "bg-blue-100 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400",
476 label: __("Approved", "yatra"),
477 },
478 trash: {
479 className:
480 "bg-gray-200 text-gray-700 dark:bg-gray-800 dark:text-gray-300",
481 label: __("Trash", "yatra"),
482 },
483 };
484
485 const statusInfo = statusMap[status] || {
486 className:
487 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
488 label: status,
489 };
490
491 return (
492 <span
493 className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-medium ${statusInfo.className}`}
494 >
495 {statusInfo.label}
496 </span>
497 );
498 };
499
500 const getTripTypeBadge = (
501 tripType?: "single_day" | "multi_day" | "flexible",
502 ) => {
503 if (!tripType) return null;
504
505 const typeMap: Record<string, { className: string; label: string }> = {
506 single_day: {
507 className:
508 "bg-blue-100 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400",
509 label: __("Single Day", "yatra"),
510 },
511 multi_day: {
512 className:
513 "bg-indigo-100 text-indigo-800 dark:bg-indigo-900/20 dark:text-indigo-400",
514 label: __("Multi-Day", "yatra"),
515 },
516 flexible: {
517 className:
518 "bg-teal-100 text-teal-700 dark:bg-teal-900/20 dark:text-teal-400",
519 label: __("Flexible", "yatra"),
520 },
521 };
522
523 const typeInfo = typeMap[tripType] || {
524 className:
525 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
526 label: tripType,
527 };
528
529 return (
530 <Badge className={`text-xs ${typeInfo.className}`}>
531 {typeInfo.label}
532 </Badge>
533 );
534 };
535
536 const handleEdit = (trip: Trip) => {
537 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&action=edit&id=${trip.id}`;
538 };
539
540 const handleDelete = (trip: Trip) => {
541 setTripToDelete(trip);
542 setIsDeleteDialogOpen(true);
543 };
544
545 // Fetch trip_base setting for permalink
546 const { data: settings } = useQuery({
547 queryKey: ["settings"],
548 queryFn: async () => {
549 try {
550 const response = await fetchSettings();
551 return response;
552 } catch (error) {
553 return null;
554 }
555 },
556 enabled: can("yatra_access_admin"),
557 staleTime: 5 * 60 * 1000, // Cache for 5 minutes
558 });
559
560 // Global/default currency (fallbacks: window.yatraAdmin, settings, then USD)
561 const defaultCurrency =
562 (window as any)?.yatraAdmin?.currency ||
563 (settings as any)?.default_currency ||
564 "USD";
565
566 const handleView = async (trip: Trip) => {
567 const tripSlug = trip.slug || "";
568 const { plainUrl, prettyUrl } = buildYatraSinglePublicUrls({
569 entity: "trip",
570 slug: tripSlug,
571 bases: settings as Record<string, unknown> | null,
572 });
573
574 if (isWordPressPlainPermalink()) {
575 if (!tripSlug) {
576 showToast(__("Trip slug is missing", "yatra"), "error");
577 return;
578 }
579 window.open(plainUrl, "_blank", "noopener,noreferrer");
580 return;
581 }
582
583 let apiPermalink = (trip as any)?.permalink || (trip as any)?.url;
584
585 if (!apiPermalink && trip.id) {
586 try {
587 const detail = await apiClient.get(`/trips/${trip.id}`);
588 apiPermalink =
589 (detail as any)?.permalink || (detail as any)?.url || apiPermalink;
590 } catch (e) {
591 // fall through to prettyUrl
592 }
593 }
594
595 if (apiPermalink) {
596 window.open(apiPermalink, "_blank", "noopener,noreferrer");
597 return;
598 }
599
600 if (!tripSlug) {
601 showToast(__("Trip slug is missing", "yatra"), "error");
602 return;
603 }
604
605 window.open(prettyUrl, "_blank", "noopener,noreferrer");
606 };
607
608 const handleCreateTrip = () => {
609 setIsCreateModalOpen(true);
610 setCreateTripError(null);
611 setNewTripTitle("");
612 setNewTripSlug("");
613 setIsSlugManuallyEdited(false);
614 };
615
616 const handleCreateTripConfirm = () => {
617 const title = newTripTitle.trim();
618 if (!title) {
619 setCreateTripError(__("Trip title is required", "yatra"));
620 return;
621 }
622 const slugBase = isSlugManuallyEdited ? newTripSlug : generateSlug(title);
623 const slug = slugBase.trim();
624 if (!slug) {
625 setCreateTripError(__("Trip slug is required", "yatra"));
626 return;
627 }
628 if (!/^[\p{L}\p{N}-]+$/u.test(slug)) {
629 setCreateTripError(
630 __("Slug can only contain letters, numbers, and hyphens", "yatra"),
631 );
632 return;
633 }
634 setNewTripSlug(slug);
635 createTripMutation.mutate({ title, slug });
636 };
637
638 const handleResetFilters = () => {
639 setSearchTerm("");
640 setStatusFilter("all");
641 setSortBy("id");
642 setSortOrder("desc");
643 setPage(1);
644 };
645
646 const handleSort = (field: string) => {
647 if (sortBy === field) {
648 setSortOrder(sortOrder === "asc" ? "desc" : "asc");
649 } else {
650 setSortBy(field);
651 setSortOrder("asc");
652 }
653 };
654
655 const getSortIcon = (field: string) => {
656 if (sortBy !== field) {
657 return <ArrowUpDown className="w-3.5 h-3.5 ml-1 text-gray-400" />;
658 }
659 return sortOrder === "asc" ? (
660 <ArrowUp className="w-3.5 h-3.5 ml-1 text-gray-600 dark:text-gray-300" />
661 ) : (
662 <ArrowDown className="w-3.5 h-3.5 ml-1 text-gray-600 dark:text-gray-300" />
663 );
664 };
665
666 const hasFilters =
667 searchTerm ||
668 statusFilter !== "all" ||
669 sortBy !== "id" ||
670 sortOrder !== "desc";
671
672 // Helper: update trip status, including required fields for backend validation
673 const updateTripStatus = async (trip: Trip, status: string) => {
674 await apiClient.put(`/trips/${trip.id}`, {
675 status,
676 title: trip.title,
677 slug: trip.slug,
678 });
679 // After a status change, refresh both list and stats
680 queryClient.invalidateQueries({ queryKey: ["trips"] });
681 queryClient.invalidateQueries({ queryKey: ["trips-stats"] });
682 };
683
684 // Column visibility state with localStorage persistence
685 const [visibleColumns, setVisibleColumns] = useState<Record<string, boolean>>(
686 () => {
687 const defaults = {
688 trip: true,
689 price: true,
690 status: true,
691 trip_type: true,
692 duration: false,
693 availability: true, // Availability status (has dates, seats)
694 capacity: false, // Min/Max travelers
695 countries: false,
696 difficulty: false,
697 attributes: false, // Custom attributes count
698 // Control chips inside Trip column
699 destinations: true,
700 activities: true,
701 categories: true,
702 bookings: true,
703 created: true,
704 modified: false, // Last modified date
705 };
706
707 if (typeof window === "undefined") {
708 return defaults;
709 }
710
711 const stored = window.localStorage.getItem("yatra-trips-visible-columns");
712 if (stored) {
713 try {
714 const parsed = JSON.parse(stored) as Record<string, boolean>;
715 // Merge stored config with defaults so new columns (duration, countries, difficulty) default to true
716 return { ...defaults, ...parsed };
717 } catch {
718 return defaults;
719 }
720 }
721 return defaults;
722 },
723 );
724
725 const toggleColumn = (columnKey: string) => {
726 const updated = {
727 ...visibleColumns,
728 [columnKey]: !visibleColumns[columnKey],
729 };
730 setVisibleColumns(updated);
731 if (typeof window !== "undefined") {
732 window.localStorage.setItem(
733 "yatra-trips-visible-columns",
734 JSON.stringify(updated),
735 );
736 }
737 };
738
739 const columnOptions = [
740 { key: "trip", label: __("Trip", "yatra"), visible: visibleColumns.trip },
741 {
742 key: "price",
743 label: __("Price", "yatra"),
744 visible: visibleColumns.price,
745 },
746 {
747 key: "status",
748 label: __("Status", "yatra"),
749 visible: visibleColumns.status,
750 },
751 {
752 key: "trip_type",
753 label: __("Trip Type", "yatra"),
754 visible: visibleColumns.trip_type,
755 },
756 {
757 key: "duration",
758 label: __("Duration", "yatra"),
759 visible: visibleColumns.duration,
760 },
761 {
762 key: "countries",
763 label: __("Countries", "yatra"),
764 visible: visibleColumns.countries,
765 },
766 {
767 key: "difficulty",
768 label: __("Difficulty", "yatra"),
769 visible: visibleColumns.difficulty,
770 },
771 {
772 key: "availability",
773 label: __("Availability", "yatra"),
774 visible: visibleColumns.availability,
775 },
776 {
777 key: "capacity",
778 label: __("Capacity", "yatra"),
779 visible: visibleColumns.capacity,
780 },
781 {
782 key: "attributes",
783 label: __("Attributes", "yatra"),
784 visible: visibleColumns.attributes,
785 },
786 // These control which chips are shown inside the Trip column
787 {
788 key: "destinations",
789 label: __("Destinations (chips)", "yatra"),
790 visible: visibleColumns.destinations,
791 },
792 {
793 key: "activities",
794 label: __("Activities (chips)", "yatra"),
795 visible: visibleColumns.activities,
796 },
797 {
798 key: "categories",
799 label: __("Categories (chips)", "yatra"),
800 visible: visibleColumns.categories,
801 },
802 ...(isPro
803 ? [
804 {
805 key: "bookings",
806 label: __("Bookings", "yatra"),
807 visible: visibleColumns.bookings,
808 },
809 ]
810 : []),
811 {
812 key: "created",
813 label: __("Created", "yatra"),
814 visible: visibleColumns.created,
815 },
816 {
817 key: "modified",
818 label: __("Modified", "yatra"),
819 visible: visibleColumns.modified,
820 },
821 ];
822
823 // Selection helpers for shared table
824 const isAllSelected = trips.length > 0 && selectedIds.length === trips.length;
825
826 const handleSelectItem = (id: string | number, checked: boolean) => {
827 setSelectedIds((prev) =>
828 checked ? [...prev, id] : prev.filter((existingId) => existingId !== id),
829 );
830 };
831
832 const handleSelectAll = (checked: boolean) => {
833 if (checked) {
834 setSelectedIds(trips.map((trip: Trip) => trip.id));
835 } else {
836 setSelectedIds([]);
837 }
838 };
839
840 // Status counts for filter tabs (stable, from stats API)
841 const statusCounts = useMemo(() => {
842 if (statsData) {
843 return {
844 all: statsData.all ?? 0,
845 publish: statsData.published ?? 0,
846 draft: statsData.draft ?? 0,
847 review: statsData.review ?? 0,
848 approved: statsData.approved ?? 0,
849 archived: statsData.archived ?? 0,
850 trash: statsData.trash ?? 0,
851 };
852 }
853
854 return {
855 all: 0,
856 publish: 0,
857 draft: 0,
858 review: 0,
859 approved: 0,
860 archived: 0,
861 trash: 0,
862 };
863 }, [statsData]);
864
865 const statusOptions = [
866 { key: "all", label: __("All", "yatra"), count: statusCounts.all },
867 {
868 key: "publish",
869 label: __("Published", "yatra"),
870 count: statusCounts.publish,
871 },
872 { key: "draft", label: __("Draft", "yatra"), count: statusCounts.draft },
873 { key: "review", label: __("Review", "yatra"), count: statusCounts.review },
874 {
875 key: "approved",
876 label: __("Approved", "yatra"),
877 count: statusCounts.approved,
878 },
879 {
880 key: "archived",
881 label: __("Archived", "yatra"),
882 count: statusCounts.archived,
883 },
884 { key: "trash", label: __("Trash", "yatra"), count: statusCounts.trash },
885 ];
886
887 // Bulk action options (status + delete)
888 const bulkActionOptions = useMemo(() => {
889 // In Trash view: allow restore to Draft and permanent delete only
890 if (statusFilter === "trash") {
891 return [
892 { value: "mark_draft", label: __("Restore to Draft", "yatra") },
893 { value: "delete", label: __("Delete Permanently", "yatra") },
894 ];
895 }
896
897 // Other views: normal status changes + move to trash + delete
898 return [
899 { value: "mark_publish", label: __("Mark as Published", "yatra") },
900 { value: "mark_draft", label: __("Mark as Draft", "yatra") },
901 { value: "mark_archived", label: __("Archive", "yatra") },
902 { value: "mark_trash", label: __("Move to Trash", "yatra") },
903 { value: "delete", label: __("Delete Permanently", "yatra") },
904 ];
905 }, [statusFilter]);
906
907 // Bulk apply handler (confirmation UI: BulkActionToolbar → ConfirmationDialog)
908 const handleBulkApply = async () => {
909 if (!bulkAction || selectedIds.length === 0) return;
910
911 try {
912 if (bulkAction === "delete") {
913 await Promise.all(
914 selectedIds.map((id) =>
915 apiClient.delete(`/trips/${id}/permanent-delete`),
916 ),
917 );
918 showToast(__("Trips deleted permanently", "yatra"), "success");
919 } else if (bulkAction.startsWith("mark_")) {
920 const status = bulkAction.replace("mark_", "");
921 // Only update trips that are currently loaded in this page
922 const selectedTrips = trips.filter((trip: Trip) =>
923 selectedIds.includes(trip.id),
924 );
925 await Promise.all(
926 selectedTrips.map((trip: Trip) => updateTripStatus(trip, status)),
927 );
928 showToast(__("Trip status updated successfully", "yatra"), "success");
929 }
930 queryClient.invalidateQueries({ queryKey: ["trips"] });
931 queryClient.invalidateQueries({ queryKey: ["trips-stats"] });
932 setSelectedIds([]);
933 setBulkAction("");
934 } catch (error) {
935 showToast(__("Bulk action failed", "yatra"), "error");
936 }
937 };
938
939 // Columns configuration for shared table
940 const columns = useMemo(() => {
941 const cols: {
942 key: string;
943 label: string;
944 sortable?: boolean;
945 visible?: boolean;
946 width?: string;
947 render?: (trip: Trip, index: number) => React.ReactNode;
948 }[] = [];
949
950 cols.push({
951 key: "title",
952 label: __("Trip", "yatra"),
953 sortable: true,
954 visible: visibleColumns.trip,
955 width: "w-[300px]",
956 render: (trip: Trip) => {
957 const editUrl = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&action=edit&id=${trip.id}`;
958 return (
959 <div className="flex items-start gap-3">
960 {/* Featured Image Thumbnail */}
961 {trip.featured_image_url ? (
962 <img
963 src={trip.featured_image_url}
964 alt={trip.title}
965 className="w-12 h-12 object-cover rounded flex-shrink-0"
966 />
967 ) : (
968 <img
969 src={`${(window as any).yatraAdmin?.siteUrl || ""}/wp-content/plugins/yatra/assets/images/trip-placeholder.svg`}
970 alt={trip.title}
971 className="w-12 h-12 object-cover rounded flex-shrink-0"
972 />
973 )}
974 <div className="flex-1 min-w-0">
975 <div className="flex items-center gap-1">
976 <a
977 href={editUrl}
978 onClick={(e) => {
979 e.preventDefault();
980 e.stopPropagation();
981 handleEdit(trip);
982 }}
983 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"
984 >
985 {trip.title}
986 </a>
987 {can("yatra_view_trips") && trip.status !== "trash" && (
988 <button
989 type="button"
990 onClick={(e) => {
991 e.stopPropagation();
992 handleView(trip);
993 }}
994 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"
995 title={__("View trip in new tab", "yatra")}
996 >
997 <ExternalLink className="w-3.5 h-3.5" />
998 </button>
999 )}
1000 </div>
1001 <div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
1002 {trip.slug}
1003 <span className="ml-1 text-[11px] text-gray-400 dark:text-gray-500">
1004 (ID: {trip.id})
1005 </span>
1006 </div>
1007 {(() => {
1008 const destinationLabel = summarizeDestinations(trip);
1009 const durationLabel = trip.duration_days
1010 ? `${trip.duration_days}${__("d", "yatra")}${trip.duration_nights ? ` / ${trip.duration_nights}${__("n", "yatra")}` : ""}`
1011 : null;
1012 const travelerLabel = summarizeTravelers(trip);
1013 const categoryLabel = summarizeCategories(trip);
1014 const activityLabel = summarizeActivities(trip);
1015 const difficultyLabel = formatLabel(trip.difficulty_level);
1016 const chips = [
1017 destinationLabel &&
1018 visibleColumns.destinations && {
1019 key: "dest",
1020 label: destinationLabel,
1021 icon: Route,
1022 },
1023 durationLabel && {
1024 key: "duration",
1025 label: durationLabel,
1026 icon: Calendar,
1027 },
1028 travelerLabel && {
1029 key: "traveler",
1030 label: travelerLabel,
1031 icon: Users,
1032 },
1033 activityLabel &&
1034 visibleColumns.activities && {
1035 key: "activity",
1036 label: activityLabel,
1037 icon: Activity,
1038 },
1039 categoryLabel &&
1040 visibleColumns.categories && {
1041 key: "category",
1042 label: categoryLabel,
1043 icon: FolderTree,
1044 },
1045 difficultyLabel && {
1046 key: "difficulty",
1047 label: difficultyLabel,
1048 icon: TrendingUp,
1049 },
1050 ].filter(Boolean) as Array<{
1051 key: string;
1052 label: string;
1053 icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
1054 }>;
1055 if (!chips.length) return null;
1056 return (
1057 <div className="flex flex-wrap gap-2 mt-1">
1058 {chips.map(({ key, label, icon: Icon }) => (
1059 <span
1060 key={`${trip.id}-${key}`}
1061 className="inline-flex items-center gap-1 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 px-2 py-0.5 text-[11px]"
1062 >
1063 <Icon className="w-3 h-3" />
1064 {label}
1065 </span>
1066 ))}
1067 </div>
1068 );
1069 })()}
1070 </div>
1071 {trip.featured_priority &&
1072 trip.featured_priority !== "none" &&
1073 isPro && (
1074 <Badge className="text-xs bg-blue-100 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400">
1075 {trip.featured_priority === "featured"
1076 ? __("Featured", "yatra")
1077 : trip.featured_priority === "new"
1078 ? __("New", "yatra")
1079 : trip.featured_priority === "limited"
1080 ? __("Limited", "yatra")
1081 : trip.featured_priority === "bestseller"
1082 ? __("Bestseller", "yatra")
1083 : trip.featured_priority}
1084 </Badge>
1085 )}
1086 </div>
1087 );
1088 },
1089 });
1090
1091 cols.push({
1092 key: "price",
1093 label: __("Price", "yatra"),
1094 sortable: true,
1095 visible: visibleColumns.price,
1096 render: (trip: Trip) => (
1097 <span className="font-medium">{formatPrice(trip)}</span>
1098 ),
1099 });
1100
1101 cols.push({
1102 key: "status",
1103 label: __("Status", "yatra"),
1104 sortable: true,
1105 visible: visibleColumns.status,
1106 render: (trip: Trip) => getStatusBadge(trip.status),
1107 });
1108
1109 cols.push({
1110 key: "trip_type",
1111 label: __("Trip Type", "yatra"),
1112 sortable: true,
1113 visible: visibleColumns.trip_type,
1114 render: (trip: Trip) => getTripTypeBadge(trip.trip_type),
1115 });
1116
1117 // Duration column (days / nights)
1118 cols.push({
1119 key: "duration",
1120 label: __("Duration", "yatra"),
1121 sortable: false,
1122 visible: visibleColumns.duration,
1123 render: (trip: Trip) => {
1124 if (!trip.duration_days && !trip.duration_nights) return null;
1125 const days = trip.duration_days
1126 ? `${trip.duration_days} ${__("days", "yatra")}`
1127 : "";
1128 const nights = trip.duration_nights
1129 ? `${trip.duration_nights} ${__("nights", "yatra")}`
1130 : "";
1131 const label = [days, nights].filter(Boolean).join(" / ");
1132 return (
1133 <span className="text-sm text-gray-700 dark:text-gray-300">
1134 {label}
1135 </span>
1136 );
1137 },
1138 });
1139
1140 // Countries column
1141 cols.push({
1142 key: "countries",
1143 label: __("Countries", "yatra"),
1144 sortable: false,
1145 visible: visibleColumns.countries,
1146 render: (trip: Trip) => {
1147 const list = (trip.countries || []).filter(Boolean);
1148 if (!list.length) return null;
1149 return (
1150 <span className="text-sm text-gray-700 dark:text-gray-300">
1151 {list.join(", ")}
1152 </span>
1153 );
1154 },
1155 });
1156
1157 // Difficulty column (from difficulty_level)
1158 cols.push({
1159 key: "difficulty",
1160 label: __("Difficulty", "yatra"),
1161 sortable: false,
1162 visible: visibleColumns.difficulty,
1163 render: (trip: Trip) => {
1164 // Only show difficulty if it's set and is a valid positive number
1165 const difficultyValue = trip.difficulty_level;
1166 if (
1167 !difficultyValue ||
1168 difficultyValue === "0" ||
1169 Number(difficultyValue) === 0
1170 )
1171 return null;
1172 const difficultyText = formatLabel(String(difficultyValue));
1173 if (!difficultyText) return null;
1174 return (
1175 <span className="text-sm text-gray-700 dark:text-gray-300">
1176 {difficultyText}
1177 </span>
1178 );
1179 },
1180 });
1181
1182 // Availability column
1183 cols.push({
1184 key: "availability",
1185 label: __("Availability", "yatra"),
1186 sortable: false,
1187 visible: visibleColumns.availability,
1188 render: (trip: Trip) => {
1189 const dates = trip.availability_dates || [];
1190 if (!dates.length) {
1191 return (
1192 <span className="text-xs text-gray-400 dark:text-gray-500">
1193 {__("No dates", "yatra")}
1194 </span>
1195 );
1196 }
1197 const availableCount = dates.filter(
1198 (d) => d.status === "available",
1199 ).length;
1200 return (
1201 <div className="text-sm">
1202 <div className="text-gray-700 dark:text-gray-300">
1203 {dates.length} {__("dates", "yatra")}
1204 </div>
1205 {availableCount > 0 && (
1206 <div className="text-xs text-green-600 dark:text-green-400">
1207 {availableCount} {__("available", "yatra")}
1208 </div>
1209 )}
1210 </div>
1211 );
1212 },
1213 });
1214
1215 // Capacity column
1216 cols.push({
1217 key: "capacity",
1218 label: __("Capacity", "yatra"),
1219 sortable: false,
1220 visible: visibleColumns.capacity,
1221 render: (trip: Trip) => {
1222 if (!trip.min_travelers && !trip.max_travelers) return null;
1223 const min = trip.min_travelers || 0;
1224 const max = trip.max_travelers || 0;
1225 return (
1226 <span className="text-sm text-gray-700 dark:text-gray-300">
1227 {min}-{max} {__("travelers", "yatra")}
1228 </span>
1229 );
1230 },
1231 });
1232
1233 // Attributes column
1234 cols.push({
1235 key: "attributes",
1236 label: __("Attributes", "yatra"),
1237 sortable: false,
1238 visible: visibleColumns.attributes,
1239 render: (trip: Trip) => {
1240 const attrCount = trip.attributes
1241 ? Object.keys(trip.attributes).length
1242 : 0;
1243 if (!attrCount) {
1244 return (
1245 <span className="text-xs text-gray-400 dark:text-gray-500">
1246 {__("None", "yatra")}
1247 </span>
1248 );
1249 }
1250 return (
1251 <Badge className="text-xs bg-blue-100 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400">
1252 {attrCount} {__("attributes", "yatra")}
1253 </Badge>
1254 );
1255 },
1256 });
1257
1258 if (isPro) {
1259 cols.push({
1260 key: "bookings",
1261 label: __("Bookings", "yatra"),
1262 sortable: false,
1263 visible: visibleColumns.bookings,
1264 render: (trip: Trip) => (
1265 <span className="text-gray-600 dark:text-gray-400">
1266 {trip.bookings_count || 0}
1267 </span>
1268 ),
1269 });
1270 }
1271
1272 cols.push({
1273 key: "date",
1274 label: __("Created", "yatra"),
1275 sortable: true,
1276 visible: visibleColumns.created,
1277 render: (trip: Trip) => (
1278 <span className="text-gray-500 dark:text-gray-400 text-sm">
1279 {formatDate(trip.created_at)}
1280 </span>
1281 ),
1282 });
1283
1284 // Modified column
1285 cols.push({
1286 key: "modified",
1287 label: __("Modified", "yatra"),
1288 sortable: true,
1289 visible: visibleColumns.modified,
1290 render: (trip: Trip) => {
1291 if (!trip.updated_at) return null;
1292 return (
1293 <span className="text-gray-500 dark:text-gray-400 text-sm">
1294 {formatDate(trip.updated_at)}
1295 </span>
1296 );
1297 },
1298 });
1299
1300 return cols;
1301 // eslint-disable-next-line react-hooks/exhaustive-deps
1302 }, [
1303 isPro,
1304 visibleColumns,
1305 summarizeDestinations,
1306 summarizeTravelers,
1307 summarizeCategories,
1308 formatLabel,
1309 formatPrice,
1310 getStatusBadge,
1311 getTripTypeBadge,
1312 ]);
1313
1314 const navigateToAvailability = (trip: Trip) => {
1315 navigate({
1316 subpage: "trips",
1317 tab: "availability",
1318 trip_id: trip.id.toString(),
1319 });
1320 };
1321
1322 const navigateToRecurringRules = (trip: Trip) => {
1323 navigate({
1324 subpage: "trips",
1325 tab: "availability",
1326 trip_id: trip.id.toString(),
1327 tab_mode: "recurring",
1328 });
1329 };
1330
1331 // Row actions for shared table (3-dot menu)
1332 const tableActions = useMemo(() => {
1333 const actions: any[] = [];
1334
1335 // View is available except for trash trips
1336 if (can("yatra_view_trips")) {
1337 actions.push({
1338 key: "view",
1339 label: __("View (frontend)", "yatra"),
1340 icon: <ExternalLink className="w-4 h-4" />,
1341 onClick: (trip: Trip) => handleView(trip),
1342 condition: (trip: Trip) => trip.status !== "trash", // Hide for trash trips
1343 });
1344 }
1345
1346 // Availability / recurring quick links
1347 actions.push({
1348 key: "view-availability",
1349 label: __("View Availability", "yatra"),
1350 icon: <Calendar className="w-4 h-4" />,
1351 onClick: (trip: Trip) => navigateToAvailability(trip),
1352 });
1353
1354 actions.push({
1355 key: "view-recurring",
1356 label: __("View Recurring Rules", "yatra"),
1357 icon: <RefreshCw className="w-4 h-4" />,
1358 onClick: (trip: Trip) => navigateToRecurringRules(trip),
1359 });
1360
1361 // Edit available for all statuses
1362 if (can("yatra_edit_trips")) {
1363 actions.push({
1364 key: "edit",
1365 label: __("Edit", "yatra"),
1366 icon: <Edit className="w-4 h-4" />,
1367 onClick: (trip: Trip) => handleEdit(trip),
1368 });
1369
1370 // Duplicate trip as draft
1371 actions.push({
1372 key: "duplicate",
1373 label: __("Duplicate", "yatra"),
1374 icon: <Copy className="w-4 h-4" />,
1375 onClick: async (trip: Trip) => {
1376 try {
1377 await apiClient.post(`/trips/${trip.id}/duplicate`);
1378 showToast(__("Trip duplicated as draft.", "yatra"), "success");
1379 queryClient.invalidateQueries({ queryKey: ["trips"] });
1380 } catch (error) {
1381 console.error("Failed to duplicate trip", error);
1382 showToast(__("Failed to duplicate trip.", "yatra"), "error");
1383 }
1384 },
1385 });
1386
1387 // Status-based actions
1388 actions.push({
1389 key: "publish",
1390 label: __("Mark as Published", "yatra"),
1391 icon: <ArrowUp className="w-4 h-4" />,
1392 onClick: async (trip: Trip) => {
1393 await updateTripStatus(trip, "publish");
1394 showToast(__("Trip marked as published.", "yatra"), "success");
1395 queryClient.invalidateQueries({ queryKey: ["trips"] });
1396 },
1397 condition: (trip: Trip) =>
1398 trip.status !== "publish" && trip.status !== "trash",
1399 });
1400
1401 actions.push({
1402 key: "draft",
1403 label: __("Mark as Draft", "yatra"),
1404 icon: <ArrowDown className="w-4 h-4" />,
1405 onClick: async (trip: Trip) => {
1406 await updateTripStatus(trip, "draft");
1407 showToast(__("Trip marked as draft.", "yatra"), "success");
1408 queryClient.invalidateQueries({ queryKey: ["trips"] });
1409 },
1410 condition: (trip: Trip) =>
1411 trip.status !== "draft" && trip.status !== "trash",
1412 });
1413
1414 actions.push({
1415 key: "archive",
1416 label: __("Archive", "yatra"),
1417 icon: <Archive className="w-4 h-4" />,
1418 onClick: async (trip: Trip) => {
1419 await updateTripStatus(trip, "archived");
1420 showToast(__("Trip archived.", "yatra"), "success");
1421 queryClient.invalidateQueries({ queryKey: ["trips"] });
1422 },
1423 condition: (trip: Trip) =>
1424 trip.status !== "archived" && trip.status !== "trash",
1425 });
1426
1427 actions.push({
1428 key: "trash",
1429 label: __("Move to Trash", "yatra"),
1430 icon: <Trash2 className="w-4 h-4" />,
1431 onClick: async (trip: Trip) => {
1432 await updateTripStatus(trip, "trash");
1433 showToast(__("Trip moved to trash.", "yatra"), "success");
1434 queryClient.invalidateQueries({ queryKey: ["trips"] });
1435 },
1436 condition: (trip: Trip) => trip.status !== "trash",
1437 });
1438
1439 actions.push({
1440 key: "restore",
1441 label: __("Restore to Draft", "yatra"),
1442 icon: <ArrowUp className="w-4 h-4" />,
1443 onClick: async (trip: Trip) => {
1444 await updateTripStatus(trip, "draft");
1445 showToast(__("Trip restored to draft.", "yatra"), "success");
1446 queryClient.invalidateQueries({ queryKey: ["trips"] });
1447 },
1448 condition: (trip: Trip) => trip.status === "trash",
1449 });
1450 }
1451
1452 // Permanent delete for all trips (admin should be able to delete any trip)
1453 if (can("yatra_delete_trips")) {
1454 actions.push({
1455 key: "delete",
1456 label: __("Delete Permanently", "yatra"),
1457 icon: <Trash2 className="w-4 h-4" />,
1458 onClick: (trip: Trip) => handleDelete(trip),
1459 variant: "destructive" as const,
1460 condition: () => true, // Show for all trips
1461 });
1462 }
1463
1464 return actions;
1465 // eslint-disable-next-line react-hooks/exhaustive-deps
1466 }, [can, queryClient, showToast]);
1467
1468 return (
1469 <>
1470 <div className="space-y-3">
1471 <PageHeader
1472 title={__("All Trips", "yatra")}
1473 description={__(
1474 "Manage your travel packages and tours. Create, edit, and organize all your trips in one place.",
1475 "yatra",
1476 )}
1477 actionCapability="yatra_edit_trips"
1478 actions={
1479 <div className="flex items-center gap-2">
1480 {/* DEBUG: Refresh button */}
1481 {(window as any).WP_DEBUG && (
1482 <Button
1483 onClick={() => {
1484 refetch();
1485 }}
1486 variant="outline"
1487 className="text-xs"
1488 >
1489 {__("Refresh Data", "yatra")}
1490 </Button>
1491 )}
1492 {isAiEligible() && isAiModuleEnabled() && (
1493 <Button
1494 type="button"
1495 onClick={() => setIsAiWizardOpen(true)}
1496 className="inline-flex items-center gap-2 whitespace-nowrap bg-gradient-to-r from-blue-600 to-blue-700 text-white border-0 hover:from-blue-700 hover:to-blue-800"
1497 title={__(
1498 "Have AI draft an entire trip — details, itinerary, SEO — from a few setup questions.",
1499 "yatra",
1500 )}
1501 >
1502 <Sparkles className="h-4 w-4 shrink-0" />
1503 <span>{__("Add Trip with AI", "yatra")}</span>
1504 </Button>
1505 )}
1506 <Button
1507 onClick={handleCreateTrip}
1508 className="inline-flex items-center gap-2 whitespace-nowrap"
1509 >
1510 <Plus className="h-4 w-4 shrink-0" />
1511 <span>{__("Add New Trip", "yatra")}</span>
1512 </Button>
1513 </div>
1514 }
1515 />
1516
1517 {/* Filters, Search, and Sorting - Always Visible */}
1518 <Card>
1519 <CardContent className="p-4">
1520 <div className="mb-3">
1521 <HelpText
1522 text={__(
1523 "Use the search box to find trips by name. Use filters to show only published, draft, or other status trips. Click column headers to sort.",
1524 "yatra",
1525 )}
1526 />
1527 </div>
1528 <div className="flex flex-col lg:flex-row gap-3 items-stretch lg:items-center w-full">
1529 {/* Search */}
1530 <div className="flex-1 min-w-0 relative">
1531 <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
1532 <Input
1533 type="text"
1534 placeholder={__("Search by trip name...", "yatra")}
1535 value={searchTerm}
1536 onChange={(e) => setSearchTerm(e.target.value)}
1537 className="pl-10 w-full"
1538 title={__("Type to search for trips by name", "yatra")}
1539 />
1540 </div>
1541
1542 {/* Status Filter */}
1543 <div className="w-full lg:w-auto lg:min-w-[160px]">
1544 <Select
1545 value={statusFilter}
1546 onChange={(e) => setStatusFilter(e.target.value)}
1547 className="w-full"
1548 >
1549 <option value="all">{__("All Status", "yatra")}</option>
1550 <option value="publish">{__("Published", "yatra")}</option>
1551 <option value="draft">{__("Draft", "yatra")}</option>
1552 <option value="review">{__("Review", "yatra")}</option>
1553 <option value="approved">{__("Approved", "yatra")}</option>
1554 <option value="archived">{__("Archived", "yatra")}</option>
1555 <option value="trash">{__("Trash", "yatra")}</option>
1556 </Select>
1557 </div>
1558
1559 {/* Sort By */}
1560 <div className="w-full lg:w-auto lg:min-w-[140px]">
1561 <Select
1562 value={sortBy}
1563 onChange={(e) => setSortBy(e.target.value)}
1564 className="w-full"
1565 >
1566 <option value="title">{__("Title", "yatra")}</option>
1567 <option value="price">{__("Price", "yatra")}</option>
1568 <option value="date">{__("Date", "yatra")}</option>
1569 <option value="status">{__("Status", "yatra")}</option>
1570 </Select>
1571 </div>
1572
1573 {/* Sort Order */}
1574 <div className="w-full lg:w-auto lg:flex-shrink-0">
1575 <Button
1576 variant="outline"
1577 onClick={() =>
1578 setSortOrder(sortOrder === "asc" ? "desc" : "asc")
1579 }
1580 className="px-4 flex items-center gap-2 w-full lg:w-auto"
1581 title={
1582 sortOrder === "asc"
1583 ? __("Ascending", "yatra")
1584 : __("Descending", "yatra")
1585 }
1586 >
1587 {sortOrder === "asc" ? (
1588 <ArrowUp className="w-4 h-4" />
1589 ) : (
1590 <ArrowDown className="w-4 h-4" />
1591 )}
1592 <span className="text-sm whitespace-nowrap">
1593 {sortOrder === "asc"
1594 ? __("Asc", "yatra")
1595 : __("Desc", "yatra")}
1596 </span>
1597 </Button>
1598 </div>
1599
1600 {/* Reset Button */}
1601 {hasFilters && (
1602 <div className="w-full lg:w-auto lg:flex-shrink-0">
1603 <Button
1604 variant="outline"
1605 onClick={handleResetFilters}
1606 className="flex items-center gap-2 w-full lg:w-auto"
1607 >
1608 <X className="w-4 h-4" />
1609 <span className="text-sm">{__("Reset", "yatra")}</span>
1610 </Button>
1611 </div>
1612 )}
1613 </div>
1614 </CardContent>
1615 </Card>
1616
1617 <ConditionalRender capability="yatra_view_trips">
1618 {/* Bulk Actions & Column Visibility */}
1619 {!error && (
1620 <BulkActionToolbar
1621 selectedIds={selectedIds}
1622 bulkAction={bulkAction}
1623 setBulkAction={setBulkAction}
1624 onApply={handleBulkApply}
1625 onClearSelection={() => setSelectedIds([])}
1626 statusFilter={statusFilter}
1627 setStatusFilter={setStatusFilter}
1628 statusOptions={statusOptions}
1629 showColumnsDropdown={showColumnsDropdown}
1630 setShowColumnsDropdown={setShowColumnsDropdown}
1631 columnOptions={columnOptions}
1632 onToggleColumn={toggleColumn}
1633 bulkMutationPending={false}
1634 totalItems={total}
1635 bulkActionOptions={bulkActionOptions}
1636 />
1637 )}
1638
1639 {/* Table */}
1640 <Card>
1641 <CardContent className="p-0">
1642 <SharedTable
1643 data={trips}
1644 columns={columns}
1645 actions={tableActions}
1646 isLoading={isLoading}
1647 isError={!!error}
1648 errorText={__("Error Loading Trips", "yatra")}
1649 errorDescription={__(
1650 "We couldn’t connect to the trips service. Please refresh or try again shortly.",
1651 "We couldn’t connect to the trips service. Please refresh or try again shortly.",
1652 )}
1653 onRetry={() =>
1654 queryClient.invalidateQueries({ queryKey: ["trips"] })
1655 }
1656 errorDetails={errorContext.details}
1657 errorRequestInfo={errorContext.requestInfo}
1658 emptyText={
1659 searchTerm || statusFilter !== "all"
1660 ? __("No trips match your search", "yatra")
1661 : __("No trips yet", "yatra")
1662 }
1663 emptyDescription={
1664 searchTerm || statusFilter !== "all"
1665 ? __(
1666 "Try adjusting your search or filters to see more results.",
1667 "yatra",
1668 )
1669 : __(
1670 "Get started by creating your first travel package. Click the button above to add a new trip.",
1671 "yatra",
1672 )
1673 }
1674 onCreateClick={
1675 !searchTerm && statusFilter === "all"
1676 ? handleCreateTrip
1677 : undefined
1678 }
1679 onSort={handleSort}
1680 getSortIcon={getSortIcon}
1681 selectedItemIds={selectedIds}
1682 onSelectItem={handleSelectItem}
1683 onSelectAll={handleSelectAll}
1684 isAllSelected={isAllSelected}
1685 getItemId={(trip: Trip) => trip.id}
1686 />
1687 </CardContent>
1688 </Card>
1689
1690 {/* Pagination - Always Visible */}
1691 {total > 0 && (
1692 <div className="mt-4">
1693 <Pagination
1694 currentPage={page}
1695 totalPages={totalPages}
1696 totalItems={total}
1697 itemsPerPage={itemsPerPage}
1698 onPageChange={setPage}
1699 itemName={__("trips", "yatra")}
1700 />
1701 </div>
1702 )}
1703 </ConditionalRender>
1704 </div>
1705
1706 {/* Create Trip Modal - Using same Modal component as Discounts */}
1707 <Modal
1708 isOpen={isCreateModalOpen}
1709 onClose={() => {
1710 if (!createTripMutation.isPending) {
1711 setIsCreateModalOpen(false);
1712 setCreateTripError(null);
1713 }
1714 }}
1715 title={__("Create New Trip", "yatra")}
1716 description={__(
1717 "Give your trip a name and URL. We'll create a draft and take you to the trip builder.",
1718 "yatra",
1719 )}
1720 size="lg"
1721 footer={
1722 <div className="flex justify-end gap-2">
1723 <Button
1724 variant="outline"
1725 onClick={() => {
1726 if (!createTripMutation.isPending) {
1727 setIsCreateModalOpen(false);
1728 setCreateTripError(null);
1729 }
1730 }}
1731 disabled={createTripMutation.isPending}
1732 >
1733 {__("Cancel", "yatra")}
1734 </Button>
1735 <Button
1736 onClick={handleCreateTripConfirm}
1737 className="flex items-center gap-2"
1738 disabled={createTripMutation.isPending}
1739 >
1740 {createTripMutation.isPending && (
1741 <span className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" />
1742 )}
1743 {createTripMutation.isPending
1744 ? __("Creating…", "yatra")
1745 : __("Create & Continue", "yatra")}
1746 </Button>
1747 </div>
1748 }
1749 >
1750 <div className="space-y-6">
1751 <div className="space-y-2">
1752 <label className="text-sm font-medium text-gray-700 dark:text-gray-300 flex items-center gap-1">
1753 {__("Trip Title", "yatra")}
1754 <span className="text-red-500">*</span>
1755 </label>
1756 <Input
1757 value={newTripTitle}
1758 onChange={(e) => handleNewTripTitleChange(e.target.value)}
1759 placeholder={__("e.g., Bali Beach Retreat", "yatra")}
1760 disabled={createTripMutation.isPending}
1761 className="text-base"
1762 />
1763 <p className="text-xs text-gray-500 dark:text-gray-400">
1764 {__(
1765 "A catchy title that describes your trip. Recommended: 50-60 characters for best SEO results.",
1766 "yatra",
1767 )}
1768 </p>
1769 </div>
1770
1771 <div className="space-y-2">
1772 <div className="flex items-center justify-between">
1773 <label className="text-sm font-medium text-gray-700 dark:text-gray-300 flex items-center gap-1">
1774 {__("Trip URL", "yatra")}
1775 <span className="text-red-500">*</span>
1776 <span className="text-[10px] font-normal text-gray-400">
1777 {__("(URL friendly)", "yatra")}
1778 </span>
1779 </label>
1780 {!isSlugManuallyEdited && (
1781 <button
1782 type="button"
1783 onClick={enableSlugEditing}
1784 className="text-xs text-blue-600 dark:text-blue-400 hover:underline"
1785 >
1786 {__("Customize URL", "yatra")}
1787 </button>
1788 )}
1789 </div>
1790 <Input
1791 value={newTripSlug}
1792 onChange={(e) => handleNewTripSlugChange(e.target.value)}
1793 placeholder={__("bali-beach-retreat", "yatra")}
1794 disabled={createTripMutation.isPending}
1795 readOnly={!isSlugManuallyEdited}
1796 className={`font-mono text-sm ${!isSlugManuallyEdited ? "bg-gray-100 dark:bg-gray-800 cursor-not-allowed" : ""}`}
1797 />
1798 <p className="text-xs text-gray-500 dark:text-gray-400">
1799 {isSlugManuallyEdited
1800 ? __(
1801 "Editing slug manually. Keep it short, lowercase, and hyphen-separated.",
1802 "yatra",
1803 )
1804 : __(
1805 'Auto-generated from the title. Click "Customize URL" if you need a custom slug.',
1806 "yatra",
1807 )}
1808 </p>
1809 {(newTripSlug || newTripTitle) && (
1810 <div className="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
1811 <p className="text-xs text-gray-600 dark:text-gray-400 mb-1">
1812 {__("Preview:", "yatra")}
1813 </p>
1814 <p className="text-xs text-gray-900 dark:text-white font-mono break-all">
1815 {(window as any).yatraAdmin?.siteUrl || "https://example.com"}
1816 /trips/{newTripSlug || generateSlug(newTripTitle)}
1817 </p>
1818 </div>
1819 )}
1820 </div>
1821
1822 {createTripError && (
1823 <div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
1824 <p className="text-sm text-red-600 dark:text-red-400">
1825 {createTripError}
1826 </p>
1827 </div>
1828 )}
1829 </div>
1830 </Modal>
1831
1832 {/* AI: "Add Trip with AI" wizard — multi-step setup → generate →
1833 review → save. Drafts an entire trip from a few seed questions. */}
1834 <CreateTripWithAiWizard
1835 open={isAiWizardOpen}
1836 onClose={() => setIsAiWizardOpen(false)}
1837 />
1838
1839 {/* Permanent Delete Confirmation (for Trash only) */}
1840 <ConfirmationDialog
1841 isOpen={isDeleteDialogOpen && !!tripToDelete}
1842 onClose={() => {
1843 if (!deleteMutation.isPending) {
1844 setIsDeleteDialogOpen(false);
1845 setTripToDelete(null);
1846 }
1847 }}
1848 onConfirm={() => {
1849 if (tripToDelete && !deleteMutation.isPending) {
1850 deleteMutation.mutate(tripToDelete.id);
1851 }
1852 }}
1853 title={__("Delete Trip Permanently", "yatra")}
1854 message={
1855 tripToDelete
1856 ? __(
1857 'Are you sure you want to permanently delete "{title}"? This cannot be undone and will remove all associated bookings.',
1858 "yatra",
1859 ).replace("{title}", tripToDelete.title)
1860 : ""
1861 }
1862 confirmText={__("Delete Permanently", "yatra")}
1863 cancelText={__("Cancel", "yatra")}
1864 variant="danger"
1865 isLoading={deleteMutation.isPending}
1866 />
1867 </>
1868 );
1869 };
1870
1871 export default Trips;
1872