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

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