PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.8
Yatra – Travel Booking & Tour Operator Software v3.0.8
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.8, at resources/js/pages/Discounts.tsx

1,257 lines 42.5 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_manage_discounts"),
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_manage_discounts"),
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(
728 Number(discount.amount) || 0,
729 adminCurrency,
730 {
731 zeroAsUnknown: false,
732 },
733 )}
734 </span>
735 <span className="text-xs text-gray-500">
736 {__("off", "yatra")}
737 </span>
738 </div>
739 )}
740 {/* Group discount info */}
741 {discount.is_group_discount && (
742 <div className="space-y-1">
743 {/* Category-based discounts */}
744 {discount.group_discount_mode === "category_based" &&
745 discount.category_discounts &&
746 discount.category_discounts.length > 0 ? (
747 <div className="space-y-1">
748 {discount.category_discounts.map((cat, idx) => (
749 <div key={idx} className="text-xs">
750 <span className="font-medium text-gray-700 dark:text-gray-300">
751 {cat.traveler_category_label}:
752 </span>
753 <span className="ml-1 text-green-600 dark:text-green-400">
754 {cat.ranges && cat.ranges.length > 0
755 ? cat.ranges.map((range, rIdx) => (
756 <span key={rIdx}>
757 {rIdx > 0 && ", "}
758 {range.min_group_size}
759 {range.max_group_size
760 ? `-${range.max_group_size}`
761 : "+"}
762 :
763 {range.discount_type === "percentage"
764 ? `${range.discount_amount}%`
765 : formatYatraMoney(
766 Number(range.discount_amount) || 0,
767 adminCurrency,
768 { zeroAsUnknown: false },
769 )}
770 </span>
771 ))
772 : "-"}
773 </span>
774 </div>
775 ))}
776 </div>
777 ) : discount.group_discount_ranges &&
778 discount.group_discount_ranges.length > 0 ? (
779 /* Total-based group discount ranges */
780 <div className="space-y-0.5">
781 {discount.group_discount_ranges.map((range, idx) => (
782 <div
783 key={idx}
784 className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1"
785 >
786 <Users className="w-3 h-3" />
787 <span>
788 {range.min_group_size}
789 {range.max_group_size
790 ? `-${range.max_group_size}`
791 : "+"}{" "}
792 {__("pax", "yatra")}:
793 {range.discount_type === "percentage"
794 ? ` ${range.discount_amount}%`
795 : ` ${formatYatraMoney(
796 Number(range.discount_amount) || 0,
797 adminCurrency,
798 { zeroAsUnknown: false },
799 )}`}{" "}
800 {__("off", "yatra")}
801 </span>
802 </div>
803 ))}
804 </div>
805 ) : (
806 /* Fallback for legacy data */
807 <div className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1">
808 <Users className="w-3 h-3" />
809 {discount.min_group_size
810 ? `${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 })}`
811 : __("Group pricing", "yatra")}
812 </div>
813 )}
814 </div>
815 )}
816 </div>
817 ),
818 },
819 {
820 key: "usage",
821 label: __("Usage", "yatra"),
822 sortable: true,
823 visible: visibleColumns.usage,
824 render: (discount: Discount) => (
825 <div className="text-center">
826 <Badge variant="outline" className="font-medium">
827 {discount.usage_count} /{" "}
828 {discount.usage_limit === 0 ? "∞" : discount.usage_limit}
829 </Badge>
830 </div>
831 ),
832 },
833 {
834 key: "expiry_date",
835 label: __("Expiry Date", "yatra"),
836 sortable: true,
837 visible: visibleColumns.expiry_date,
838 render: (discount: Discount) => (
839 <span className="text-gray-500 dark:text-gray-400 text-sm">
840 {discount.expiry_date
841 ? formatDate(discount.expiry_date)
842 : __("No expiry", "yatra")}
843 </span>
844 ),
845 },
846 {
847 key: "status",
848 label: __("Status", "yatra"),
849 sortable: true,
850 visible: visibleColumns.status,
851 render: (discount: Discount) => getStatusBadge(discount.status),
852 },
853 ];
854
855 const actions = [
856 {
857 key: "duplicate",
858 label: __("Duplicate", "yatra"),
859 icon: <Copy className="w-4 h-4" />,
860 onClick: (discount: Discount) => handleDuplicate(discount),
861 },
862 {
863 key: "edit",
864 label: __("Edit", "yatra"),
865 icon: <Edit className="w-4 h-4" />,
866 onClick: (discount: Discount) => handleEdit(discount),
867 },
868 {
869 key: "mark_publish",
870 label: __("Mark as Publish", "yatra"),
871 icon: <ArrowUp className="w-4 h-4" />,
872 onClick: async (discount: Discount) => {
873 setIsBulkPending(true);
874 try {
875 await updateStatusForIds([discount.id], "publish");
876 invalidateDiscountQueries();
877 showToast(__("Discount status updated", "yatra"), "success");
878 } catch (error: any) {
879 showToast(
880 error?.message || __("Failed to update discount status", "yatra"),
881 "error",
882 );
883 } finally {
884 setIsBulkPending(false);
885 }
886 },
887 condition: (discount: Discount) => discount.status !== "publish",
888 },
889 {
890 key: "mark_draft",
891 label: __("Mark as Draft", "yatra"),
892 icon: <ArrowDown className="w-4 h-4" />,
893 onClick: async (discount: Discount) => {
894 setIsBulkPending(true);
895 try {
896 await updateStatusForIds([discount.id], "draft");
897 invalidateDiscountQueries();
898 showToast(__("Discount status updated", "yatra"), "success");
899 } catch (error: any) {
900 showToast(
901 error?.message || __("Failed to update discount status", "yatra"),
902 "error",
903 );
904 } finally {
905 setIsBulkPending(false);
906 }
907 },
908 condition: (discount: Discount) => discount.status !== "draft",
909 },
910 {
911 key: "move_trash",
912 label: __("Move to Trash", "yatra"),
913 icon: <Trash2 className="w-4 h-4" />,
914 onClick: async (discount: Discount) => {
915 setIsBulkPending(true);
916 try {
917 await updateStatusForIds([discount.id], "trash");
918 invalidateDiscountQueries();
919 showToast(__("Discount moved to trash", "yatra"), "success");
920 } catch (error: any) {
921 showToast(
922 error?.message || __("Failed to move discount to trash", "yatra"),
923 "error",
924 );
925 } finally {
926 setIsBulkPending(false);
927 }
928 },
929 condition: (discount: Discount) => discount.status !== "trash",
930 },
931 {
932 key: "mark_expired",
933 label: __("Mark as Expired", "yatra"),
934 icon: <ArrowDown className="w-4 h-4" />,
935 onClick: async (discount: Discount) => {
936 setIsBulkPending(true);
937 try {
938 await updateStatusForIds([discount.id], "expired");
939 invalidateDiscountQueries();
940 showToast(__("Discount status updated", "yatra"), "success");
941 } catch (error: any) {
942 showToast(
943 error?.message || __("Failed to update discount status", "yatra"),
944 "error",
945 );
946 } finally {
947 setIsBulkPending(false);
948 }
949 },
950 condition: (discount: Discount) => discount.status !== "expired",
951 },
952 {
953 key: "delete",
954 label: __("Delete", "yatra"),
955 icon: <Trash2 className="w-4 h-4" />,
956 onClick: (discount: Discount) => handleDelete(discount),
957 variant: "destructive" as const,
958 },
959 ];
960
961 return (
962 <div className="space-y-3">
963 {/* Discount Type Selection Modal */}
964 <Modal
965 isOpen={showDiscountTypeModal}
966 onClose={() => setShowDiscountTypeModal(false)}
967 title={__("What type of discount do you want to create?", "yatra")}
968 description={__(
969 "Choose the discount type that best fits your needs",
970 "yatra",
971 )}
972 size="lg"
973 footer={
974 <div className="flex justify-end">
975 <Button
976 variant="outline"
977 onClick={() => setShowDiscountTypeModal(false)}
978 >
979 {__("Cancel", "yatra")}
980 </Button>
981 </div>
982 }
983 >
984 <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
985 {/* Promo Code Option */}
986 <button
987 onClick={() => handleSelectDiscountType("promo")}
988 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"
989 >
990 <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">
991 <Tag className="w-7 h-7 text-blue-600 dark:text-blue-400" />
992 </div>
993 <h3 className="font-semibold text-gray-900 dark:text-white mb-2">
994 {__("Promo Code", "yatra")}
995 </h3>
996 <p className="text-xs text-gray-500 dark:text-gray-400 text-center">
997 {__(
998 "Customer enters a code to get a percentage or fixed discount",
999 "yatra",
1000 )}
1001 </p>
1002 </button>
1003
1004 {/* Group Discount Option */}
1005 <button
1006 onClick={() => handleSelectDiscountType("group")}
1007 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"
1008 >
1009 {!isAdvancedDiscountEnabled && (
1010 <div
1011 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"
1012 style={{
1013 background: "linear-gradient(to right, #f59e0b, #f97316)",
1014 color: "#ffffff",
1015 border: "1px solid #fbbf24",
1016 }}
1017 >
1018 <Lock className="w-3 h-3" style={{ color: "#ffffff" }} />
1019 <span style={{ color: "#ffffff" }}>{__("Pro", "yatra")}</span>
1020 </div>
1021 )}
1022 <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">
1023 <Users className="w-7 h-7 text-green-600 dark:text-green-400" />
1024 </div>
1025 <h3 className="font-semibold text-gray-900 dark:text-white mb-2">
1026 {__("Group Discount", "yatra")}
1027 </h3>
1028 <p className="text-xs text-gray-500 dark:text-gray-400 text-center">
1029 {__(
1030 "Auto-applies when booking multiple travelers. No code needed.",
1031 "yatra",
1032 )}
1033 </p>
1034 </button>
1035
1036 {/* Both Option */}
1037 <button
1038 onClick={() => handleSelectDiscountType("both")}
1039 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"
1040 >
1041 {!isAdvancedDiscountEnabled && (
1042 <div
1043 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"
1044 style={{
1045 background: "linear-gradient(to right, #f59e0b, #f97316)",
1046 color: "#ffffff",
1047 border: "1px solid #fbbf24",
1048 }}
1049 >
1050 <Lock className="w-3 h-3" style={{ color: "#ffffff" }} />
1051 <span style={{ color: "#ffffff" }}>{__("Pro", "yatra")}</span>
1052 </div>
1053 )}
1054 <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">
1055 <div className="flex -space-x-1">
1056 <Tag className="w-5 h-5 text-purple-600 dark:text-purple-400" />
1057 <Users className="w-5 h-5 text-purple-600 dark:text-purple-400" />
1058 </div>
1059 </div>
1060 <h3 className="font-semibold text-gray-900 dark:text-white mb-2">
1061 {__("Promo + Group", "yatra")}
1062 </h3>
1063 <p className="text-xs text-gray-500 dark:text-gray-400 text-center">
1064 {__(
1065 "Promo code gives base discount + extra savings for groups",
1066 "yatra",
1067 )}
1068 </p>
1069 </button>
1070 </div>
1071 </Modal>
1072
1073 <ConfirmationDialog
1074 isOpen={deleteConfirm.isOpen}
1075 onClose={() => setDeleteConfirm({ isOpen: false, discount: null })}
1076 onConfirm={confirmDelete}
1077 title={__("Delete Discount", "yatra")}
1078 message={
1079 deleteConfirm.discount
1080 ? __(
1081 'Are you sure you want to delete discount code "{code}"? This action cannot be undone.',
1082 "yatra",
1083 ).replace("{code}", deleteConfirm.discount.code)
1084 : __(
1085 "Are you sure you want to delete this discount? This action cannot be undone.",
1086 "yatra",
1087 )
1088 }
1089 confirmText={__("Delete", "yatra")}
1090 cancelText={__("Cancel", "yatra")}
1091 variant="danger"
1092 isLoading={deleteMutation.isPending}
1093 />
1094
1095 {/* Premium Upgrade Dialog for Group Discount features */}
1096 <PremiumUpgradeDialog
1097 open={premiumDialog.open}
1098 onClose={() => setPremiumDialog({ open: false, type: null })}
1099 moduleName={getPremiumDialogContent().name}
1100 moduleDescription={getPremiumDialogContent().description}
1101 purchaseUrl={getPremiumDialogContent().purchaseUrl}
1102 />
1103
1104 <PageHeader
1105 title={__("Discounts", "yatra")}
1106 description={__(
1107 "Create and manage discount coupons for your trips",
1108 "yatra",
1109 )}
1110 actionCapability="yatra_manage_discounts"
1111 actions={
1112 <Button
1113 onClick={handleCreateDiscount}
1114 className="flex items-center gap-2"
1115 >
1116 <Plus className="w-4 h-4" />
1117 {__("Create Discount", "yatra")}
1118 </Button>
1119 }
1120 />
1121
1122 <Card>
1123 <CardContent className="p-3">
1124 <div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-2">
1125 <div className="flex-1 min-w-0">
1126 <SearchFilterToolbar
1127 searchTerm={searchTerm}
1128 onSearchChange={setSearchTerm}
1129 statusFilter={statusFilter}
1130 onStatusChange={(value) => {
1131 setStatusFilter(value);
1132 setPage(1);
1133 }}
1134 statusOptions={statusOptions}
1135 sortBy={sortBy}
1136 onSortByChange={(field) => {
1137 setSortBy(field);
1138 setPage(1);
1139 }}
1140 sortOrder={sortOrder}
1141 onSortOrderChange={(order) => {
1142 setSortOrder(order);
1143 setPage(1);
1144 }}
1145 sortOptions={sortOptions}
1146 onResetFilters={handleResetFilters}
1147 hasFilters={!!hasFilters}
1148 placeholder={__("Search by code...", "yatra")}
1149 />
1150 </div>
1151
1152 <div className="w-full lg:w-auto flex lg:justify-end">
1153 <Select
1154 value={typeFilter}
1155 onChange={(e) => {
1156 setTypeFilter(e.target.value);
1157 setPage(1);
1158 }}
1159 className="w-full lg:w-48 max-w-xs"
1160 >
1161 <option value="all">{__("All Types", "yatra")}</option>
1162 <option value="percentage">{__("Percentage", "yatra")}</option>
1163 <option value="fixed">{__("Fixed Amount", "yatra")}</option>
1164 </Select>
1165 </div>
1166 </div>
1167 </CardContent>
1168 </Card>
1169
1170 <ConditionalRender capability="yatra_manage_discounts">
1171 <>
1172 <BulkActionToolbar
1173 selectedIds={selectedIds}
1174 bulkAction={bulkAction}
1175 setBulkAction={setBulkAction}
1176 onApply={handleBulkApply}
1177 onClearSelection={() => setSelectedIds([])}
1178 statusFilter={statusFilter}
1179 setStatusFilter={(value) => {
1180 setStatusFilter(value);
1181 setPage(1);
1182 }}
1183 statusOptions={bulkToolbarStatusOptions}
1184 showColumnsDropdown={showColumnsDropdown}
1185 setShowColumnsDropdown={setShowColumnsDropdown}
1186 columnOptions={columnOptions}
1187 onToggleColumn={toggleColumn}
1188 bulkMutationPending={isBulkPending}
1189 totalItems={total}
1190 bulkActionOptions={bulkActionOptions}
1191 />
1192
1193 <Card>
1194 <CardContent className="p-0">
1195 <SharedTable
1196 data={discounts}
1197 columns={columns}
1198 actions={actions}
1199 isLoading={isLoading}
1200 isError={isDiscountsError}
1201 errorText={__("Error loading discounts", "yatra")}
1202 errorDescription={__(
1203 "We couldn't connect to the discounts service. Please refresh or try again shortly.",
1204 "We couldn't connect to the discounts service. Please refresh or try again shortly.",
1205 )}
1206 errorDetails={derivedErrorDetails}
1207 errorRequestInfo={errorContext.requestInfo}
1208 onRetry={() => refetch()}
1209 emptyText={__("No discounts found", "yatra")}
1210 emptyDescription={
1211 hasFilters
1212 ? __(
1213 "Try adjusting your filters to see more results.",
1214 "yatra",
1215 )
1216 : __(
1217 "Get started by creating your first discount coupon.",
1218 "yatra",
1219 )
1220 }
1221 onCreateClick={
1222 can("yatra_manage_discounts")
1223 ? handleCreateDiscount
1224 : undefined
1225 }
1226 onSort={handleSort}
1227 getSortIcon={getSortIcon}
1228 selectedItemIds={selectedIds}
1229 onSelectItem={handleSelectItem}
1230 onSelectAll={handleSelectAll}
1231 isAllSelected={isAllSelected}
1232 getItemId={(discount: Discount) => discount.id}
1233 skeletonRows={5}
1234 capability="yatra_manage_discounts"
1235 />
1236 </CardContent>
1237 </Card>
1238
1239 {total > 0 && (
1240 <div className="mt-4">
1241 <Pagination
1242 currentPage={page}
1243 totalPages={totalPages}
1244 totalItems={total}
1245 itemsPerPage={itemsPerPage}
1246 onPageChange={(newPage) => setPage(newPage)}
1247 itemName={__("coupons", "yatra")}
1248 />
1249 </div>
1250 )}
1251 </>
1252 </ConditionalRender>
1253 </div>
1254 );
1255 };
1256 export default Discounts;
1257