/** * Reviews Page * Clean, minimal SaaS-style reviews management page */ import React, { useState, useMemo } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Plus, Search, X, ArrowUpDown, ArrowUp, ArrowDown, Eye, Edit, Trash2, Star, } from "lucide-react"; import { __ } from "../lib/i18n"; import { formatDate as formatDateUtil } from "../lib/dateFormat"; import { usePermissions } from "../hooks/usePermissions"; import { apiClient } from "../lib/api-client"; import { apiService } from "../lib/api-client"; import { getErrorContext } from "../lib/errors"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { Select } from "../components/ui/select"; import { PageHeader } from "../components/common/PageHeader"; import { Card, CardContent } from "../components/ui/card"; import { ConfirmationDialog } from "../components/ui/confirmation-dialog"; import { Pagination, Table as SharedTable, BulkActionToolbar, } from "../components/shared"; interface Review { id: number; trip_id: number; trip_title: string; trip_slug: string; customer_name: string; customer_email: string; rating: number; title: string; content: string; status: string; verified: boolean; created_at: string; } const Reviews: React.FC = () => { const [searchTerm, setSearchTerm] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); const [ratingFilter, setRatingFilter] = useState("all"); const [sortBy, setSortBy] = useState("created_at"); const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); const [page, setPage] = useState(1); const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; review: Review | null; }>({ isOpen: false, review: null, }); const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]); const [bulkAction, setBulkAction] = useState(""); const [showColumnsDropdown, setShowColumnsDropdown] = useState(false); const [visibleColumns, setVisibleColumns] = useState(() => { if (typeof window === "undefined") { return { trip: true, customer: true, rating: true, review: true, status: true, date: true, }; } const saved = window.localStorage.getItem("yatra-reviews-visible-columns"); return saved ? JSON.parse(saved) : { trip: true, customer: true, rating: true, review: true, status: true, date: true, }; }); const queryClient = useQueryClient(); const { can } = usePermissions(); // 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; } if (ratingFilter !== "all") { params.rating = ratingFilter; } return params; }, [searchTerm, statusFilter, ratingFilter, sortBy, sortOrder, page]); // Fetch reviews from API const { data, isLoading, error } = useQuery({ queryKey: ["reviews", queryParams], queryFn: async () => { const response = await apiClient.get("/reviews", { params: queryParams }); // Transform API response to match our interface const reviews = (response?.data || []).map((item: any) => ({ id: item.id, trip_id: item.trip_id, trip_title: item.trip_title || "Unknown Trip", trip_slug: item.trip_slug || "", customer_name: item.customer_name || item.author_name || "Anonymous", customer_email: item.customer_email || item.author_email || "", rating: item.rating, title: item.title || "", comment: item.comment || item.content || "", status: item.status || "pending", verified: item.verified || false, created_at: item.created_at || "", })); return { data: reviews, total: response?.total || 0, page: response?.page || page, per_page: response?.per_page || 10, }; }, enabled: can("yatra_view_reviews"), }); // Delete mutation const deleteMutation = useMutation({ mutationFn: async (id: number) => { return await apiClient.delete(`/reviews/${id}`); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["reviews"] }); }, }); const reviews: Review[] = data?.data || []; const total = data?.total || 0; const totalPages = Math.ceil(total / 10); const errorContext = getErrorContext(error); const formatDate = (dateString: string) => { return formatDateUtil(dateString); }; const renderStars = (rating: number) => { return (
{[1, 2, 3, 4, 5].map((star) => ( ))} {rating}
); }; const getStatusBadge = (status: string) => { const statusMap: Record = { approved: { className: "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400", label: __("Approved", "yatra"), }, pending: { className: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-400", label: __("Pending", "yatra"), }, spam: { className: "bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400", label: __("Spam", "yatra"), }, trash: { className: "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400", label: __("Trash", "yatra"), }, }; const statusInfo = statusMap[status] || { className: "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400", label: status, }; return ( {statusInfo.label} ); }; const handleEdit = (review: Review) => { window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=reviews&action=edit&id=${review.id}`; }; const handleDelete = (review: Review) => { setDeleteConfirm({ isOpen: true, review }); }; const confirmDelete = () => { if (deleteConfirm.review) { deleteMutation.mutate(deleteConfirm.review.id); setDeleteConfirm({ isOpen: false, review: null }); } }; const handleView = (review: Review) => { window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=reviews&action=view&id=${review.id}`; }; const handleCreateReview = () => { window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=reviews&action=create`; }; const handleResetFilters = () => { setSearchTerm(""); setStatusFilter("all"); setRatingFilter("all"); setSortBy("created_at"); setSortOrder("desc"); setPage(1); }; const handleSort = (field: string) => { if (sortBy === field) { setSortOrder(sortOrder === "asc" ? "desc" : "asc"); } else { setSortBy(field); setSortOrder("desc"); } }; const getSortIcon = (field: string) => { if (sortBy !== field) { return ; } return sortOrder === "asc" ? ( ) : ( ); }; const hasFilters = searchTerm || statusFilter !== "all" || ratingFilter !== "all" || sortBy !== "created_at" || sortOrder !== "desc"; // Bulk action options depend on current status filter const bulkActionOptions = useMemo(() => { // In trash view, only allow permanent delete if (statusFilter === "trash") { return [{ value: "delete", label: __("Delete Permanently", "yatra") }]; } // In spam view, allow move to trash or delete if (statusFilter === "spam") { return [ { value: "mark_trash", label: __("Move to Trash", "yatra") }, { value: "delete", label: __("Delete Permanently", "yatra") }, ]; } // Default view: allow approve, pending, spam, trash, delete return [ { value: "mark_approved", label: __("Mark as Approved", "yatra") }, { value: "mark_pending", label: __("Mark as Pending", "yatra") }, { value: "mark_spam", label: __("Mark as Spam", "yatra") }, { value: "mark_trash", label: __("Move to Trash", "yatra") }, { value: "delete", label: __("Delete Permanently", "yatra") }, ]; }, [statusFilter]); // Bulk actions const handleBulkApply = () => { if (!bulkAction || selectedIds.length === 0) { return; } const run = async () => { try { await apiService.bulkReviewsAction(bulkAction, selectedIds); queryClient.invalidateQueries({ queryKey: ["reviews"] }); } catch (error) { console.error("Bulk review action error", error); } finally { setSelectedIds([]); setBulkAction(""); } }; void run(); }; const toggleColumn = (columnKey: string) => { const newVisible = { ...visibleColumns, [columnKey]: !visibleColumns[columnKey as keyof typeof visibleColumns], }; setVisibleColumns(newVisible); if (typeof window !== "undefined") { window.localStorage.setItem( "yatra-reviews-visible-columns", JSON.stringify(newVisible), ); } }; const columnOptions = [ { key: "trip", label: __("Trip", "yatra"), visible: visibleColumns.trip }, { key: "customer", label: __("Customer", "yatra"), visible: visibleColumns.customer, }, { key: "rating", label: __("Rating", "yatra"), visible: visibleColumns.rating, }, { key: "review", label: __("Review", "yatra"), visible: visibleColumns.review, }, { key: "status", label: __("Status", "yatra"), visible: visibleColumns.status, }, { key: "date", label: __("Date", "yatra"), visible: visibleColumns.date }, ]; const handleApprove = async (review: Review) => { await apiService.updateReviewStatus(review.id, "approved"); queryClient.invalidateQueries({ queryKey: ["reviews"] }); }; const handleMarkPending = async (review: Review) => { await apiService.updateReviewStatus(review.id, "pending"); queryClient.invalidateQueries({ queryKey: ["reviews"] }); }; const handleMarkSpam = async (review: Review) => { await apiService.updateReviewStatus(review.id, "spam"); queryClient.invalidateQueries({ queryKey: ["reviews"] }); }; const handleMarkTrash = async (review: Review) => { await apiService.updateReviewStatus(review.id, "trash"); queryClient.invalidateQueries({ queryKey: ["reviews"] }); }; const actions = [ { key: "view", label: __("View", "yatra"), icon: , onClick: (review: Review) => handleView(review), condition: () => can("yatra_view_reviews"), }, { key: "approve", label: __("Approve", "yatra"), icon: , onClick: (review: Review) => handleApprove(review), condition: (review: Review) => can("yatra_edit_reviews") && review.status !== "approved", }, { key: "mark_pending", label: __("Mark as Pending", "yatra"), icon: , onClick: (review: Review) => handleMarkPending(review), condition: (review: Review) => can("yatra_edit_reviews") && review.status !== "pending", }, { key: "edit", label: __("Edit", "yatra"), icon: , onClick: (review: Review) => handleEdit(review), condition: () => can("yatra_edit_reviews"), }, { key: "mark_spam", label: __("Mark as Spam", "yatra"), icon: , onClick: (review: Review) => handleMarkSpam(review), condition: (review: Review) => can("yatra_edit_reviews") && review.status !== "spam", }, { key: "mark_trash", label: __("Move to Trash", "yatra"), icon: , onClick: (review: Review) => handleMarkTrash(review), condition: (review: Review) => can("yatra_edit_reviews") && review.status !== "trash", }, { key: "delete", label: __("Delete Permanently", "yatra"), icon: , onClick: (review: Review) => handleDelete(review), variant: "destructive" as const, condition: () => can("yatra_delete_reviews"), }, ]; return (
setDeleteConfirm({ isOpen: false, review: null })} onConfirm={confirmDelete} title={__("Delete Review", "yatra")} message={ deleteConfirm.review ? __( 'Are you sure you want to delete this review by "{name}"? This action cannot be undone.', "yatra", ).replace("{name}", deleteConfirm.review.customer_name) : __( "Are you sure you want to delete this review? This action cannot be undone.", "yatra", ) } confirmText={__("Delete", "yatra")} cancelText={__("Cancel", "yatra")} variant="danger" isLoading={deleteMutation.isPending} /> {__("Add New Review", "yatra")} } /> {/* Filters, Search, and Sorting - Always Visible */}
{/* Search */}
setSearchTerm(e.target.value)} className="pl-10 w-full" />
{/* Status Filter */} {/* Rating Filter */} {/* Sort By */} {/* Sort Order */} {/* Reset Button */} {hasFilters && ( )}
{/* Bulk actions toolbar - shared component */} setSelectedIds([])} statusFilter={statusFilter} setStatusFilter={(value: string) => { setStatusFilter(value); setPage(1); setSelectedIds([]); setBulkAction(""); }} statusOptions={[ { key: "all", label: __("All", "yatra"), count: total }, { key: "approved", label: __("Approved", "yatra"), count: reviews.filter((r) => r.status === "approved").length, }, { key: "pending", label: __("Pending", "yatra"), count: reviews.filter((r) => r.status === "pending").length, }, { key: "spam", label: __("Spam", "yatra"), count: reviews.filter((r) => r.status === "spam").length, }, { key: "trash", label: __("Trash", "yatra"), count: reviews.filter((r) => r.status === "trash").length, }, ]} showColumnsDropdown={showColumnsDropdown} setShowColumnsDropdown={setShowColumnsDropdown} columnOptions={columnOptions} onToggleColumn={toggleColumn} bulkMutationPending={deleteMutation.isPending} totalItems={reviews.length} bulkActionOptions={bulkActionOptions} /> review.trip_id ? ( {review.trip_title} ) : ( {review.trip_title} ), }, visibleColumns.customer && { key: "customer", label: __("Customer", "yatra"), render: (review: Review) => (
{review.customer_name}
{review.customer_email}
{review.verified && ( {__("Verified", "yatra")} )}
), }, visibleColumns.rating && { key: "rating", label: __("Rating", "yatra"), render: (review: Review) => renderStars(review.rating), }, visibleColumns.review && { key: "review", label: __("Review", "yatra"), render: (review: Review) => (
{review.title}
{review.content}
), }, visibleColumns.status && { key: "status", label: __("Status", "yatra"), render: (review: Review) => getStatusBadge(review.status), }, visibleColumns.date && { key: "date", label: __("Date", "yatra"), render: (review: Review) => ( {formatDate(review.created_at)} ), }, ].filter(Boolean)} actions={actions} isLoading={isLoading} isError={!!error} errorText={__("Error loading reviews", "yatra")} errorDescription={__( "We couldn’t connect to the reviews service. Please refresh or try again shortly.", "We couldn’t connect to the reviews service. Please refresh or try again shortly.", )} onRetry={() => queryClient.invalidateQueries({ queryKey: ["reviews"] }) } errorDetails={errorContext.details} errorRequestInfo={errorContext.requestInfo} emptyText={__("No reviews found", "yatra")} emptyDescription={__( "When customers submit reviews, they will appear here.", "yatra", )} onSort={handleSort} getSortIcon={getSortIcon} selectedItemIds={selectedIds} onSelectItem={(id: string | number, checked: boolean) => { if (checked) { setSelectedIds((prev) => prev.includes(id) ? prev : [...prev, id], ); } else { setSelectedIds((prev) => prev.filter((existingId) => existingId !== id), ); } }} onSelectAll={(checked: boolean) => { if (checked) { setSelectedIds(reviews.map((r) => r.id)); } else { setSelectedIds([]); } }} isAllSelected={ reviews.length > 0 && selectedIds.length === reviews.length } getItemId={(review: Review) => review.id} capability="yatra_view_reviews" skeletonRows={5} />
{/* Pagination - shared component, no extra white background */} {total > 0 && ( )}
); }; export default Reviews;