PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.3
Yatra – Travel Booking & Tour Operator Software v3.0.3
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
yatra / resources / js / pages / Discounts.tsx

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

1,251 lines 42.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Discounts Page
3 * Manage discount coupons for trips
4 */
5
6 import React, { useState, useMemo } from "react";
7 import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
8 import {
9 Plus,
10 ArrowUpDown,
11 ArrowUp,
12 ArrowDown,
13 Edit,
14 Trash2,
15 Copy,
16 ClipboardCopy,
17 Tag,
18 Users,
19 Lock,
20 } from "lucide-react";
21 import { PremiumUpgradeDialog } from "../components/modules/PremiumUpgradeDialog";
22 import {
23 Pagination,
24 SearchFilterToolbar,
25 BulkActionToolbar,
26 Table as SharedTable,
27 } from "../components/shared";
28 import { __ } from "../lib/i18n";
29 import { usePermissions } from "../hooks/usePermissions";
30 import { useToast } from "../components/ui/toast";
31 import { apiClient } from "../lib/api-client";
32 import { getErrorContext } from "../lib/errors";
33 import { Button } from "../components/ui/button";
34 import { Select } from "../components/ui/select";
35 import { PageHeader } from "../components/common/PageHeader";
36 import { Card, CardContent } from "../components/ui/card";
37 import { ConditionalRender } from "../components/ui/conditional-render";
38 import { Badge } from "../components/ui/badge";
39 import { ConfirmationDialog } from "../components/ui/confirmation-dialog";
40 import { Modal } from "../components/ui/modal";
41 import { formatYatraMoney } from "../lib/currency-display";
42
43 interface GroupDiscountRange {
44 id: string;
45 min_group_size: string;
46 max_group_size: string;
47 discount_type: "percentage" | "fixed";
48 discount_amount: string;
49 }
50
51 interface CategoryDiscount {
52 traveler_category_id: string;
53 traveler_category_label: string;
54 ranges: GroupDiscountRange[];
55 }
56
57 interface Discount {
58 id: number;
59 code: string;
60 description?: string;
61 type: "percentage" | "fixed";
62 amount: number;
63 max_discount_amount?: number;
64 usage_limit: number;
65 usage_limit_per_customer?: number;
66 usage_count: number;
67 valid_from?: string;
68 expiry_date?: string;
69 status: "publish" | "draft" | "trash" | "expired";
70 applicable_to: "all" | "specific_trips";
71 trip_ids?: number[];
72 min_amount?: number;
73 first_time_customer_only?: boolean;
74 is_group_discount?: boolean;
75 discount_mode?: "promo" | "group" | "both"; // Type of discount
76 min_group_size?: number;
77 group_discount_type?: "percentage" | "fixed";
78 group_discount_amount?: number;
79 group_discount_mode?: "total" | "category_based";
80 group_discount_ranges?: GroupDiscountRange[];
81 category_discounts?: CategoryDiscount[];
82 created_at: string;
83 updated_at: string;
84 created_by: number; // user_id
85 updated_by: number; // user_id
86 created_by_name?: string; // Optional: user name from API
87 updated_by_name?: string; // Optional: user name from API
88 }
89
90 const Discounts: React.FC = () => {
91 const [searchTerm, setSearchTerm] = useState("");
92 const [statusFilter, setStatusFilter] = useState("all");
93 const [typeFilter, setTypeFilter] = useState("all");
94 const [sortBy, setSortBy] = useState("created_at");
95 const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
96 const [page, setPage] = useState(1);
97 const [deleteConfirm, setDeleteConfirm] = useState<{
98 isOpen: boolean;
99 discount: Discount | null;
100 }>({ isOpen: false, discount: null });
101 const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]);
102 const [bulkAction, setBulkAction] = useState("");
103 const [showColumnsDropdown, setShowColumnsDropdown] = useState(false);
104 const [isBulkPending, setIsBulkPending] = useState(false);
105 const [showDiscountTypeModal, setShowDiscountTypeModal] = useState(false);
106 const [premiumDialog, setPremiumDialog] = useState<{
107 open: boolean;
108 type: "group" | "both" | null;
109 }>({ open: false, type: null });
110 const [visibleColumns, setVisibleColumns] = useState(() => {
111 if (typeof window === "undefined") {
112 return {
113 code: true,
114 type: true,
115 discount: true,
116 usage: true,
117 expiry_date: true,
118 status: true,
119 };
120 }
121 const saved = window.localStorage.getItem(
122 "yatra-discounts-visible-columns",
123 );
124 return saved
125 ? JSON.parse(saved)
126 : {
127 code: true,
128 type: true,
129 discount: true,
130 usage: true,
131 expiry_date: true,
132 status: true,
133 };
134 });
135 const queryClient = useQueryClient();
136 const { can } = usePermissions();
137 const { showToast } = useToast();
138
139 const invalidateDiscountQueries = () => {
140 queryClient.invalidateQueries({ queryKey: ["discounts"] });
141 queryClient.invalidateQueries({ queryKey: ["discounts-stats"] });
142 };
143
144 const baseAdminUrl = (window as any).yatraAdmin?.adminUrl || "";
145 const adminCurrency = (window as any)?.yatraAdmin?.currency || "USD";
146
147 // Build query params
148 const queryParams = useMemo(() => {
149 const params: Record<string, any> = {
150 page,
151 per_page: 10,
152 orderby: sortBy,
153 order: sortOrder,
154 };
155
156 if (searchTerm) {
157 params.search = searchTerm;
158 }
159
160 if (statusFilter !== "all") {
161 params.status = statusFilter;
162 }
163
164 if (typeFilter !== "all") {
165 params.type = typeFilter;
166 }
167
168 return params;
169 }, [searchTerm, statusFilter, typeFilter, sortBy, sortOrder, page]);
170
171 // Fetch discounts from API
172 const { data, isLoading, error, refetch } = useQuery({
173 queryKey: ["discounts", queryParams],
174 queryFn: async () => {
175 // Check URL parameter for error simulation (for testing)
176 const urlParams = new URLSearchParams(window.location.search);
177 if (urlParams.get("simulate_error") === "true") {
178 throw new Error(
179 "Simulated API error for testing error UI functionality",
180 );
181 }
182
183 const response = await apiClient.get("/discounts", {
184 params: queryParams,
185 });
186 return response;
187 },
188 enabled: can("yatra_view_bookings"),
189 });
190
191 const { data: discountStats } = useQuery({
192 queryKey: ["discounts-stats"],
193 queryFn: async () => {
194 try {
195 return await apiClient.get("/discounts/stats");
196 } catch {
197 return {
198 all: 0,
199 publish: 0,
200 draft: 0,
201 trash: 0,
202 expired: 0,
203 };
204 }
205 },
206 enabled: can("yatra_view_bookings"),
207 });
208
209 const discountStatusCounts = useMemo(() => {
210 const d = discountStats as Record<string, unknown> | null | undefined;
211 if (!d) {
212 return { all: 0, publish: 0, draft: 0, trash: 0, expired: 0 };
213 }
214 return {
215 all: Number(d.all ?? 0),
216 publish: Number(d.publish ?? 0),
217 draft: Number(d.draft ?? 0),
218 trash: Number(d.trash ?? 0),
219 expired: Number(d.expired ?? 0),
220 };
221 }, [discountStats]);
222
223 const bulkToolbarStatusOptions = useMemo(
224 () => [
225 {
226 key: "all",
227 label: __("All", "yatra"),
228 count: discountStatusCounts.all,
229 },
230 {
231 key: "publish",
232 label: __("Publish", "yatra"),
233 count: discountStatusCounts.publish,
234 },
235 {
236 key: "draft",
237 label: __("Draft", "yatra"),
238 count: discountStatusCounts.draft,
239 },
240 {
241 key: "trash",
242 label: __("Trash", "yatra"),
243 count: discountStatusCounts.trash,
244 },
245 {
246 key: "expired",
247 label: __("Expired", "yatra"),
248 count: discountStatusCounts.expired,
249 },
250 ],
251 [discountStatusCounts],
252 );
253
254 // Delete mutation
255 const deleteMutation = useMutation({
256 mutationFn: async (id: number) => {
257 return await apiClient.delete(`/discounts/${id}`);
258 },
259 onSuccess: () => {
260 invalidateDiscountQueries();
261 showToast(__("Discount deleted successfully", "yatra"), "success");
262 setDeleteConfirm({ isOpen: false, discount: null });
263 },
264 onError: (error: any) => {
265 showToast(
266 error?.message || __("Failed to delete discount", "yatra"),
267 "error",
268 );
269 },
270 });
271
272 const discounts = data?.data || [];
273 const total = data?.total || 0;
274 const itemsPerPage = 10;
275 const totalPages = Math.ceil(total / itemsPerPage);
276
277 // Enhanced error handling
278 const errorContext = getErrorContext(error);
279 const apiErrorMessage = (data as any)?.error || (data as any)?.message;
280 const derivedErrorDetails =
281 errorContext.details ||
282 (apiErrorMessage ? String(apiErrorMessage) : undefined) ||
283 (error ? String(error?.message || error) : undefined);
284 const isDiscountsError = !!error || !!apiErrorMessage;
285
286 const formatDate = (dateString: string) => {
287 if (!dateString) return __("N/A", "yatra");
288 try {
289 const date = new Date(dateString);
290 return date.toLocaleDateString("en-US", {
291 month: "short",
292 day: "numeric",
293 year: "numeric",
294 hour: "2-digit",
295 minute: "2-digit",
296 });
297 } catch (e) {
298 return __("Invalid date", "yatra");
299 }
300 };
301
302 const confirmDelete = () => {
303 if (deleteConfirm.discount) {
304 deleteMutation.mutate(deleteConfirm.discount.id);
305 }
306 };
307
308 const getStatusBadge = (status: string) => {
309 const statusMap: Record<string, { className: string; label: string }> = {
310 publish: {
311 className:
312 "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400",
313 label: __("Publish", "yatra"),
314 },
315 active: {
316 className:
317 "bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400",
318 label: __("Publish", "yatra"),
319 },
320 draft: {
321 className:
322 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
323 label: __("Draft", "yatra"),
324 },
325 trash: {
326 className:
327 "bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400",
328 label: __("Trash", "yatra"),
329 },
330 expired: {
331 className:
332 "bg-orange-100 text-orange-700 dark:bg-orange-900/20 dark:text-orange-400",
333 label: __("Expired", "yatra"),
334 },
335 };
336
337 const statusInfo = statusMap[status] || {
338 className:
339 "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-400",
340 label: status,
341 };
342
343 return (
344 <span
345 className={`inline-flex items-center px-2 py-1 rounded-md text-xs font-medium ${statusInfo.className}`}
346 >
347 {statusInfo.label}
348 </span>
349 );
350 };
351
352 const handleEdit = (discount: Discount) => {
353 window.location.href = `${baseAdminUrl}?page=yatra&subpage=discounts&action=edit&id=${discount.id}`;
354 };
355
356 const handleDelete = (discount: Discount) => {
357 setDeleteConfirm({ isOpen: true, discount });
358 };
359
360 const handleDuplicate = (discount: Discount) => {
361 window.location.href = `${baseAdminUrl}?page=yatra&subpage=discounts&action=create&duplicate=${discount.id}`;
362 };
363
364 const handleCreateDiscount = () => {
365 setShowDiscountTypeModal(true);
366 };
367
368 // Check if Advanced Discount module is enabled (Pro plugin sets this flag via yatraAdmin)
369 const isAdvancedDiscountEnabled = !!(window as any).yatraAdmin
370 ?.advancedDiscountEnabled;
371
372 const handleSelectDiscountType = (type: "promo" | "group" | "both") => {
373 // Block group and both options if Advanced Discount module is not enabled - show premium dialog
374 if ((type === "group" || type === "both") && !isAdvancedDiscountEnabled) {
375 setPremiumDialog({ open: true, type });
376 return;
377 }
378 setShowDiscountTypeModal(false);
379 window.location.href = `${baseAdminUrl}?page=yatra&subpage=discounts&action=create&discount_mode=${type}`;
380 };
381
382 // Get premium dialog content based on type
383 const getPremiumDialogContent = () => {
384 if (premiumDialog.type === "group") {
385 return {
386 name: __("Group Discount", "yatra"),
387 description: __(
388 "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.",
389 "yatra",
390 ),
391 purchaseUrl: "https://wpyatra.com/pricing?module=advanced-discount",
392 };
393 }
394 return {
395 name: __("Promo + Group Discount", "yatra"),
396 description: __(
397 "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.",
398 "yatra",
399 ),
400 purchaseUrl: "https://wpyatra.com/pricing?module=advanced-discount",
401 };
402 };
403
404 const handleResetFilters = () => {
405 setSearchTerm("");
406 setStatusFilter("all");
407 setTypeFilter("all");
408 setSortBy("created_at");
409 setSortOrder("desc");
410 setPage(1);
411 };
412
413 const handleSort = (field: string) => {
414 if (sortBy === field) {
415 setSortOrder(sortOrder === "asc" ? "desc" : "asc");
416 } else {
417 setSortBy(field);
418 setSortOrder("desc");
419 }
420 };
421
422 const getSortIcon = (field: string) => {
423 if (sortBy !== field) {
424 return <ArrowUpDown className="w-3.5 h-3.5 ml-1 text-gray-400" />;
425 }
426 return sortOrder === "asc" ? (
427 <ArrowUp className="w-3.5 h-3.5 ml-1 text-gray-600 dark:text-gray-300" />
428 ) : (
429 <ArrowDown className="w-3.5 h-3.5 ml-1 text-gray-600 dark:text-gray-300" />
430 );
431 };
432
433 const hasFilters =
434 searchTerm ||
435 statusFilter !== "all" ||
436 typeFilter !== "all" ||
437 sortBy !== "created_at" ||
438 sortOrder !== "desc";
439
440 const toggleColumn = (columnKey: string) => {
441 const newVisibleColumns = {
442 ...visibleColumns,
443 [columnKey]: !visibleColumns[columnKey as keyof typeof visibleColumns],
444 };
445 setVisibleColumns(newVisibleColumns);
446 if (typeof window !== "undefined") {
447 window.localStorage.setItem(
448 "yatra-discounts-visible-columns",
449 JSON.stringify(newVisibleColumns),
450 );
451 }
452 };
453
454 const handleSelectItem = (id: string | number, checked: boolean) => {
455 setSelectedIds((prev) =>
456 checked ? [...prev, id] : prev.filter((selectedId) => selectedId !== id),
457 );
458 };
459
460 const handleSelectAll = (checked: boolean) => {
461 if (checked) {
462 setSelectedIds(discounts.map((discount: Discount) => discount.id));
463 } else {
464 setSelectedIds([]);
465 }
466 };
467
468 const isAllSelected =
469 discounts.length > 0 && selectedIds.length === discounts.length;
470
471 const updateStatusForIds = async (
472 ids: (string | number)[],
473 newStatus: Discount["status"],
474 ) => {
475 await Promise.all(
476 ids.map((id) => {
477 const discount = discounts.find((item: Discount) => item.id === id);
478 if (!discount) {
479 return Promise.resolve();
480 }
481
482 const payload: Partial<Discount> = {
483 ...discount,
484 status: newStatus,
485 };
486
487 return apiClient.put(`/discounts/${id}`, payload);
488 }),
489 );
490 };
491
492 const handleBulkApply = async () => {
493 if (!bulkAction || selectedIds.length === 0) {
494 return;
495 }
496
497 try {
498 setIsBulkPending(true);
499
500 if (bulkAction === "delete") {
501 await Promise.all(
502 selectedIds.map((id) => apiClient.delete(`/discounts/${id}`)),
503 );
504 showToast(
505 __("Selected discounts deleted successfully", "yatra"),
506 "success",
507 );
508 } else {
509 const newStatus = bulkAction as Discount["status"];
510 await updateStatusForIds(selectedIds, newStatus);
511 showToast(__("Bulk status updated successfully", "yatra"), "success");
512 }
513
514 invalidateDiscountQueries();
515 setSelectedIds([]);
516 setBulkAction("");
517 } catch (error: any) {
518 showToast(
519 error?.message ||
520 __("Failed to perform bulk action on discounts", "yatra"),
521 "error",
522 );
523 } finally {
524 setIsBulkPending(false);
525 }
526 };
527
528 const allBulkActionOptions = [
529 { value: "publish", label: __("Mark as Publish", "yatra") },
530 { value: "draft", label: __("Mark as Draft", "yatra") },
531 { value: "trash", label: __("Move to Trash", "yatra") },
532 { value: "expired", label: __("Mark as Expired", "yatra") },
533 { value: "delete", label: __("Delete permanently", "yatra") },
534 ];
535
536 const getBulkActionOptionsForStatus = (view: string) => {
537 switch (view) {
538 case "publish":
539 return allBulkActionOptions.filter((opt) =>
540 ["draft", "trash", "expired", "delete"].includes(opt.value),
541 );
542 case "draft":
543 return allBulkActionOptions.filter((opt) =>
544 ["publish", "trash", "expired", "delete"].includes(opt.value),
545 );
546 case "trash":
547 return allBulkActionOptions.filter((opt) =>
548 ["publish", "draft", "delete"].includes(opt.value),
549 );
550 case "expired":
551 return allBulkActionOptions.filter((opt) =>
552 ["publish", "draft", "trash", "delete"].includes(opt.value),
553 );
554 default:
555 // "all" view: allow all actions
556 return allBulkActionOptions;
557 }
558 };
559
560 const bulkActionOptions = getBulkActionOptionsForStatus(statusFilter);
561
562 const copyPromoCodeToClipboard = async (code: string) => {
563 const text = code.trim();
564 if (!text) {
565 return;
566 }
567 try {
568 await navigator.clipboard.writeText(text);
569 showToast(__("Coupon code copied to clipboard", "yatra"), "success");
570 } catch {
571 try {
572 const ta = document.createElement("textarea");
573 ta.value = text;
574 ta.setAttribute("readonly", "");
575 ta.style.position = "fixed";
576 ta.style.left = "-9999px";
577 document.body.appendChild(ta);
578 ta.select();
579 document.execCommand("copy");
580 document.body.removeChild(ta);
581 showToast(__("Coupon code copied to clipboard", "yatra"), "success");
582 } catch {
583 showToast(__("Could not copy to clipboard", "yatra"), "error");
584 }
585 }
586 };
587
588 const statusOptions = [
589 { value: "all", label: __("All Status", "yatra") },
590 { value: "publish", label: __("Publish", "yatra") },
591 { value: "draft", label: __("Draft", "yatra") },
592 { value: "trash", label: __("Trash", "yatra") },
593 { value: "expired", label: __("Expired", "yatra") },
594 ];
595
596 const sortOptions = [
597 { value: "created_at", label: __("Created Date", "yatra") },
598 { value: "code", label: __("Code", "yatra") },
599 { value: "type", label: __("Type", "yatra") },
600 { value: "amount", label: __("Amount", "yatra") },
601 { value: "usage_count", label: __("Usage", "yatra") },
602 { value: "expiry_date", label: __("Expiry Date", "yatra") },
603 { value: "status", label: __("Status", "yatra") },
604 ];
605
606 const columnOptions = [
607 {
608 key: "code",
609 label: __("Coupon Code", "yatra"),
610 visible: visibleColumns.code,
611 },
612 {
613 key: "type",
614 label: __("Type", "yatra"),
615 visible: visibleColumns.type,
616 },
617 {
618 key: "discount",
619 label: __("Discount", "yatra"),
620 visible: visibleColumns.discount,
621 },
622 {
623 key: "usage",
624 label: __("Usage", "yatra"),
625 visible: visibleColumns.usage,
626 },
627 {
628 key: "expiry_date",
629 label: __("Expiry Date", "yatra"),
630 visible: visibleColumns.expiry_date,
631 },
632 {
633 key: "status",
634 label: __("Status", "yatra"),
635 visible: visibleColumns.status,
636 },
637 ];
638
639 const columns = [
640 {
641 key: "code",
642 label: __("Name / Code", "yatra"),
643 sortable: true,
644 visible: visibleColumns.code,
645 width: "w-[280px]",
646 render: (discount: Discount) => (
647 <div>
648 <div className="flex items-center gap-2 mb-1 flex-wrap">
649 {/* Discount Mode Badge */}
650 {discount.discount_mode === "group" ? (
651 <Badge
652 variant="success"
653 className="text-xs flex items-center gap-1"
654 >
655 <Users className="w-3 h-3" />
656 {__("Group Only", "yatra")}
657 </Badge>
658 ) : discount.discount_mode === "promo" ? (
659 <Badge variant="info" className="text-xs flex items-center gap-1">
660 <Tag className="w-3 h-3" />
661 {__("Promo Code", "yatra")}
662 </Badge>
663 ) : discount.is_group_discount ? (
664 <Badge
665 variant="warning"
666 className="text-xs flex items-center gap-1"
667 >
668 <Tag className="w-3 h-3" />
669 <Users className="w-3 h-3" />
670 {__("Promo + Group", "yatra")}
671 </Badge>
672 ) : (
673 <Badge variant="info" className="text-xs flex items-center gap-1">
674 <Tag className="w-3 h-3" />
675 {__("Promo Code", "yatra")}
676 </Badge>
677 )}
678 </div>
679 <div className="flex items-center gap-1.5 mt-1 flex-wrap">
680 <a
681 href={`${baseAdminUrl}?page=yatra&subpage=discounts&action=edit&id=${discount.id}`}
682 className="font-medium font-mono text-sm text-blue-600 dark:text-blue-400 hover:underline underline-offset-2"
683 >
684 {discount.code}
685 </a>
686 {discount.discount_mode !== "group" && discount.code?.trim() ? (
687 <button
688 type="button"
689 className="inline-flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-md border border-gray-300 bg-white text-gray-800 shadow-sm transition-colors hover:border-gray-400 hover:bg-gray-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 dark:border-gray-500 dark:bg-gray-800 dark:text-gray-100 dark:hover:border-gray-400 dark:hover:bg-gray-700"
690 title={__("Copy coupon code", "yatra")}
691 aria-label={__("Copy coupon code", "yatra")}
692 onClick={(e) => {
693 e.preventDefault();
694 e.stopPropagation();
695 void copyPromoCodeToClipboard(discount.code);
696 }}
697 >
698 <ClipboardCopy
699 className="pointer-events-none h-4 w-4"
700 strokeWidth={2.25}
701 aria-hidden
702 />
703 </button>
704 ) : null}
705 </div>
706 {discount.description && (
707 <div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
708 {discount.description}
709 </div>
710 )}
711 </div>
712 ),
713 },
714 {
715 key: "type",
716 label: __("Discount", "yatra"),
717 sortable: true,
718 visible: visibleColumns.type,
719 render: (discount: Discount) => (
720 <div className="space-y-1.5">
721 {/* Base discount - only show if not group-only */}
722 {discount.discount_mode !== "group" && (
723 <div className="flex items-center gap-1">
724 <span className="font-medium text-gray-900 dark:text-white">
725 {discount.type === "percentage"
726 ? `${discount.amount}%`
727 : formatYatraMoney(Number(discount.amount) || 0, adminCurrency, {
728 zeroAsUnknown: false,
729 })}
730 </span>
731 <span className="text-xs text-gray-500">
732 {__("off", "yatra")}
733 </span>
734 </div>
735 )}
736 {/* Group discount info */}
737 {discount.is_group_discount && (
738 <div className="space-y-1">
739 {/* Category-based discounts */}
740 {discount.group_discount_mode === "category_based" &&
741 discount.category_discounts &&
742 discount.category_discounts.length > 0 ? (
743 <div className="space-y-1">
744 {discount.category_discounts.map((cat, idx) => (
745 <div key={idx} className="text-xs">
746 <span className="font-medium text-gray-700 dark:text-gray-300">
747 {cat.traveler_category_label}:
748 </span>
749 <span className="ml-1 text-green-600 dark:text-green-400">
750 {cat.ranges && cat.ranges.length > 0
751 ? cat.ranges.map((range, rIdx) => (
752 <span key={rIdx}>
753 {rIdx > 0 && ", "}
754 {range.min_group_size}
755 {range.max_group_size
756 ? `-${range.max_group_size}`
757 : "+"}
758 :
759 {range.discount_type === "percentage"
760 ? `${range.discount_amount}%`
761 : formatYatraMoney(
762 Number(range.discount_amount) || 0,
763 adminCurrency,
764 { zeroAsUnknown: false },
765 )}
766 </span>
767 ))
768 : "-"}
769 </span>
770 </div>
771 ))}
772 </div>
773 ) : discount.group_discount_ranges &&
774 discount.group_discount_ranges.length > 0 ? (
775 /* Total-based group discount ranges */
776 <div className="space-y-0.5">
777 {discount.group_discount_ranges.map((range, idx) => (
778 <div
779 key={idx}
780 className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1"
781 >
782 <Users className="w-3 h-3" />
783 <span>
784 {range.min_group_size}
785 {range.max_group_size
786 ? `-${range.max_group_size}`
787 : "+"}{" "}
788 {__("pax", "yatra")}:
789 {range.discount_type === "percentage"
790 ? ` ${range.discount_amount}%`
791 : ` ${formatYatraMoney(
792 Number(range.discount_amount) || 0,
793 adminCurrency,
794 { zeroAsUnknown: false },
795 )}`}{" "}
796 {__("off", "yatra")}
797 </span>
798 </div>
799 ))}
800 </div>
801 ) : (
802 /* Fallback for legacy data */
803 <div className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1">
804 <Users className="w-3 h-3" />
805 {discount.min_group_size
806 ? `${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 })}`
807 : __("Group pricing", "yatra")}
808 </div>
809 )}
810 </div>
811 )}
812 </div>
813 ),
814 },
815 {
816 key: "usage",
817 label: __("Usage", "yatra"),
818 sortable: true,
819 visible: visibleColumns.usage,
820 render: (discount: Discount) => (
821 <div className="text-center">
822 <Badge variant="outline" className="font-medium">
823 {discount.usage_count} /{" "}
824 {discount.usage_limit === 0 ? "∞" : discount.usage_limit}
825 </Badge>
826 </div>
827 ),
828 },
829 {
830 key: "expiry_date",
831 label: __("Expiry Date", "yatra"),
832 sortable: true,
833 visible: visibleColumns.expiry_date,
834 render: (discount: Discount) => (
835 <span className="text-gray-500 dark:text-gray-400 text-sm">
836 {discount.expiry_date
837 ? formatDate(discount.expiry_date)
838 : __("No expiry", "yatra")}
839 </span>
840 ),
841 },
842 {
843 key: "status",
844 label: __("Status", "yatra"),
845 sortable: true,
846 visible: visibleColumns.status,
847 render: (discount: Discount) => getStatusBadge(discount.status),
848 },
849 ];
850
851 const actions = [
852 {
853 key: "duplicate",
854 label: __("Duplicate", "yatra"),
855 icon: <Copy className="w-4 h-4" />,
856 onClick: (discount: Discount) => handleDuplicate(discount),
857 },
858 {
859 key: "edit",
860 label: __("Edit", "yatra"),
861 icon: <Edit className="w-4 h-4" />,
862 onClick: (discount: Discount) => handleEdit(discount),
863 },
864 {
865 key: "mark_publish",
866 label: __("Mark as Publish", "yatra"),
867 icon: <ArrowUp className="w-4 h-4" />,
868 onClick: async (discount: Discount) => {
869 setIsBulkPending(true);
870 try {
871 await updateStatusForIds([discount.id], "publish");
872 invalidateDiscountQueries();
873 showToast(__("Discount status updated", "yatra"), "success");
874 } catch (error: any) {
875 showToast(
876 error?.message || __("Failed to update discount status", "yatra"),
877 "error",
878 );
879 } finally {
880 setIsBulkPending(false);
881 }
882 },
883 condition: (discount: Discount) => discount.status !== "publish",
884 },
885 {
886 key: "mark_draft",
887 label: __("Mark as Draft", "yatra"),
888 icon: <ArrowDown className="w-4 h-4" />,
889 onClick: async (discount: Discount) => {
890 setIsBulkPending(true);
891 try {
892 await updateStatusForIds([discount.id], "draft");
893 invalidateDiscountQueries();
894 showToast(__("Discount status updated", "yatra"), "success");
895 } catch (error: any) {
896 showToast(
897 error?.message || __("Failed to update discount status", "yatra"),
898 "error",
899 );
900 } finally {
901 setIsBulkPending(false);
902 }
903 },
904 condition: (discount: Discount) => discount.status !== "draft",
905 },
906 {
907 key: "move_trash",
908 label: __("Move to Trash", "yatra"),
909 icon: <Trash2 className="w-4 h-4" />,
910 onClick: async (discount: Discount) => {
911 setIsBulkPending(true);
912 try {
913 await updateStatusForIds([discount.id], "trash");
914 invalidateDiscountQueries();
915 showToast(__("Discount moved to trash", "yatra"), "success");
916 } catch (error: any) {
917 showToast(
918 error?.message || __("Failed to move discount to trash", "yatra"),
919 "error",
920 );
921 } finally {
922 setIsBulkPending(false);
923 }
924 },
925 condition: (discount: Discount) => discount.status !== "trash",
926 },
927 {
928 key: "mark_expired",
929 label: __("Mark as Expired", "yatra"),
930 icon: <ArrowDown className="w-4 h-4" />,
931 onClick: async (discount: Discount) => {
932 setIsBulkPending(true);
933 try {
934 await updateStatusForIds([discount.id], "expired");
935 invalidateDiscountQueries();
936 showToast(__("Discount status updated", "yatra"), "success");
937 } catch (error: any) {
938 showToast(
939 error?.message || __("Failed to update discount status", "yatra"),
940 "error",
941 );
942 } finally {
943 setIsBulkPending(false);
944 }
945 },
946 condition: (discount: Discount) => discount.status !== "expired",
947 },
948 {
949 key: "delete",
950 label: __("Delete", "yatra"),
951 icon: <Trash2 className="w-4 h-4" />,
952 onClick: (discount: Discount) => handleDelete(discount),
953 variant: "destructive" as const,
954 },
955 ];
956
957 return (
958 <div className="space-y-3">
959 {/* Discount Type Selection Modal */}
960 <Modal
961 isOpen={showDiscountTypeModal}
962 onClose={() => setShowDiscountTypeModal(false)}
963 title={__("What type of discount do you want to create?", "yatra")}
964 description={__(
965 "Choose the discount type that best fits your needs",
966 "yatra",
967 )}
968 size="lg"
969 footer={
970 <div className="flex justify-end">
971 <Button
972 variant="outline"
973 onClick={() => setShowDiscountTypeModal(false)}
974 >
975 {__("Cancel", "yatra")}
976 </Button>
977 </div>
978 }
979 >
980 <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
981 {/* Promo Code Option */}
982 <button
983 onClick={() => handleSelectDiscountType("promo")}
984 className="flex flex-col items-center p-6 border-2 border-gray-200 dark:border-gray-600 rounded-xl hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-all group text-left"
985 >
986 <div className="w-14 h-14 bg-blue-100 dark:bg-blue-900/50 rounded-full flex items-center justify-center mb-4 group-hover:bg-blue-200 dark:group-hover:bg-blue-800 transition-colors">
987 <Tag className="w-7 h-7 text-blue-600 dark:text-blue-400" />
988 </div>
989 <h3 className="font-semibold text-gray-900 dark:text-white mb-2">
990 {__("Promo Code", "yatra")}
991 </h3>
992 <p className="text-xs text-gray-500 dark:text-gray-400 text-center">
993 {__(
994 "Customer enters a code to get a percentage or fixed discount",
995 "yatra",
996 )}
997 </p>
998 </button>
999
1000 {/* Group Discount Option */}
1001 <button
1002 onClick={() => handleSelectDiscountType("group")}
1003 className="relative flex flex-col items-center p-6 border-2 border-gray-200 dark:border-gray-600 rounded-xl hover:border-green-500 hover:bg-green-50 dark:hover:bg-green-900/20 transition-all group text-left"
1004 >
1005 {!isAdvancedDiscountEnabled && (
1006 <div
1007 className="absolute top-2 right-2 z-10 flex items-center gap-1 px-2 py-1 text-[10px] font-semibold rounded-full shadow-md"
1008 style={{
1009 background: "linear-gradient(to right, #f59e0b, #f97316)",
1010 color: "#ffffff",
1011 border: "1px solid #fbbf24",
1012 }}
1013 >
1014 <Lock className="w-3 h-3" style={{ color: "#ffffff" }} />
1015 <span style={{ color: "#ffffff" }}>{__("Pro", "yatra")}</span>
1016 </div>
1017 )}
1018 <div className="w-14 h-14 bg-green-100 dark:bg-green-900/50 rounded-full flex items-center justify-center mb-4 group-hover:bg-green-200 dark:group-hover:bg-green-800 transition-colors">
1019 <Users className="w-7 h-7 text-green-600 dark:text-green-400" />
1020 </div>
1021 <h3 className="font-semibold text-gray-900 dark:text-white mb-2">
1022 {__("Group Discount", "yatra")}
1023 </h3>
1024 <p className="text-xs text-gray-500 dark:text-gray-400 text-center">
1025 {__(
1026 "Auto-applies when booking multiple travelers. No code needed.",
1027 "yatra",
1028 )}
1029 </p>
1030 </button>
1031
1032 {/* Both Option */}
1033 <button
1034 onClick={() => handleSelectDiscountType("both")}
1035 className="relative flex flex-col items-center p-6 border-2 border-gray-200 dark:border-gray-600 rounded-xl hover:border-purple-500 hover:bg-purple-50 dark:hover:bg-purple-900/20 transition-all group text-left"
1036 >
1037 {!isAdvancedDiscountEnabled && (
1038 <div
1039 className="absolute top-2 right-2 z-10 flex items-center gap-1 px-2 py-1 text-[10px] font-semibold rounded-full shadow-md"
1040 style={{
1041 background: "linear-gradient(to right, #f59e0b, #f97316)",
1042 color: "#ffffff",
1043 border: "1px solid #fbbf24",
1044 }}
1045 >
1046 <Lock className="w-3 h-3" style={{ color: "#ffffff" }} />
1047 <span style={{ color: "#ffffff" }}>{__("Pro", "yatra")}</span>
1048 </div>
1049 )}
1050 <div className="w-14 h-14 bg-purple-100 dark:bg-purple-900/50 rounded-full flex items-center justify-center mb-4 group-hover:bg-purple-200 dark:group-hover:bg-purple-800 transition-colors">
1051 <div className="flex -space-x-1">
1052 <Tag className="w-5 h-5 text-purple-600 dark:text-purple-400" />
1053 <Users className="w-5 h-5 text-purple-600 dark:text-purple-400" />
1054 </div>
1055 </div>
1056 <h3 className="font-semibold text-gray-900 dark:text-white mb-2">
1057 {__("Promo + Group", "yatra")}
1058 </h3>
1059 <p className="text-xs text-gray-500 dark:text-gray-400 text-center">
1060 {__(
1061 "Promo code gives base discount + extra savings for groups",
1062 "yatra",
1063 )}
1064 </p>
1065 </button>
1066 </div>
1067 </Modal>
1068
1069 <ConfirmationDialog
1070 isOpen={deleteConfirm.isOpen}
1071 onClose={() => setDeleteConfirm({ isOpen: false, discount: null })}
1072 onConfirm={confirmDelete}
1073 title={__("Delete Discount", "yatra")}
1074 message={
1075 deleteConfirm.discount
1076 ? __(
1077 'Are you sure you want to delete discount code "{code}"? This action cannot be undone.',
1078 "yatra",
1079 ).replace("{code}", deleteConfirm.discount.code)
1080 : __(
1081 "Are you sure you want to delete this discount? This action cannot be undone.",
1082 "yatra",
1083 )
1084 }
1085 confirmText={__("Delete", "yatra")}
1086 cancelText={__("Cancel", "yatra")}
1087 variant="danger"
1088 isLoading={deleteMutation.isPending}
1089 />
1090
1091 {/* Premium Upgrade Dialog for Group Discount features */}
1092 <PremiumUpgradeDialog
1093 open={premiumDialog.open}
1094 onClose={() => setPremiumDialog({ open: false, type: null })}
1095 moduleName={getPremiumDialogContent().name}
1096 moduleDescription={getPremiumDialogContent().description}
1097 purchaseUrl={getPremiumDialogContent().purchaseUrl}
1098 />
1099
1100 <PageHeader
1101 title={__("Discounts", "yatra")}
1102 description={__(
1103 "Create and manage discount coupons for your trips",
1104 "yatra",
1105 )}
1106 actionCapability="yatra_edit_bookings"
1107 actions={
1108 <Button
1109 onClick={handleCreateDiscount}
1110 className="flex items-center gap-2"
1111 >
1112 <Plus className="w-4 h-4" />
1113 {__("Create Discount", "yatra")}
1114 </Button>
1115 }
1116 />
1117
1118 <Card>
1119 <CardContent className="p-3">
1120 <div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-2">
1121 <div className="flex-1 min-w-0">
1122 <SearchFilterToolbar
1123 searchTerm={searchTerm}
1124 onSearchChange={setSearchTerm}
1125 statusFilter={statusFilter}
1126 onStatusChange={(value) => {
1127 setStatusFilter(value);
1128 setPage(1);
1129 }}
1130 statusOptions={statusOptions}
1131 sortBy={sortBy}
1132 onSortByChange={(field) => {
1133 setSortBy(field);
1134 setPage(1);
1135 }}
1136 sortOrder={sortOrder}
1137 onSortOrderChange={(order) => {
1138 setSortOrder(order);
1139 setPage(1);
1140 }}
1141 sortOptions={sortOptions}
1142 onResetFilters={handleResetFilters}
1143 hasFilters={!!hasFilters}
1144 placeholder={__("Search by code...", "yatra")}
1145 />
1146 </div>
1147
1148 <div className="w-full lg:w-auto flex lg:justify-end">
1149 <Select
1150 value={typeFilter}
1151 onChange={(e) => {
1152 setTypeFilter(e.target.value);
1153 setPage(1);
1154 }}
1155 className="w-full lg:w-48 max-w-xs"
1156 >
1157 <option value="all">{__("All Types", "yatra")}</option>
1158 <option value="percentage">{__("Percentage", "yatra")}</option>
1159 <option value="fixed">{__("Fixed Amount", "yatra")}</option>
1160 </Select>
1161 </div>
1162 </div>
1163 </CardContent>
1164 </Card>
1165
1166 <ConditionalRender capability="yatra_view_bookings">
1167 <>
1168 <BulkActionToolbar
1169 selectedIds={selectedIds}
1170 bulkAction={bulkAction}
1171 setBulkAction={setBulkAction}
1172 onApply={handleBulkApply}
1173 onClearSelection={() => setSelectedIds([])}
1174 statusFilter={statusFilter}
1175 setStatusFilter={(value) => {
1176 setStatusFilter(value);
1177 setPage(1);
1178 }}
1179 statusOptions={bulkToolbarStatusOptions}
1180 showColumnsDropdown={showColumnsDropdown}
1181 setShowColumnsDropdown={setShowColumnsDropdown}
1182 columnOptions={columnOptions}
1183 onToggleColumn={toggleColumn}
1184 bulkMutationPending={isBulkPending}
1185 totalItems={total}
1186 bulkActionOptions={bulkActionOptions}
1187 />
1188
1189 <Card>
1190 <CardContent className="p-0">
1191 <SharedTable
1192 data={discounts}
1193 columns={columns}
1194 actions={actions}
1195 isLoading={isLoading}
1196 isError={isDiscountsError}
1197 errorText={__("Error loading discounts", "yatra")}
1198 errorDescription={__(
1199 "We couldn't connect to the discounts service. Please refresh or try again shortly.",
1200 "We couldn't connect to the discounts service. Please refresh or try again shortly.",
1201 )}
1202 errorDetails={derivedErrorDetails}
1203 errorRequestInfo={errorContext.requestInfo}
1204 onRetry={() => refetch()}
1205 emptyText={__("No discounts found", "yatra")}
1206 emptyDescription={
1207 hasFilters
1208 ? __(
1209 "Try adjusting your filters to see more results.",
1210 "yatra",
1211 )
1212 : __(
1213 "Get started by creating your first discount coupon.",
1214 "yatra",
1215 )
1216 }
1217 onCreateClick={
1218 can("yatra_edit_bookings") ? handleCreateDiscount : undefined
1219 }
1220 onSort={handleSort}
1221 getSortIcon={getSortIcon}
1222 selectedItemIds={selectedIds}
1223 onSelectItem={handleSelectItem}
1224 onSelectAll={handleSelectAll}
1225 isAllSelected={isAllSelected}
1226 getItemId={(discount: Discount) => discount.id}
1227 skeletonRows={5}
1228 capability="yatra_view_bookings"
1229 />
1230 </CardContent>
1231 </Card>
1232
1233 {total > 0 && (
1234 <div className="mt-4">
1235 <Pagination
1236 currentPage={page}
1237 totalPages={totalPages}
1238 totalItems={total}
1239 itemsPerPage={itemsPerPage}
1240 onPageChange={(newPage) => setPage(newPage)}
1241 itemName={__("coupons", "yatra")}
1242 />
1243 </div>
1244 )}
1245 </>
1246 </ConditionalRender>
1247 </div>
1248 );
1249 };
1250 export default Discounts;
1251