PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.10
Yatra – Travel Booking & Tour Operator Software v3.0.10
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 2.0.10 All 83 releases
yatra / resources / js / pages / Discounts.tsx

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

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