/** * Destinations Page * Clean, minimal SaaS-style destinations management page */ import React, { useState, useMemo, useEffect } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Plus, ArrowUpDown, ArrowUp, ArrowDown, RotateCcw, Edit, Trash2, Eye, ExternalLink, } from "lucide-react"; import { Pagination, SearchFilterToolbar, BulkActionToolbar, Table as SharedTable, } from "../components/shared"; import { getDefaultBulkStatusOptions } from "../components/shared/bulkStatusOptions"; import { __ } from "../lib/i18n"; import { usePermissions } from "../hooks/usePermissions"; import { useToast } from "../components/ui/toast"; import { fetchSettings } from "../api/settings-api"; import { apiClient } from "../lib/api-client"; import { getErrorContext } from "../lib/errors"; import { buildYatraSinglePublicUrls, isWordPressPlainPermalink, } from "../lib/frontend-permalink-urls"; import { Button } from "../components/ui/button"; import { PageHeader } from "../components/common/PageHeader"; import { Card, CardContent } from "../components/ui/card"; import { ConditionalRender } from "../components/ui/conditional-render"; import { ConfirmationDialog } from "../components/ui/confirmation-dialog"; import { IconSelector } from "../components/ui/icon-selector"; import type { IconPickerValue } from "../components/ui/icon-picker"; interface Destination { id: string | number; name: string; slug: string; description: string; icon?: IconPickerValue | null; status: string; trip_count?: number; metadata?: { seo_title?: string; seo_description?: string; seo_keywords?: string; [key: string]: any; }; created_at: string; updated_at: string; created_by: string; updated_by: string; created_by_name?: string; updated_by_name?: string; } const Destinations: React.FC = () => { const [searchTerm, setSearchTerm] = useState(""); const [showColumnsDropdown, setShowColumnsDropdown] = useState(false); const [sortBy, setSortBy] = useState("id"); const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); const [page, setPage] = useState(1); const [statusFilter, setStatusFilter] = useState("all"); const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]); const [bulkAction, setBulkAction] = useState(""); const [visibleColumns, setVisibleColumns] = useState(() => { const defaultColumns = { name: true, description: true, trips: true, status: true, seo_title: false, seo_description: false, seo_keywords: false, created_at: false, updated_at: false, created_by_name: false, updated_by_name: false, }; const saved = localStorage.getItem("yatra_destinations_visible_columns_v2"); return saved ? { ...defaultColumns, ...JSON.parse(saved) } : defaultColumns; }); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; destination: Destination | null; }>({ isOpen: false, destination: null, }); const queryClient = useQueryClient(); const { can } = usePermissions(); const { showToast } = useToast(); // Close dropdown when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { const target = event.target as HTMLElement; if ( !target.closest("[data-columns-trigger]") && !target.closest("[data-columns-content]") ) { setShowColumnsDropdown(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, []); // Build query params const queryParams = useMemo(() => { const params: Record = { page, per_page: 10, orderby: sortBy, order: sortOrder, }; if (searchTerm) { params.search = searchTerm; } if (statusFilter !== "all") { params.status = statusFilter; } return params; }, [searchTerm, statusFilter, sortBy, sortOrder, page]); // Fetch status counts from API const { data: statsData } = useQuery({ queryKey: ["destinations-stats"], queryFn: async () => { try { const response = await apiClient.get("/destinations/stats"); return response; } catch (error: any) { return { all: 0, publish: 0, draft: 0, trash: 0 }; } }, enabled: can("yatra_view_trips"), }); // Fetch destinations from API const { data, isLoading, error } = useQuery({ queryKey: ["destinations", queryParams], queryFn: async () => { try { const response = await apiClient.get("/destinations", { params: queryParams, }); return response; } catch (error: any) { showToast( error?.message || __("Failed to load destinations", "yatra"), "error", ); throw error; } }, enabled: can("yatra_view_trips"), }); // Fetch settings for permalink handling const { data: settings } = useQuery({ queryKey: ["settings"], queryFn: async () => { try { const response = await fetchSettings(); return response; } catch (error) { return null; } }, enabled: can("yatra_access_admin"), staleTime: 5 * 60 * 1000, // Cache for 5 minutes }); // Delete mutation const deleteMutation = useMutation({ mutationFn: async (id: string | number) => { return await apiClient.delete(`/destinations/${id}`); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["destinations"] }); queryClient.invalidateQueries({ queryKey: ["destinations-stats"] }); showToast(__("Destination deleted successfully", "yatra"), "success"); setDeleteConfirm({ isOpen: false, destination: null }); }, onError: (error: any) => { showToast( error?.message || __("Failed to delete destination", "yatra"), "error", ); }, }); const destinations = data?.data || []; const total = data?.total || 0; const totalPages = Math.ceil(total / 10); const errorContext = getErrorContext(error); const handleEdit = (destination: Destination) => { window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&tab=destinations&action=edit&id=${destination.id}`; }; const handleView = async (destination: Destination) => { const destinationSlug = destination.slug || ""; const { plainUrl, prettyUrl } = buildYatraSinglePublicUrls({ entity: "destination", slug: destinationSlug, bases: settings as Record | null, }); if (isWordPressPlainPermalink()) { if (!destinationSlug) { showToast(__("Destination slug is missing", "yatra"), "error"); return; } window.open(plainUrl, "_blank", "noopener,noreferrer"); return; } let apiPermalink = (destination as any)?.permalink || (destination as any)?.url; if (!apiPermalink && destination.id) { try { const detail = await apiClient.get(`/destinations/${destination.id}`); apiPermalink = (detail as any)?.permalink || (detail as any)?.url || apiPermalink; } catch (e) { // fall through to prettyUrl } } if (apiPermalink) { window.open(apiPermalink, "_blank", "noopener,noreferrer"); return; } if (!destinationSlug) { showToast(__("Destination slug is missing", "yatra"), "error"); return; } window.open(prettyUrl, "_blank", "noopener,noreferrer"); }; const handleDelete = (destination: Destination) => { setDeleteConfirm({ isOpen: true, destination }); }; const confirmDelete = () => { if (deleteConfirm.destination) { deleteMutation.mutate(deleteConfirm.destination.id); } }; const handleCreateDestination = () => { window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&tab=destinations&action=create`; }; const handleResetFilters = () => { setSearchTerm(""); setStatusFilter("all"); setSortBy("id"); setSortOrder("desc"); setPage(1); }; const handleSort = (field: string) => { if (sortBy === field) { setSortOrder(sortOrder === "asc" ? "desc" : "asc"); } else { setSortBy(field); setSortOrder(field === "id" ? "desc" : "asc"); } }; const getSortIcon = (field: string) => { if (sortBy !== field) { return ; } return sortOrder === "asc" ? ( ) : ( ); }; // Toggle column visibility const toggleColumn = (columnKey: string) => { const newVisibleColumns = { ...visibleColumns, [columnKey]: !visibleColumns[columnKey as keyof typeof visibleColumns], }; setVisibleColumns(newVisibleColumns); localStorage.setItem( "yatra_destinations_visible_columns_v2", JSON.stringify(newVisibleColumns), ); }; // Status counts from stats API const statusCounts = useMemo(() => { if (statsData) { return { all: statsData.all ?? 0, publish: statsData.publish ?? 0, draft: statsData.draft ?? 0, trash: statsData.trash ?? 0, }; } return { all: 0, publish: 0, draft: 0, trash: 0, }; }, [statsData]); // Bulk actions mutation const bulkMutation = useMutation({ mutationFn: async ({ action, ids, }: { action: string; ids: (string | number)[]; }) => { return await apiClient.post("/destinations/bulk", { action, ids }); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["destinations"] }); queryClient.invalidateQueries({ queryKey: ["destinations-stats"] }); showToast(__("Bulk action completed successfully", "yatra"), "success"); setSelectedIds([]); setBulkAction(""); }, onError: (error: any) => { showToast( error?.message || __("Failed to perform bulk action", "yatra"), "error", ); }, }); const handleBulkApply = () => { if (!bulkAction) { showToast(__("Select a bulk action first.", "yatra"), "warning"); return; } if (selectedIds.length === 0) { showToast(__("Select at least one destination.", "yatra"), "warning"); return; } // Execute bulk action (confirmation is now handled in BulkActionToolbar) bulkMutation.mutate({ action: bulkAction, ids: selectedIds }); }; const viewFilters = [ { key: "all", label: __("All", "yatra"), count: statusCounts.all ?? 0 }, { key: "publish", label: __("Published", "yatra"), count: statusCounts.publish ?? 0, }, { key: "draft", label: __("Draft", "yatra"), count: statusCounts.draft ?? 0, }, { key: "trash", label: __("Trash", "yatra"), count: statusCounts.trash ?? 0, }, ]; const hasFilters = searchTerm || statusFilter !== "all" || sortBy !== "id" || sortOrder !== "desc"; return (
{/* Delete Confirmation Dialog */} setDeleteConfirm({ isOpen: false, destination: null })} onConfirm={confirmDelete} title={__("Delete Destination", "yatra")} message={ deleteConfirm.destination ? __( 'Are you sure you want to delete "{name}"? This action cannot be undone.', "yatra", ).replace("{name}", deleteConfirm.destination.name) : __( "Are you sure you want to delete this destination? This action cannot be undone.", "yatra", ) } confirmText={__("Delete", "yatra")} cancelText={__("Cancel", "yatra")} variant="danger" isLoading={deleteMutation.isPending} /> {__("Add New Destination", "yatra")} } /> {/* Filters, Search, and Sorting - Always Visible */} { setStatusFilter(value); setPage(1); setSelectedIds([]); setBulkAction(""); }} statusOptions={[ { value: "all", label: __("All Status", "yatra") }, { value: "publish", label: __("Published", "yatra") }, { value: "draft", label: __("Draft", "yatra") }, { value: "trash", label: __("Trash", "yatra") }, ]} sortBy={sortBy} onSortByChange={setSortBy} sortOrder={sortOrder} onSortOrderChange={setSortOrder} sortOptions={[ { value: "name", label: __("Name", "yatra") }, { value: "status", label: __("Status", "yatra") }, { value: "created_at", label: __("Created At", "yatra") }, { value: "updated_at", label: __("Updated At", "yatra") }, ]} onResetFilters={handleResetFilters} hasFilters={!!hasFilters} placeholder={__("Search destinations...", "yatra")} /> {!error && ( setSelectedIds([])} statusFilter={statusFilter} setStatusFilter={(value) => { setStatusFilter(value); setPage(1); setSelectedIds([]); setBulkAction(""); }} statusOptions={viewFilters} showColumnsDropdown={showColumnsDropdown} setShowColumnsDropdown={setShowColumnsDropdown} columnOptions={[ { key: "name", label: __("Destination", "yatra"), visible: visibleColumns.name, }, { key: "description", label: __("Description", "yatra"), visible: visibleColumns.description, }, { key: "trips", label: __("Trips", "yatra"), visible: visibleColumns.trips, }, { key: "seo_title", label: __("SEO Title", "yatra"), visible: visibleColumns.seo_title, }, { key: "seo_description", label: __("SEO Description", "yatra"), visible: visibleColumns.seo_description, }, { key: "seo_keywords", label: __("SEO Keywords", "yatra"), visible: visibleColumns.seo_keywords, }, { key: "status", label: __("Status", "yatra"), visible: visibleColumns.status, }, { key: "created_at", label: __("Created Date", "yatra"), visible: visibleColumns.created_at, }, { key: "updated_at", label: __("Updated Date", "yatra"), visible: visibleColumns.updated_at, }, { key: "created_by_name", label: __("Created By", "yatra"), visible: visibleColumns.created_by_name, }, { key: "updated_by_name", label: __("Updated By", "yatra"), visible: visibleColumns.updated_by_name, }, ]} onToggleColumn={toggleColumn} bulkMutationPending={bulkMutation.isPending} totalItems={destinations.length} bulkActionOptions={getDefaultBulkStatusOptions(statusFilter)} /> )} <> [ { key: "name", label: __("Destination", "yatra"), sortable: true, visible: visibleColumns.name, render: (destination: Destination) => (
{/* Icon/Image */}
{destination.icon ? ( destination.icon.type === "image" ? ( {destination.name} ) : ( ) ) : (
{destination.name.charAt(0).toUpperCase()}
)}
{/* Text */}
{destination.name} {can("yatra_view_trips") && destination.status !== "trash" && ( )}
{destination.slug} ({__("ID:", "yatra")} {destination.id})
), }, { key: "trips", label: __("Trips", "yatra"), sortable: false, visible: visibleColumns.trips, render: (destination: Destination) => ( {typeof destination.trip_count === "number" ? destination.trip_count : 0} ), }, { key: "description", label: __("Description", "yatra"), visible: visibleColumns.description, render: (destination: Destination) => ( {destination.description || __("No description", "yatra")} ), }, { key: "seo_title", label: __("SEO Title", "yatra"), visible: visibleColumns.seo_title, render: (destination: Destination) => ( {destination.metadata?.seo_title || __("Not set", "yatra")} ), }, { key: "seo_description", label: __("SEO Description", "yatra"), visible: visibleColumns.seo_description, render: (destination: Destination) => ( {destination.metadata?.seo_description ? destination.metadata.seo_description.length > 50 ? destination.metadata.seo_description.substring( 0, 50, ) + "..." : destination.metadata.seo_description : __("Not set", "yatra")} ), }, { key: "seo_keywords", label: __("SEO Keywords", "yatra"), visible: visibleColumns.seo_keywords, render: (destination: Destination) => ( {destination.metadata?.seo_keywords ? destination.metadata.seo_keywords.length > 30 ? destination.metadata.seo_keywords.substring( 0, 30, ) + "..." : destination.metadata.seo_keywords : __("Not set", "yatra")} ), }, { key: "status", label: __("Status", "yatra"), sortable: true, visible: visibleColumns.status, render: (destination: Destination) => ( {destination.status === "trash" || statusFilter === "trash" ? __("Trash", "yatra") : destination.status === "publish" ? __("Published", "yatra") : __("Draft", "yatra")} ), }, { key: "created_at", label: __("Created Date", "yatra"), sortable: true, visible: visibleColumns.created_at, render: (destination: Destination) => ( {new Date(destination.created_at).toLocaleDateString( "en-US", { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }, )} ), }, { key: "updated_at", label: __("Updated Date", "yatra"), sortable: true, visible: visibleColumns.updated_at, render: (destination: Destination) => ( {new Date(destination.updated_at).toLocaleDateString( "en-US", { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }, )} ), }, { key: "created_by_name", label: __("Created By", "yatra"), visible: visibleColumns.created_by_name, render: (destination: Destination) => ( {destination.created_by_name || __("Unknown", "yatra")} ), }, { key: "updated_by_name", label: __("Updated By", "yatra"), visible: visibleColumns.updated_by_name, render: (destination: Destination) => ( {destination.updated_by_name || __("Unknown", "yatra")} ), }, ], // eslint-disable-next-line react-hooks/exhaustive-deps [visibleColumns], )} actions={[ { key: "view", label: __("View", "yatra"), icon: , onClick: (destination: Destination) => { // Open destination in new tab const destinationUrl = `${window.yatraAdmin?.siteUrl || ""}/destination/${destination.slug}`; window.open(destinationUrl, "_blank"); }, condition: () => true, // Always show view action }, { key: "edit", label: __("Edit", "yatra"), icon: , onClick: handleEdit, condition: () => can("yatra_edit_trips"), }, { key: "restore", label: __("Restore", "yatra"), icon: , onClick: (destination: Destination) => { // Handle restore action bulkMutation.mutate({ action: "restore", ids: [destination.id], }); }, condition: (destination: Destination) => (destination.status === "trash" || statusFilter === "trash") && can("yatra_edit_trips"), }, { key: "trash", label: __("Move to Trash", "yatra"), icon: , onClick: (destination: Destination) => { bulkMutation.mutate({ action: "trash", ids: [destination.id], }); }, condition: (destination: Destination) => destination.status !== "trash" && statusFilter !== "trash" && can("yatra_edit_trips"), }, { key: "delete", label: __("Delete Permanently", "yatra"), icon: , onClick: handleDelete, condition: (destination: Destination) => (destination.status === "trash" || statusFilter === "trash") && can("yatra_delete_trips"), variant: "destructive", }, ]} isLoading={isLoading} isError={!!error} errorText={__("Error loading destinations", "yatra")} errorDescription={__( "We couldn’t connect to the destinations service. Please refresh or try again shortly.", "yatra", )} onRetry={() => queryClient.invalidateQueries({ queryKey: ["destinations"] }) } errorDetails={errorContext.details} errorRequestInfo={errorContext.requestInfo} emptyText={__("No destinations found", "yatra")} emptyDescription={ hasFilters ? __( "Try adjusting your filters to see more results.", "yatra", ) : __( "Get started by creating your first destination.", "yatra", ) } onCreateClick={ can("yatra_edit_trips") ? handleCreateDestination : undefined } onSort={handleSort} getSortIcon={getSortIcon} selectedItemIds={selectedIds} onSelectItem={(id: string | number, checked: boolean) => { if (checked) { setSelectedIds([...selectedIds, id]); } else { setSelectedIds( selectedIds.filter((selectedId) => selectedId !== id), ); } }} onSelectAll={(checked: boolean) => { if (checked) { setSelectedIds(destinations.map((d: Destination) => d.id)); } else { setSelectedIds([]); } }} isAllSelected={ destinations.length > 0 && selectedIds.length === destinations.length } getItemId={(destination: Destination) => destination.id} getItemStatus={(destination: Destination) => destination.status} statusFilter={statusFilter} skeletonRows={5} />
{/* Pagination */} {total > 0 && (
setPage(newPage)} itemName={__("destinations", "yatra")} />
)}
); }; export default Destinations;