/** * Discounts Page * Manage discount coupons for trips */ import React, { useState, useMemo } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Plus, ArrowUpDown, ArrowUp, ArrowDown, Edit, Trash2, Copy, ClipboardCopy, Tag, Users, Lock, } from "lucide-react"; import { PremiumUpgradeDialog } from "../components/modules/PremiumUpgradeDialog"; import { Pagination, SearchFilterToolbar, BulkActionToolbar, Table as SharedTable, } from "../components/shared"; import { __ } from "../lib/i18n"; import { toDateValue } from "../lib/dateFormat"; import { usePermissions } from "../hooks/usePermissions"; import { useToast } from "../components/ui/toast"; import { apiClient } from "../lib/api-client"; import { getErrorContext } from "../lib/errors"; import { Button } from "../components/ui/button"; import { Select } from "../components/ui/select"; import { PageHeader } from "../components/common/PageHeader"; import { Card, CardContent } from "../components/ui/card"; import { ConditionalRender } from "../components/ui/conditional-render"; import { Badge } from "../components/ui/badge"; import { ConfirmationDialog } from "../components/ui/confirmation-dialog"; import { Modal } from "../components/ui/modal"; import { formatYatraMoney } from "../lib/currency-display"; interface GroupDiscountRange { id: string; min_group_size: string; max_group_size: string; discount_type: "percentage" | "fixed"; discount_amount: string; } interface CategoryDiscount { traveler_category_id: string; traveler_category_label: string; ranges: GroupDiscountRange[]; } interface Discount { id: number; code: string; description?: string; type: "percentage" | "fixed"; amount: number; max_discount_amount?: number; usage_limit: number; usage_limit_per_customer?: number; usage_count: number; valid_from?: string; expiry_date?: string; status: "publish" | "draft" | "trash" | "expired"; applicable_to: "all" | "specific_trips"; trip_ids?: number[]; min_amount?: number; first_time_customer_only?: boolean; is_group_discount?: boolean; discount_mode?: "promo" | "group" | "both"; // Type of discount min_group_size?: number; group_discount_type?: "percentage" | "fixed"; group_discount_amount?: number; group_discount_mode?: "total" | "category_based"; group_discount_ranges?: GroupDiscountRange[]; category_discounts?: CategoryDiscount[]; created_at: string; updated_at: string; created_by: number; // user_id updated_by: number; // user_id created_by_name?: string; // Optional: user name from API updated_by_name?: string; // Optional: user name from API } const Discounts: React.FC = () => { const [searchTerm, setSearchTerm] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); const [typeFilter, setTypeFilter] = 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; discount: Discount | null; }>({ isOpen: false, discount: null }); const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]); const [bulkAction, setBulkAction] = useState(""); const [showColumnsDropdown, setShowColumnsDropdown] = useState(false); const [isBulkPending, setIsBulkPending] = useState(false); const [showDiscountTypeModal, setShowDiscountTypeModal] = useState(false); const [premiumDialog, setPremiumDialog] = useState<{ open: boolean; type: "group" | "both" | null; }>({ open: false, type: null }); const [visibleColumns, setVisibleColumns] = useState(() => { if (typeof window === "undefined") { return { code: true, type: true, discount: true, usage: true, expiry_date: true, status: true, }; } const saved = window.localStorage.getItem( "yatra-discounts-visible-columns", ); return saved ? JSON.parse(saved) : { code: true, type: true, discount: true, usage: true, expiry_date: true, status: true, }; }); const queryClient = useQueryClient(); const { can } = usePermissions(); const { showToast } = useToast(); const invalidateDiscountQueries = () => { queryClient.invalidateQueries({ queryKey: ["discounts"] }); queryClient.invalidateQueries({ queryKey: ["discounts-stats"] }); }; const baseAdminUrl = (window as any).yatraAdmin?.adminUrl || ""; const adminCurrency = (window as any)?.yatraAdmin?.currency || "USD"; // 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 (typeFilter !== "all") { params.type = typeFilter; } return params; }, [searchTerm, statusFilter, typeFilter, sortBy, sortOrder, page]); // Fetch discounts from API const { data, isLoading, error, refetch } = useQuery({ queryKey: ["discounts", queryParams], queryFn: async () => { // Check URL parameter for error simulation (for testing) const urlParams = new URLSearchParams(window.location.search); if (urlParams.get("simulate_error") === "true") { throw new Error( "Simulated API error for testing error UI functionality", ); } const response = await apiClient.get("/discounts", { params: queryParams, }); return response; }, enabled: can("yatra_manage_discounts"), }); const { data: discountStats } = useQuery({ queryKey: ["discounts-stats"], queryFn: async () => { try { return await apiClient.get("/discounts/stats"); } catch { return { all: 0, publish: 0, draft: 0, trash: 0, expired: 0, }; } }, enabled: can("yatra_manage_discounts"), }); const discountStatusCounts = useMemo(() => { const d = discountStats as Record | null | undefined; if (!d) { return { all: 0, publish: 0, draft: 0, trash: 0, expired: 0 }; } return { all: Number(d.all ?? 0), publish: Number(d.publish ?? 0), draft: Number(d.draft ?? 0), trash: Number(d.trash ?? 0), expired: Number(d.expired ?? 0), }; }, [discountStats]); const bulkToolbarStatusOptions = useMemo( () => [ { key: "all", label: __("All", "yatra"), count: discountStatusCounts.all, }, { key: "publish", label: __("Publish", "yatra"), count: discountStatusCounts.publish, }, { key: "draft", label: __("Draft", "yatra"), count: discountStatusCounts.draft, }, { key: "trash", label: __("Trash", "yatra"), count: discountStatusCounts.trash, }, { key: "expired", label: __("Expired", "yatra"), count: discountStatusCounts.expired, }, ], [discountStatusCounts], ); // Delete mutation const deleteMutation = useMutation({ mutationFn: async (id: number) => { return await apiClient.delete(`/discounts/${id}`); }, onSuccess: () => { invalidateDiscountQueries(); showToast(__("Discount deleted successfully", "yatra"), "success"); setDeleteConfirm({ isOpen: false, discount: null }); }, onError: (error: any) => { showToast( error?.message || __("Failed to delete discount", "yatra"), "error", ); }, }); const discounts = data?.data || []; const total = data?.total || 0; const itemsPerPage = 10; const totalPages = Math.ceil(total / itemsPerPage); // Enhanced error handling const errorContext = getErrorContext(error); const apiErrorMessage = (data as any)?.error || (data as any)?.message; const derivedErrorDetails = errorContext.details || (apiErrorMessage ? String(apiErrorMessage) : undefined) || (error ? String(error?.message || error) : undefined); const isDiscountsError = !!error || !!apiErrorMessage; const formatDate = (dateString: string) => { if (!dateString) return __("N/A", "yatra"); try { // toDateValue: a date-only expiry_date must not roll back a day in behind-UTC zones. const date = toDateValue(dateString); return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", hour: "2-digit", minute: "2-digit", }); } catch (e) { return __("Invalid date", "yatra"); } }; const confirmDelete = () => { if (deleteConfirm.discount) { deleteMutation.mutate(deleteConfirm.discount.id); } }; const getStatusBadge = (status: string) => { const statusMap: Record = { publish: { className: "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400", label: __("Publish", "yatra"), }, active: { className: "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400", label: __("Publish", "yatra"), }, draft: { className: "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400", label: __("Draft", "yatra"), }, trash: { className: "bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400", label: __("Trash", "yatra"), }, expired: { className: "bg-orange-100 text-orange-700 dark:bg-orange-900/20 dark:text-orange-400", label: __("Expired", "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 = (discount: Discount) => { window.location.href = `${baseAdminUrl}?page=yatra&subpage=discounts&action=edit&id=${discount.id}`; }; const handleDelete = (discount: Discount) => { setDeleteConfirm({ isOpen: true, discount }); }; const handleDuplicate = (discount: Discount) => { window.location.href = `${baseAdminUrl}?page=yatra&subpage=discounts&action=create&duplicate=${discount.id}`; }; const handleCreateDiscount = () => { setShowDiscountTypeModal(true); }; // Check if Advanced Discount module is enabled (Pro plugin sets this flag via yatraAdmin) const isAdvancedDiscountEnabled = !!(window as any).yatraAdmin ?.advancedDiscountEnabled; const handleSelectDiscountType = (type: "promo" | "group" | "both") => { // Block group and both options if Advanced Discount module is not enabled - show premium dialog if ((type === "group" || type === "both") && !isAdvancedDiscountEnabled) { setPremiumDialog({ open: true, type }); return; } setShowDiscountTypeModal(false); window.location.href = `${baseAdminUrl}?page=yatra&subpage=discounts&action=create&discount_mode=${type}`; }; // Get premium dialog content based on type const getPremiumDialogContent = () => { if (premiumDialog.type === "group") { return { name: __("Group Discount", "yatra"), description: __( "Create powerful group discounts that automatically apply when customers book for multiple travelers. Set tiered pricing based on group size, offer category-specific discounts, and boost your group bookings without requiring promo codes.", "yatra", ), purchaseUrl: "https://wpyatra.com/pricing?module=advanced-discount", }; } return { name: __("Promo + Group Discount", "yatra"), description: __( "Combine the power of promo codes with automatic group discounts. Customers get a base discount with their promo code, plus additional savings when booking for groups. Perfect for maximizing conversions and encouraging larger bookings.", "yatra", ), purchaseUrl: "https://wpyatra.com/pricing?module=advanced-discount", }; }; const handleResetFilters = () => { setSearchTerm(""); setStatusFilter("all"); setTypeFilter("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" || typeFilter !== "all" || sortBy !== "created_at" || sortOrder !== "desc"; const toggleColumn = (columnKey: string) => { const newVisibleColumns = { ...visibleColumns, [columnKey]: !visibleColumns[columnKey as keyof typeof visibleColumns], }; setVisibleColumns(newVisibleColumns); if (typeof window !== "undefined") { window.localStorage.setItem( "yatra-discounts-visible-columns", JSON.stringify(newVisibleColumns), ); } }; const handleSelectItem = (id: string | number, checked: boolean) => { setSelectedIds((prev) => checked ? [...prev, id] : prev.filter((selectedId) => selectedId !== id), ); }; const handleSelectAll = (checked: boolean) => { if (checked) { setSelectedIds(discounts.map((discount: Discount) => discount.id)); } else { setSelectedIds([]); } }; const isAllSelected = discounts.length > 0 && selectedIds.length === discounts.length; const updateStatusForIds = async ( ids: (string | number)[], newStatus: Discount["status"], ) => { await Promise.all( ids.map((id) => { const discount = discounts.find((item: Discount) => item.id === id); if (!discount) { return Promise.resolve(); } const payload: Partial = { ...discount, status: newStatus, }; return apiClient.put(`/discounts/${id}`, payload); }), ); }; const handleBulkApply = async () => { if (!bulkAction || selectedIds.length === 0) { return; } try { setIsBulkPending(true); if (bulkAction === "delete") { await Promise.all( selectedIds.map((id) => apiClient.delete(`/discounts/${id}`)), ); showToast( __("Selected discounts deleted successfully", "yatra"), "success", ); } else { const newStatus = bulkAction as Discount["status"]; await updateStatusForIds(selectedIds, newStatus); showToast(__("Bulk status updated successfully", "yatra"), "success"); } invalidateDiscountQueries(); setSelectedIds([]); setBulkAction(""); } catch (error: any) { showToast( error?.message || __("Failed to perform bulk action on discounts", "yatra"), "error", ); } finally { setIsBulkPending(false); } }; const allBulkActionOptions = [ { value: "publish", label: __("Mark as Publish", "yatra") }, { value: "draft", label: __("Mark as Draft", "yatra") }, { value: "trash", label: __("Move to Trash", "yatra") }, { value: "expired", label: __("Mark as Expired", "yatra") }, { value: "delete", label: __("Delete permanently", "yatra") }, ]; const getBulkActionOptionsForStatus = (view: string) => { switch (view) { case "publish": return allBulkActionOptions.filter((opt) => ["draft", "trash", "expired", "delete"].includes(opt.value), ); case "draft": return allBulkActionOptions.filter((opt) => ["publish", "trash", "expired", "delete"].includes(opt.value), ); case "trash": return allBulkActionOptions.filter((opt) => ["publish", "draft", "delete"].includes(opt.value), ); case "expired": return allBulkActionOptions.filter((opt) => ["publish", "draft", "trash", "delete"].includes(opt.value), ); default: // "all" view: allow all actions return allBulkActionOptions; } }; const bulkActionOptions = getBulkActionOptionsForStatus(statusFilter); const copyPromoCodeToClipboard = async (code: string) => { const text = code.trim(); if (!text) { return; } try { await navigator.clipboard.writeText(text); showToast(__("Coupon code copied to clipboard", "yatra"), "success"); } catch { try { const ta = document.createElement("textarea"); ta.value = text; ta.setAttribute("readonly", ""); ta.style.position = "fixed"; ta.style.left = "-9999px"; document.body.appendChild(ta); ta.select(); document.execCommand("copy"); document.body.removeChild(ta); showToast(__("Coupon code copied to clipboard", "yatra"), "success"); } catch { showToast(__("Could not copy to clipboard", "yatra"), "error"); } } }; const statusOptions = [ { value: "all", label: __("All Status", "yatra") }, { value: "publish", label: __("Publish", "yatra") }, { value: "draft", label: __("Draft", "yatra") }, { value: "trash", label: __("Trash", "yatra") }, { value: "expired", label: __("Expired", "yatra") }, ]; const sortOptions = [ { value: "created_at", label: __("Created Date", "yatra") }, { value: "code", label: __("Code", "yatra") }, { value: "type", label: __("Type", "yatra") }, { value: "amount", label: __("Amount", "yatra") }, { value: "usage_count", label: __("Usage", "yatra") }, { value: "expiry_date", label: __("Expiry Date", "yatra") }, { value: "status", label: __("Status", "yatra") }, ]; const columnOptions = [ { key: "code", label: __("Coupon Code", "yatra"), visible: visibleColumns.code, }, { key: "type", label: __("Type", "yatra"), visible: visibleColumns.type, }, { key: "discount", label: __("Discount", "yatra"), visible: visibleColumns.discount, }, { key: "usage", label: __("Usage", "yatra"), visible: visibleColumns.usage, }, { key: "expiry_date", label: __("Expiry Date", "yatra"), visible: visibleColumns.expiry_date, }, { key: "status", label: __("Status", "yatra"), visible: visibleColumns.status, }, ]; const columns = [ { key: "code", label: __("Name / Code", "yatra"), sortable: true, visible: visibleColumns.code, width: "w-[280px]", render: (discount: Discount) => (
{/* Discount Mode Badge */} {discount.discount_mode === "group" ? ( {__("Group Only", "yatra")} ) : discount.discount_mode === "promo" ? ( {__("Promo Code", "yatra")} ) : discount.is_group_discount ? ( {__("Promo + Group", "yatra")} ) : ( {__("Promo Code", "yatra")} )}
{discount.code} {discount.discount_mode !== "group" && discount.code?.trim() ? ( ) : null}
{discount.description && (
{discount.description}
)}
), }, { key: "type", label: __("Discount", "yatra"), sortable: true, visible: visibleColumns.type, render: (discount: Discount) => (
{/* Base discount - only show if not group-only */} {discount.discount_mode !== "group" && (
{discount.type === "percentage" ? `${discount.amount}%` : formatYatraMoney( Number(discount.amount) || 0, adminCurrency, { zeroAsUnknown: false, }, )} {__("off", "yatra")}
)} {/* Group discount info */} {discount.is_group_discount && (
{/* Category-based discounts */} {discount.group_discount_mode === "category_based" && discount.category_discounts && discount.category_discounts.length > 0 ? (
{discount.category_discounts.map((cat, idx) => (
{cat.traveler_category_label}: {cat.ranges && cat.ranges.length > 0 ? cat.ranges.map((range, rIdx) => ( {rIdx > 0 && ", "} {range.min_group_size} {range.max_group_size ? `-${range.max_group_size}` : "+"} : {range.discount_type === "percentage" ? `${range.discount_amount}%` : formatYatraMoney( Number(range.discount_amount) || 0, adminCurrency, { zeroAsUnknown: false }, )} )) : "-"}
))}
) : discount.group_discount_ranges && discount.group_discount_ranges.length > 0 ? ( /* Total-based group discount ranges */
{discount.group_discount_ranges.map((range, idx) => (
{range.min_group_size} {range.max_group_size ? `-${range.max_group_size}` : "+"}{" "} {__("pax", "yatra")}: {range.discount_type === "percentage" ? ` ${range.discount_amount}%` : ` ${formatYatraMoney( Number(range.discount_amount) || 0, adminCurrency, { zeroAsUnknown: false }, )}`}{" "} {__("off", "yatra")}
))}
) : ( /* Fallback for legacy data */
{discount.min_group_size ? `${discount.min_group_size}+ ${__("travelers", "yatra")}: ${discount.group_discount_type === "percentage" ? `${discount.group_discount_amount}%` : formatYatraMoney(Number(discount.group_discount_amount) || 0, adminCurrency, { zeroAsUnknown: false })}` : __("Group pricing", "yatra")}
)}
)}
), }, { key: "usage", label: __("Usage", "yatra"), sortable: true, visible: visibleColumns.usage, render: (discount: Discount) => (
{discount.usage_count} /{" "} {discount.usage_limit === 0 ? "∞" : discount.usage_limit}
), }, { key: "expiry_date", label: __("Expiry Date", "yatra"), sortable: true, visible: visibleColumns.expiry_date, render: (discount: Discount) => ( {discount.expiry_date ? formatDate(discount.expiry_date) : __("No expiry", "yatra")} ), }, { key: "status", label: __("Status", "yatra"), sortable: true, visible: visibleColumns.status, render: (discount: Discount) => getStatusBadge(discount.status), }, ]; const actions = [ { key: "duplicate", label: __("Duplicate", "yatra"), icon: , onClick: (discount: Discount) => handleDuplicate(discount), }, { key: "edit", label: __("Edit", "yatra"), icon: , onClick: (discount: Discount) => handleEdit(discount), }, { key: "mark_publish", label: __("Mark as Publish", "yatra"), icon: , onClick: async (discount: Discount) => { setIsBulkPending(true); try { await updateStatusForIds([discount.id], "publish"); invalidateDiscountQueries(); showToast(__("Discount status updated", "yatra"), "success"); } catch (error: any) { showToast( error?.message || __("Failed to update discount status", "yatra"), "error", ); } finally { setIsBulkPending(false); } }, condition: (discount: Discount) => discount.status !== "publish", }, { key: "mark_draft", label: __("Mark as Draft", "yatra"), icon: , onClick: async (discount: Discount) => { setIsBulkPending(true); try { await updateStatusForIds([discount.id], "draft"); invalidateDiscountQueries(); showToast(__("Discount status updated", "yatra"), "success"); } catch (error: any) { showToast( error?.message || __("Failed to update discount status", "yatra"), "error", ); } finally { setIsBulkPending(false); } }, condition: (discount: Discount) => discount.status !== "draft", }, { key: "move_trash", label: __("Move to Trash", "yatra"), icon: , onClick: async (discount: Discount) => { setIsBulkPending(true); try { await updateStatusForIds([discount.id], "trash"); invalidateDiscountQueries(); showToast(__("Discount moved to trash", "yatra"), "success"); } catch (error: any) { showToast( error?.message || __("Failed to move discount to trash", "yatra"), "error", ); } finally { setIsBulkPending(false); } }, condition: (discount: Discount) => discount.status !== "trash", }, { key: "mark_expired", label: __("Mark as Expired", "yatra"), icon: , onClick: async (discount: Discount) => { setIsBulkPending(true); try { await updateStatusForIds([discount.id], "expired"); invalidateDiscountQueries(); showToast(__("Discount status updated", "yatra"), "success"); } catch (error: any) { showToast( error?.message || __("Failed to update discount status", "yatra"), "error", ); } finally { setIsBulkPending(false); } }, condition: (discount: Discount) => discount.status !== "expired", }, { key: "delete", label: __("Delete", "yatra"), icon: , onClick: (discount: Discount) => handleDelete(discount), variant: "destructive" as const, }, ]; return (
{/* Discount Type Selection Modal */} setShowDiscountTypeModal(false)} title={__("What type of discount do you want to create?", "yatra")} description={__( "Choose the discount type that best fits your needs", "yatra", )} size="lg" footer={
} >
{/* Promo Code Option */} {/* Group Discount Option */} {/* Both Option */}
setDeleteConfirm({ isOpen: false, discount: null })} onConfirm={confirmDelete} title={__("Delete Discount", "yatra")} message={ deleteConfirm.discount ? __( 'Are you sure you want to delete discount code "{code}"? This action cannot be undone.', "yatra", ).replace("{code}", deleteConfirm.discount.code) : __( "Are you sure you want to delete this discount? This action cannot be undone.", "yatra", ) } confirmText={__("Delete", "yatra")} cancelText={__("Cancel", "yatra")} variant="danger" isLoading={deleteMutation.isPending} /> {/* Premium Upgrade Dialog for Group Discount features */} setPremiumDialog({ open: false, type: null })} moduleName={getPremiumDialogContent().name} moduleDescription={getPremiumDialogContent().description} purchaseUrl={getPremiumDialogContent().purchaseUrl} /> {__("Create Discount", "yatra")} } />
{ setStatusFilter(value); setPage(1); }} statusOptions={statusOptions} sortBy={sortBy} onSortByChange={(field) => { setSortBy(field); setPage(1); }} sortOrder={sortOrder} onSortOrderChange={(order) => { setSortOrder(order); setPage(1); }} sortOptions={sortOptions} onResetFilters={handleResetFilters} hasFilters={!!hasFilters} placeholder={__("Search by code...", "yatra")} />
<> setSelectedIds([])} statusFilter={statusFilter} setStatusFilter={(value) => { setStatusFilter(value); setPage(1); }} statusOptions={bulkToolbarStatusOptions} showColumnsDropdown={showColumnsDropdown} setShowColumnsDropdown={setShowColumnsDropdown} columnOptions={columnOptions} onToggleColumn={toggleColumn} bulkMutationPending={isBulkPending} totalItems={total} bulkActionOptions={bulkActionOptions} /> refetch()} emptyText={__("No discounts found", "yatra")} emptyDescription={ hasFilters ? __( "Try adjusting your filters to see more results.", "yatra", ) : __( "Get started by creating your first discount coupon.", "yatra", ) } onCreateClick={ can("yatra_manage_discounts") ? handleCreateDiscount : undefined } onSort={handleSort} getSortIcon={getSortIcon} selectedItemIds={selectedIds} onSelectItem={handleSelectItem} onSelectAll={handleSelectAll} isAllSelected={isAllSelected} getItemId={(discount: Discount) => discount.id} skeletonRows={5} capability="yatra_manage_discounts" /> {total > 0 && (
setPage(newPage)} itemName={__("coupons", "yatra")} />
)}
); }; export default Discounts;