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 / DiscountForm.tsx

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

2,726 lines 126.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Discount Form Page
3 * Add/Edit Discount Coupon form
4 */
5
6 import React, { useState, useEffect, useMemo } from "react";
7 import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
8 import {
9 ArrowLeft,
10 Save,
11 Loader2,
12 Info,
13 Plus,
14 Trash2,
15 Users,
16 } from "lucide-react";
17 import { __ } from "../lib/i18n";
18 import { toDateValue } from "../lib/dateFormat";
19 import { usePermissions } from "../hooks/usePermissions";
20 import { useToast } from "../components/ui/toast";
21 import { apiClient } from "../lib/api-client";
22 import { Button } from "../components/ui/button";
23 import { Input } from "../components/ui/input";
24 import { getCurrencySymbol } from "../data/currencies";
25 import {
26 normalizeCurrencyPosition,
27 readYatraCurrencyPositionFromWindow,
28 } from "../lib/currency-display";
29 import { Select } from "../components/ui/select";
30 import { PageHeader } from "../components/common/PageHeader";
31 import {
32 Card,
33 CardContent,
34 CardHeader,
35 CardTitle,
36 } from "../components/ui/card";
37 import { ConditionalRender } from "../components/ui/conditional-render";
38 import { HelpText } from "../components/ui/help-text";
39 import { DatePicker } from "../components/ui/date-picker";
40 import { ApplicableTripSelector } from "../components/shared/ApplicableTripSelector";
41 import { PremiumUpgradeDialog } from "../components/modules/PremiumUpgradeDialog";
42
43 interface DiscountFormData {
44 code: string;
45 description: string;
46 type: "percentage" | "fixed";
47 amount: string;
48 max_discount_amount: string; // Maximum discount amount for percentage discounts
49 usage_limit: string; // Total usage limit
50 usage_limit_per_customer: string; // Usage limit per customer
51 valid_from: string; // Start date
52 expiry_date: string; // End date
53 status: "draft" | "publish" | "trash";
54 applicable_to: "all" | "specific_trips";
55 trip_ids: number[];
56 min_amount: string; // Minimum booking amount
57 first_time_customer_only: boolean; // Only for first-time customers
58 is_group_discount: boolean;
59 discount_mode: "promo" | "group" | "both"; // Type of discount: promo code only, group only, or both
60 group_discount_mode: "total" | "category_based";
61 group_discount_ranges: Array<{
62 id: string;
63 min_group_size: string;
64 max_group_size: string; // empty = unlimited
65 discount_type: "percentage" | "fixed";
66 discount_amount: string;
67 categories: Array<{
68 id: string;
69 traveler_category_id: string;
70 discount_type: "percentage" | "fixed";
71 discount_amount: string;
72 }>;
73 }>;
74 category_discounts: Array<{
75 traveler_category_id: string;
76 traveler_category_label: string;
77 isSelecting?: boolean;
78 ranges: Array<{
79 id: string;
80 min_group_size: string;
81 max_group_size: string; // empty = unlimited
82 discount_type: "percentage" | "fixed";
83 discount_amount: string;
84 }>;
85 }>;
86 }
87
88 const DiscountForm: React.FC = () => {
89 const queryClient = useQueryClient();
90 const { can, permissions, isPro } = usePermissions();
91 const { showToast } = useToast();
92
93 // Currency symbol + placement for the amount fields, taken from the operator's
94 // settings (localized into window.yatraAdmin). Previously these inputs
95 // hardcoded "$" on the left, ignoring both the configured symbol and its
96 // position (left / left_space / right / right_space).
97 const currencySymbol = getCurrencySymbol(
98 ((window as unknown as { yatraAdmin?: { currency?: string } })?.yatraAdmin
99 ?.currency as string) || "USD",
100 );
101 const currencyOnRight = (() => {
102 const pos = normalizeCurrencyPosition(readYatraCurrencyPositionFromWindow());
103 return pos === "right" || pos === "right_space";
104 })();
105 const currencyPad = currencyOnRight
106 ? currencySymbol.length > 1
107 ? "pr-10"
108 : "pr-7"
109 : currencySymbol.length > 1
110 ? "pl-10"
111 : "pl-7";
112 const [showPremiumModal, setShowPremiumModal] = useState(false);
113 // Get initial discount_mode from URL at initialization time
114 const getInitialState = (): DiscountFormData => {
115 const params = new URLSearchParams(window.location.search);
116 const urlDiscountMode = (params.get("discount_mode") || "both") as
117 | "promo"
118 | "group"
119 | "both";
120 const isGroupOnly = urlDiscountMode === "group";
121
122 return {
123 code: "",
124 description: "",
125 type: "percentage",
126 amount: isGroupOnly ? "0" : "",
127 max_discount_amount: "",
128 usage_limit: "0",
129 usage_limit_per_customer: "0",
130 valid_from: "",
131 expiry_date: "",
132 status: "draft",
133 applicable_to: "all",
134 trip_ids: [],
135 min_amount: "",
136 first_time_customer_only: false,
137 is_group_discount: isGroupOnly,
138 discount_mode: urlDiscountMode,
139 group_discount_mode: "total",
140 group_discount_ranges: [
141 {
142 id: String(Date.now()),
143 min_group_size: "",
144 max_group_size: "",
145 discount_type: "percentage",
146 discount_amount: "",
147 categories: [],
148 },
149 ],
150 category_discounts: [],
151 };
152 };
153
154 const [formData, setFormData] = useState<DiscountFormData>(getInitialState);
155 const [errors, setErrors] = useState<Record<string, string>>({});
156 const [isSubmitting, setIsSubmitting] = useState(false);
157 const [showCategoryDropdown, setShowCategoryDropdown] = useState(false);
158
159 // Get action and id from URL
160 const action = useMemo(() => {
161 const params = new URLSearchParams(window.location.search);
162 return params.get("action") || "create";
163 }, []);
164
165 const discountId = useMemo(() => {
166 const params = new URLSearchParams(window.location.search);
167 return params.get("id") ? parseInt(params.get("id") || "0") : null;
168 }, []);
169
170 const duplicateId = useMemo(() => {
171 const params = new URLSearchParams(window.location.search);
172 return params.get("duplicate")
173 ? parseInt(params.get("duplicate") || "0")
174 : null;
175 }, []);
176
177 // Get discount_mode from URL (promo, group, or both) - compute once at module level
178 const discountMode = useMemo(() => {
179 const params = new URLSearchParams(window.location.search);
180 return (params.get("discount_mode") || "both") as
181 | "promo"
182 | "group"
183 | "both";
184 }, []);
185
186 const isEditMode = action === "edit" && discountId !== null;
187 const isDuplicateMode = action === "create" && duplicateId !== null;
188
189 // For new discounts, use URL param; for edit mode, we'll use formData.discount_mode after it's loaded
190 const isGroupOnlyMode = discountMode === "group";
191 const isPromoOnlyMode = discountMode === "promo";
192
193 // Computed mode that works for both create and edit - use formData when available
194 const effectiveDiscountMode = isEditMode
195 ? formData.discount_mode
196 : discountMode;
197 const isEffectiveGroupOnly = effectiveDiscountMode === "group";
198 const isEffectivePromoOnly = effectiveDiscountMode === "promo";
199
200 // Fetch discount data if editing or duplicating
201 const { data: discountData, isLoading: isLoadingDiscount } = useQuery({
202 queryKey: ["discount", discountId || duplicateId],
203 queryFn: async () => {
204 const id = discountId || duplicateId;
205 if (!id) return null;
206 try {
207 const response = await apiClient.get(`/discounts/${id}`);
208 return response;
209 } catch (error: any) {
210 showToast(
211 error?.message || __("Failed to load discount", "yatra"),
212 "error",
213 );
214 throw error;
215 }
216 },
217 enabled: (isEditMode || isDuplicateMode) && can("yatra_manage_discounts"),
218 });
219
220 // Load discount data into form when editing or duplicating
221 useEffect(() => {
222 if (discountData && (isEditMode || isDuplicateMode)) {
223 setFormData({
224 code: isDuplicateMode
225 ? `${discountData.code}_COPY`
226 : discountData.code || "",
227 description: discountData.description || "",
228 type: (discountData.type || "percentage") as "percentage" | "fixed",
229 amount: discountData.amount?.toString() || "",
230 max_discount_amount: discountData.max_discount_amount?.toString() || "",
231 usage_limit: discountData.usage_limit?.toString() || "0",
232 usage_limit_per_customer:
233 discountData.usage_limit_per_customer?.toString() || "0",
234 valid_from: discountData.valid_from || "",
235 expiry_date: discountData.expiry_date || "",
236 status: (isDuplicateMode ? "draft" : discountData.status || "draft") as
237 | "draft"
238 | "publish"
239 | "trash",
240 applicable_to: (discountData.applicable_to || "all") as
241 | "all"
242 | "specific_trips",
243 trip_ids: discountData.trip_ids || [],
244 min_amount: discountData.min_amount?.toString() || "",
245 first_time_customer_only:
246 discountData.first_time_customer_only || false,
247 is_group_discount: discountData.is_group_discount || false,
248 discount_mode: (discountData.discount_mode || "both") as
249 | "promo"
250 | "group"
251 | "both",
252 group_discount_mode: (discountData.group_discount_mode || "total") as
253 | "total"
254 | "category_based",
255 group_discount_ranges:
256 Array.isArray(discountData.group_discount_ranges) &&
257 discountData.group_discount_ranges.length
258 ? discountData.group_discount_ranges
259 : [
260 {
261 id: String(Date.now()),
262 min_group_size: discountData.min_group_size?.toString() || "",
263 max_group_size: discountData.max_group_size?.toString() || "",
264 discount_type: (discountData.group_discount_type ||
265 "percentage") as "percentage" | "fixed",
266 discount_amount:
267 discountData.group_discount_amount?.toString() || "",
268 categories: [],
269 },
270 ],
271 category_discounts:
272 Array.isArray(discountData.category_discounts) &&
273 discountData.category_discounts?.length
274 ? discountData.category_discounts
275 : [],
276 });
277 }
278 }, [discountData, isEditMode, isDuplicateMode]);
279
280 // Set initial form state based on discount mode (only for new discounts)
281 useEffect(() => {
282 if (!isEditMode && !isDuplicateMode && !discountData) {
283 if (isGroupOnlyMode) {
284 // Group discount only - auto-enable group discount, set amount to 0
285 setFormData((prev) => ({
286 ...prev,
287 is_group_discount: true,
288 discount_mode: "group",
289 amount: "0",
290 type: "percentage",
291 }));
292 } else if (isPromoOnlyMode) {
293 // Promo code only - disable group discount
294 setFormData((prev) => ({
295 ...prev,
296 is_group_discount: false,
297 discount_mode: "promo",
298 }));
299 } else {
300 // Both - default
301 setFormData((prev) => ({
302 ...prev,
303 discount_mode: "both",
304 }));
305 }
306 }
307 }, [
308 isGroupOnlyMode,
309 isPromoOnlyMode,
310 isEditMode,
311 isDuplicateMode,
312 discountData,
313 ]);
314
315 const handleFieldChange = (field: keyof DiscountFormData, value: any) => {
316 setFormData((prev) => ({ ...prev, [field]: value }));
317 if (errors[field]) {
318 setErrors((prev) => ({ ...prev, [field]: "" }));
319 }
320
321 // Clear ranges when switching discount modes
322 if (field === "group_discount_mode") {
323 if (value === "category_based") {
324 // Switch to category-based - clear regular ranges and start with empty categories
325 setFormData((prev) => ({
326 ...prev,
327 group_discount_mode: "category_based",
328 group_discount_ranges: [],
329 category_discounts: [], // Ensure empty categories array
330 }));
331 } else {
332 // Switch to total - clear category discounts and start with one empty range
333 setFormData((prev) => ({
334 ...prev,
335 group_discount_mode: "total",
336 group_discount_ranges: [
337 {
338 id: String(Date.now()),
339 min_group_size: "",
340 max_group_size: "",
341 discount_type: "percentage",
342 discount_amount: "",
343 categories: [],
344 },
345 ],
346 category_discounts: [], // Ensure empty categories array
347 }));
348 }
349 }
350 };
351
352 const handleGroupDiscountToggle = (checked: boolean) => {
353 handleFieldChange("is_group_discount", checked);
354 };
355
356 const travelerCategoriesQuery = useQuery({
357 queryKey: ["traveler-categories"],
358 queryFn: async () => {
359 try {
360 const response = await apiClient.get("/traveler-categories", {
361 params: {
362 per_page: 100,
363 status: "publish",
364 },
365 });
366 const categories =
367 response?.data?.data || response?.data || response || [];
368 return Array.isArray(categories) ? categories : [];
369 } catch (error: any) {
370 console.error("Failed to load traveler categories:", error);
371 return [];
372 }
373 },
374 staleTime: 5 * 60 * 1000,
375 });
376
377 const travelerCategoryOptions = useMemo(() => {
378 const items = travelerCategoriesQuery.data || [];
379 return items
380 .filter(
381 (c: any) =>
382 !!c &&
383 (typeof c.id === "number" || typeof c.id === "string") &&
384 (c.status === "publish" || c.status === "active"),
385 )
386 .map((c: any) => ({
387 value: String(c.id),
388 label: c.label || c.slug || String(c.id),
389 description: c.description || "",
390 age_min: c.age_min,
391 age_max: c.age_max,
392 pricing_mode: c.pricing_mode || "per_person",
393 }));
394 }, [travelerCategoriesQuery.data]);
395
396 const addRange = () => {
397 setFormData((prev) => ({
398 ...prev,
399 group_discount_ranges: [
400 ...prev.group_discount_ranges,
401 {
402 id: String(Date.now() + Math.random()),
403 min_group_size: "",
404 max_group_size: "",
405 discount_type: "percentage",
406 discount_amount: "",
407 categories: [],
408 },
409 ],
410 }));
411 };
412
413 const removeRange = (rangeId: string) => {
414 setFormData((prev) => ({
415 ...prev,
416 group_discount_ranges: prev.group_discount_ranges.filter(
417 (r) => r.id !== rangeId,
418 ),
419 }));
420 };
421
422 const updateRange = (
423 rangeId: string,
424 patch: Partial<DiscountFormData["group_discount_ranges"][number]>,
425 ) => {
426 setFormData((prev) => ({
427 ...prev,
428 group_discount_ranges: prev.group_discount_ranges.map((r) =>
429 r.id === rangeId ? { ...r, ...patch } : r,
430 ),
431 }));
432 };
433
434 // Category-based discount functions
435 const addTravelerCategory = () => {
436 // Toggle category selection dropdown
437 setShowCategoryDropdown((prev) => !prev);
438 };
439
440 const selectCategory = (categoryId: string, categoryLabel: string) => {
441 // Add selected category and close dropdown
442 setFormData((prev) => ({
443 ...prev,
444 category_discounts: [
445 ...prev.category_discounts,
446 {
447 traveler_category_id: categoryId,
448 traveler_category_label: categoryLabel,
449 ranges: [],
450 isSelecting: false,
451 },
452 ],
453 }));
454 setShowCategoryDropdown(false);
455 };
456
457 const removeTravelerCategory = (categoryIndex: number) => {
458 setFormData((prev) => ({
459 ...prev,
460 category_discounts: prev.category_discounts.filter(
461 (_, idx) => idx !== categoryIndex,
462 ),
463 }));
464 };
465
466 const updateTravelerCategory = (
467 categoryIndex: number,
468 patch: Partial<DiscountFormData["category_discounts"][number]>,
469 ) => {
470 setFormData((prev) => ({
471 ...prev,
472 category_discounts: prev.category_discounts.map((cat, idx) =>
473 idx === categoryIndex ? { ...cat, ...patch } : cat,
474 ),
475 }));
476 };
477
478 const addRangeToCategory = (categoryIndex: number) => {
479 setFormData((prev) => ({
480 ...prev,
481 category_discounts: prev.category_discounts.map((cat, idx) => {
482 if (idx !== categoryIndex) return cat;
483 return {
484 ...cat,
485 ranges: [
486 ...cat.ranges,
487 {
488 id: String(Date.now() + Math.random()),
489 min_group_size: "",
490 max_group_size: "",
491 discount_type: "percentage",
492 discount_amount: "",
493 },
494 ],
495 };
496 }),
497 }));
498 };
499
500 const removeRangeFromCategory = (categoryIndex: number, rangeId: string) => {
501 setFormData((prev) => ({
502 ...prev,
503 category_discounts: prev.category_discounts.map((cat, idx) => {
504 if (idx !== categoryIndex) return cat;
505 return { ...cat, ranges: cat.ranges.filter((r) => r.id !== rangeId) };
506 }),
507 }));
508 };
509
510 const updateRangeInCategory = (
511 categoryIndex: number,
512 rangeId: string,
513 patch: Partial<
514 DiscountFormData["category_discounts"][number]["ranges"][number]
515 >,
516 ) => {
517 setFormData((prev) => ({
518 ...prev,
519 category_discounts: prev.category_discounts.map((cat, idx) => {
520 if (idx !== categoryIndex) return cat;
521 const ranges = cat.ranges.map((r) =>
522 r.id === rangeId ? { ...r, ...patch } : r,
523 );
524 return { ...cat, ranges };
525 }),
526 }));
527 };
528
529 const validateForm = (): boolean => {
530 const newErrors: Record<string, string> = {};
531
532 // Internal name/code is always required
533 if (!formData.code.trim()) {
534 newErrors.code = isEffectiveGroupOnly
535 ? __("Internal name is required", "yatra")
536 : __("Coupon code is required", "yatra");
537 } else if (!/^[A-Z0-9_-]+$/.test(formData.code)) {
538 newErrors.code = __(
539 "Only uppercase letters, numbers, hyphens, and underscores are allowed",
540 "yatra",
541 );
542 }
543
544 // Discount amount validation - skip for group-only mode
545 if (!isEffectiveGroupOnly) {
546 if (!formData.amount.trim()) {
547 newErrors.amount = __("Discount amount is required", "yatra");
548 } else {
549 const amount = parseFloat(formData.amount);
550 if (isNaN(amount) || amount < 0) {
551 newErrors.amount = __(
552 "Discount amount must be a positive number",
553 "yatra",
554 );
555 } else if (formData.type === "percentage" && amount > 100) {
556 newErrors.amount = __(
557 "Percentage discount cannot exceed 100%",
558 "yatra",
559 );
560 }
561 }
562
563 // Maximum discount amount validation (for percentage discounts)
564 if (
565 formData.type === "percentage" &&
566 formData.max_discount_amount &&
567 formData.max_discount_amount.trim()
568 ) {
569 const maxAmount = parseFloat(formData.max_discount_amount);
570 if (isNaN(maxAmount) || maxAmount <= 0) {
571 newErrors.max_discount_amount = __(
572 "Maximum discount amount must be a positive number",
573 "yatra",
574 );
575 }
576 }
577 }
578
579 if (formData.usage_limit && formData.usage_limit !== "0") {
580 const limit = parseInt(formData.usage_limit);
581 if (isNaN(limit) || limit < 0) {
582 newErrors.usage_limit = __(
583 "Usage limit must be a positive number or 0 for unlimited",
584 "yatra",
585 );
586 }
587 }
588
589 if (
590 formData.usage_limit_per_customer &&
591 formData.usage_limit_per_customer !== "0"
592 ) {
593 const limit = parseInt(formData.usage_limit_per_customer);
594 if (isNaN(limit) || limit < 0) {
595 newErrors.usage_limit_per_customer = __(
596 "Per-customer usage limit must be a positive number or 0 for unlimited",
597 "yatra",
598 );
599 }
600 }
601
602 // Date validation
603 const today = new Date();
604 today.setHours(0, 0, 0, 0);
605
606 // Valid from date can be in the past (backdating allowed)
607 // No validation needed for valid_from date
608
609 if (formData.expiry_date) {
610 const expiryDate = toDateValue(formData.expiry_date);
611 if (expiryDate < today) {
612 newErrors.expiry_date = __(
613 "Expiry date cannot be in the past",
614 "yatra",
615 );
616 }
617 }
618
619 // Check if expiry date is after valid from date
620 if (formData.valid_from && formData.expiry_date) {
621 const validFrom = toDateValue(formData.valid_from);
622 const expiryDate = toDateValue(formData.expiry_date);
623 if (expiryDate < validFrom) {
624 newErrors.expiry_date = __(
625 "Expiry date must be after the valid from date",
626 "yatra",
627 );
628 }
629 }
630
631 if (formData.min_amount && formData.min_amount.trim()) {
632 const minAmount = parseFloat(formData.min_amount);
633 if (isNaN(minAmount) || minAmount < 0) {
634 newErrors.min_amount = __(
635 "Minimum amount must be a positive number",
636 "yatra",
637 );
638 }
639 }
640
641 // Group discount validation (multiple ranges) - only for 'total' mode
642 if (
643 formData.is_group_discount &&
644 formData.group_discount_mode === "total"
645 ) {
646 if (
647 !formData.group_discount_ranges ||
648 formData.group_discount_ranges.length === 0
649 ) {
650 newErrors.group_discount_ranges = __(
651 "At least one group discount range is required",
652 "yatra",
653 );
654 } else {
655 formData.group_discount_ranges.forEach((range, idx) => {
656 const prefix = `group_discount_ranges_${idx}`;
657 const min = parseInt(range.min_group_size);
658 const max = range.max_group_size
659 ? parseInt(range.max_group_size)
660 : null;
661
662 if (Number.isNaN(min) || min < 1) {
663 newErrors[`${prefix}_min`] = __(
664 "Minimum group size must be at least 1",
665 "yatra",
666 );
667 }
668
669 if (max !== null) {
670 if (Number.isNaN(max) || max < 1) {
671 newErrors[`${prefix}_max`] = __(
672 "Maximum group size must be a positive number",
673 "yatra",
674 );
675 } else if (!Number.isNaN(min) && max <= min) {
676 newErrors[`${prefix}_max`] = __(
677 "Maximum group size must be greater than minimum group size",
678 "yatra",
679 );
680 }
681 }
682
683 const amount = range.discount_amount
684 ? parseFloat(range.discount_amount)
685 : NaN;
686 if (!range.discount_type) {
687 newErrors[`${prefix}_type`] = __(
688 "Discount type is required",
689 "yatra",
690 );
691 }
692 if (Number.isNaN(amount) || amount <= 0) {
693 newErrors[`${prefix}_amount`] = __(
694 "Discount amount must be a positive number",
695 "yatra",
696 );
697 } else if (range.discount_type === "percentage" && amount > 100) {
698 newErrors[`${prefix}_amount`] = __(
699 "Percentage discount cannot exceed 100%",
700 "yatra",
701 );
702 }
703 });
704 }
705 }
706
707 // Category-based discounts validation
708 if (
709 formData.is_group_discount &&
710 formData.group_discount_mode === "category_based"
711 ) {
712 if (
713 !formData.category_discounts ||
714 formData.category_discounts.length === 0
715 ) {
716 newErrors.category_discounts = __(
717 "Add at least one traveler category",
718 "yatra",
719 );
720 } else {
721 formData.category_discounts.forEach((cat, catIdx) => {
722 const catPrefix = `category_discounts_${catIdx}`;
723 if (!cat.traveler_category_id) {
724 newErrors[`${catPrefix}_id`] = __(
725 "Traveler category is required",
726 "yatra",
727 );
728 }
729 if (!cat.ranges || cat.ranges.length === 0) {
730 newErrors[`${catPrefix}_ranges`] = __(
731 "Add at least one discount range",
732 "yatra",
733 );
734 } else {
735 cat.ranges.forEach((range, rIdx) => {
736 const rangePrefix = `${catPrefix}_ranges_${rIdx}`;
737 const min = parseInt(range.min_group_size);
738 const max = range.max_group_size
739 ? parseInt(range.max_group_size)
740 : null;
741
742 if (Number.isNaN(min) || min < 1) {
743 newErrors[`${rangePrefix}_min`] = __(
744 "Minimum group size must be at least 1",
745 "yatra",
746 );
747 }
748
749 if (max !== null) {
750 if (Number.isNaN(max) || max < 1) {
751 newErrors[`${rangePrefix}_max`] = __(
752 "Maximum group size must be a positive number",
753 "yatra",
754 );
755 } else if (!Number.isNaN(min) && max <= min) {
756 newErrors[`${rangePrefix}_max`] = __(
757 "Maximum group size must be greater than minimum group size",
758 "yatra",
759 );
760 }
761 }
762
763 const amount = range.discount_amount
764 ? parseFloat(range.discount_amount)
765 : NaN;
766 if (!range.discount_type) {
767 newErrors[`${rangePrefix}_type`] = __(
768 "Discount type is required",
769 "yatra",
770 );
771 }
772 if (Number.isNaN(amount) || amount <= 0) {
773 newErrors[`${rangePrefix}_amount`] = __(
774 "Discount amount must be a positive number",
775 "yatra",
776 );
777 } else if (range.discount_type === "percentage" && amount > 100) {
778 newErrors[`${rangePrefix}_amount`] = __(
779 "Percentage discount cannot exceed 100%",
780 "yatra",
781 );
782 }
783 });
784 }
785 });
786 }
787 }
788
789 setErrors(newErrors);
790
791 // Scroll to first error field
792 if (Object.keys(newErrors).length > 0) {
793 const firstErrorKey = Object.keys(newErrors)[0];
794 // Try to find the element by name or id
795 const errorElement = document.querySelector(
796 `[name="${firstErrorKey}"], #${firstErrorKey}, [data-field="${firstErrorKey}"]`,
797 );
798 if (errorElement) {
799 errorElement.scrollIntoView({ behavior: "smooth", block: "center" });
800 // Focus the element if it's an input
801 if (
802 errorElement instanceof HTMLInputElement ||
803 errorElement instanceof HTMLSelectElement ||
804 errorElement instanceof HTMLTextAreaElement
805 ) {
806 setTimeout(() => errorElement.focus(), 300);
807 }
808 } else {
809 // Fallback: scroll to the first error message
810 const errorMessage = document.querySelector(
811 ".text-red-500, .text-red-600",
812 );
813 if (errorMessage) {
814 errorMessage.scrollIntoView({ behavior: "smooth", block: "center" });
815 }
816 }
817 }
818
819 return Object.keys(newErrors).length === 0;
820 };
821
822 // Create/Update mutation
823 const saveMutation = useMutation({
824 mutationFn: async (data: DiscountFormData) => {
825 const payload: any = {
826 code: data.code.trim().toUpperCase(),
827 description: data.description.trim(),
828 type: data.type,
829 amount: parseFloat(data.amount) || 0,
830 max_discount_amount: data.max_discount_amount
831 ? parseFloat(data.max_discount_amount)
832 : null,
833 usage_limit: parseInt(data.usage_limit) || 0,
834 usage_limit_per_customer: parseInt(data.usage_limit_per_customer) || 0,
835 valid_from: data.valid_from || null,
836 expiry_date: data.expiry_date || null,
837 status: data.status,
838 applicable_to: data.applicable_to,
839 trip_ids: data.applicable_to === "specific_trips" ? data.trip_ids : [],
840 min_amount: data.min_amount ? parseFloat(data.min_amount) : null,
841 first_time_customer_only: data.first_time_customer_only,
842 is_group_discount: data.is_group_discount,
843 discount_mode: data.discount_mode,
844 group_discount_mode: data.is_group_discount
845 ? data.group_discount_mode
846 : null,
847 group_discount_ranges: data.is_group_discount
848 ? data.group_discount_ranges
849 : [],
850 category_discounts:
851 data.is_group_discount &&
852 data.group_discount_mode === "category_based"
853 ? data.category_discounts.map((cat) => ({
854 traveler_category_id: cat.traveler_category_id,
855 traveler_category_label: cat.traveler_category_label,
856 ranges: cat.ranges.map((range) => ({
857 id: range.id,
858 min_group_size: range.min_group_size,
859 max_group_size: range.max_group_size,
860 discount_type: range.discount_type,
861 discount_amount: range.discount_amount,
862 })),
863 }))
864 : [],
865 };
866
867 if (isEditMode && discountId) {
868 return await apiClient.put(`/discounts/${discountId}`, payload);
869 } else {
870 return await apiClient.post("/discounts", payload);
871 }
872 },
873 onSuccess: () => {
874 queryClient.invalidateQueries({ queryKey: ["discounts"] });
875 queryClient.invalidateQueries({
876 queryKey: ["discount", discountId || duplicateId],
877 });
878 showToast(
879 isEditMode
880 ? __("Discount updated successfully", "yatra")
881 : __("Discount created successfully", "yatra"),
882 "success",
883 );
884 // Only redirect to listing page on create, not on update
885 if (!isEditMode) {
886 setTimeout(() => {
887 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=discounts`;
888 }, 1000);
889 }
890 setIsSubmitting(false);
891 },
892 onError: (error: any) => {
893 const errorMessage =
894 error?.message ||
895 __("An error occurred while saving the discount", "yatra");
896 showToast(errorMessage, "error");
897 setIsSubmitting(false);
898 },
899 });
900
901 const handleSubmit = async (e: React.FormEvent) => {
902 e.preventDefault();
903
904 if (!validateForm()) {
905 return;
906 }
907
908 setIsSubmitting(true);
909 try {
910 await saveMutation.mutateAsync(formData);
911 } catch (error) {
912 // Error handled in mutation
913 } finally {
914 setIsSubmitting(false);
915 }
916 };
917
918 const handleBack = () => {
919 window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=discounts`;
920 };
921
922 if (isLoadingDiscount) {
923 return (
924 <div className="space-y-3">
925 {/* Skeleton Header */}
926 <div className="flex items-center justify-between">
927 <div className="space-y-2">
928 <div className="h-8 w-64 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
929 <div className="h-4 w-48 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
930 </div>
931 <div className="h-10 w-24 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
932 </div>
933
934 {/* Skeleton Info Card */}
935 <Card className="bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800">
936 <CardContent className="p-4">
937 <div className="flex items-start gap-3">
938 <div className="w-5 h-5 bg-blue-200 dark:bg-blue-700 rounded animate-pulse" />
939 <div className="flex-1 space-y-2">
940 <div className="h-4 w-40 bg-blue-200 dark:bg-blue-700 rounded animate-pulse" />
941 <div className="space-y-1">
942 <div className="h-3 w-full bg-blue-100 dark:bg-blue-800 rounded animate-pulse" />
943 <div className="h-3 w-3/4 bg-blue-100 dark:bg-blue-800 rounded animate-pulse" />
944 </div>
945 </div>
946 </div>
947 </CardContent>
948 </Card>
949
950 <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
951 {/* Main Form Skeleton */}
952 <div className="lg:col-span-2 space-y-3">
953 {/* Basic Information Card Skeleton */}
954 <Card>
955 <CardHeader className="pb-2">
956 <div className="h-5 w-40 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
957 </CardHeader>
958 <CardContent className="space-y-4">
959 {/* Coupon Code */}
960 <div className="space-y-2">
961 <div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
962 <div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
963 </div>
964 {/* Description */}
965 <div className="space-y-2">
966 <div className="h-4 w-20 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
967 <div className="h-20 w-full bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
968 </div>
969 {/* Type and Amount */}
970 <div className="grid grid-cols-2 gap-3">
971 <div className="space-y-2">
972 <div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
973 <div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
974 </div>
975 <div className="space-y-2">
976 <div className="h-4 w-28 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
977 <div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
978 </div>
979 </div>
980 </CardContent>
981 </Card>
982
983 {/* Usage Limits Card Skeleton */}
984 <Card>
985 <CardHeader className="pb-2">
986 <div className="h-5 w-32 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
987 </CardHeader>
988 <CardContent className="space-y-4">
989 <div className="grid grid-cols-2 gap-3">
990 <div className="space-y-2">
991 <div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
992 <div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
993 </div>
994 <div className="space-y-2">
995 <div className="h-4 w-32 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
996 <div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
997 </div>
998 </div>
999 {/* Dates */}
1000 <div className="grid grid-cols-2 gap-3">
1001 <div className="space-y-2">
1002 <div className="h-4 w-20 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
1003 <div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
1004 </div>
1005 <div className="space-y-2">
1006 <div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
1007 <div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
1008 </div>
1009 </div>
1010 </CardContent>
1011 </Card>
1012
1013 {/* Group Discount Card Skeleton */}
1014 <Card>
1015 <CardHeader className="pb-2">
1016 <div className="h-5 w-44 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
1017 </CardHeader>
1018 <CardContent className="space-y-4">
1019 <div className="flex items-center gap-3">
1020 <div className="h-5 w-5 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
1021 <div className="h-4 w-40 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
1022 </div>
1023 </CardContent>
1024 </Card>
1025 </div>
1026
1027 {/* Sidebar Skeleton */}
1028 <div className="space-y-3">
1029 {/* Status Card */}
1030 <Card>
1031 <CardHeader className="pb-2">
1032 <div className="h-5 w-16 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
1033 </CardHeader>
1034 <CardContent>
1035 <div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
1036 </CardContent>
1037 </Card>
1038
1039 {/* Submit Button */}
1040 <Card>
1041 <CardContent className="p-3">
1042 <div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
1043 </CardContent>
1044 </Card>
1045 </div>
1046 </div>
1047 </div>
1048 );
1049 }
1050
1051 return (
1052 <div className="space-y-3">
1053 <PageHeader
1054 title={
1055 isEditMode
1056 ? __("Edit Discount Coupon", "yatra")
1057 : __("Add New Discount Coupon", "yatra")
1058 }
1059 description={
1060 isEditMode
1061 ? __("Update discount coupon details", "yatra")
1062 : __("Create a new discount coupon for your trips", "yatra")
1063 }
1064 actions={
1065 <Button
1066 variant="outline"
1067 onClick={handleBack}
1068 className="flex items-center gap-2"
1069 >
1070 <ArrowLeft className="w-4 h-4" />
1071 {__("Back", "yatra")}
1072 </Button>
1073 }
1074 />
1075
1076 <ConditionalRender capability="yatra_manage_discounts">
1077 {/* Discount Information Card */}
1078 <Card className="bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800">
1079 <CardContent className="p-4">
1080 <div className="flex items-start gap-3">
1081 <Info className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
1082 <div className="flex-1">
1083 <h4 className="text-sm font-semibold text-blue-900 dark:text-blue-100 mb-2">
1084 {__("How Discounts Work", "yatra")}
1085 </h4>
1086 <ul className="text-xs text-blue-800 dark:text-blue-200 space-y-1.5">
1087 <li>
1088 • <strong>{__("Regular Discount", "yatra")}:</strong>{" "}
1089 {__(
1090 "Applied first to the booking total (trip price × travelers)",
1091 "yatra",
1092 )}
1093 </li>
1094 <li>
1095 • <strong>{__("Group Discount", "yatra")}:</strong>{" "}
1096 {__(
1097 "Additional discount applied AFTER regular discount when group size requirement is met",
1098 "yatra",
1099 )}
1100 </li>
1101 <li>
1102 • <strong>{__("Maximum Cap", "yatra")}:</strong>{" "}
1103 {__(
1104 "For percentage discounts, you can set a maximum dollar amount to cap the discount",
1105 "yatra",
1106 )}
1107 </li>
1108 <li>
1109 • <strong>{__("Example", "yatra")}:</strong>{" "}
1110 {__(
1111 "$1000 booking, 15% regular discount ($150 off), then 10% group discount on $850 = $85 more off. Final: $765",
1112 "yatra",
1113 )}
1114 </li>
1115 </ul>
1116 </div>
1117 </div>
1118 </CardContent>
1119 </Card>
1120
1121 <form onSubmit={handleSubmit}>
1122 <div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
1123 {/* Main Form Fields */}
1124 <div className="lg:col-span-2 space-y-3">
1125 {/* Discount Mode Indicator - Show on both Create and Edit */}
1126 {(() => {
1127 // Determine mode: use URL param for new discounts, formData for existing
1128 const currentMode = isEditMode
1129 ? formData.discount_mode
1130 : discountMode;
1131 const isGroupOnly = currentMode === "group";
1132 const isPromoOnly = currentMode === "promo";
1133
1134 return (
1135 <div
1136 className={`p-3 rounded-lg border-2 mb-3 ${
1137 isGroupOnly
1138 ? "bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800"
1139 : isPromoOnly
1140 ? "bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800"
1141 : "bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800"
1142 }`}
1143 >
1144 <div className="flex items-center gap-2">
1145 {isGroupOnly ? (
1146 <Users className="w-5 h-5 text-green-600 dark:text-green-400" />
1147 ) : isPromoOnly ? (
1148 <Info className="w-5 h-5 text-blue-600 dark:text-blue-400" />
1149 ) : (
1150 <Info className="w-5 h-5 text-purple-600 dark:text-purple-400" />
1151 )}
1152 <span
1153 className={`font-medium ${
1154 isGroupOnly
1155 ? "text-green-700 dark:text-green-300"
1156 : isPromoOnly
1157 ? "text-blue-700 dark:text-blue-300"
1158 : "text-purple-700 dark:text-purple-300"
1159 }`}
1160 >
1161 {isGroupOnly
1162 ? isEditMode
1163 ? __(
1164 "Group Discount (Auto-applies, no code needed)",
1165 "yatra",
1166 )
1167 : __(
1168 "Creating Group Discount (Auto-applies, no code needed)",
1169 "yatra",
1170 )
1171 : isPromoOnly
1172 ? isEditMode
1173 ? __("Promo Code Discount", "yatra")
1174 : __("Creating Promo Code Discount", "yatra")
1175 : isEditMode
1176 ? __("Promo Code + Group Discount", "yatra")
1177 : __(
1178 "Creating Promo Code + Group Discount",
1179 "yatra",
1180 )}
1181 </span>
1182 </div>
1183 </div>
1184 );
1185 })()}
1186
1187 {/* Basic Information */}
1188 <Card>
1189 <CardHeader className="pb-2">
1190 <CardTitle className="text-base">
1191 {isEffectiveGroupOnly
1192 ? __("Group Discount Information", "yatra")
1193 : __("Basic Information", "yatra")}
1194 </CardTitle>
1195 </CardHeader>
1196 <CardContent className="space-y-3">
1197 {/* Coupon Code - Only show for promo modes */}
1198 {!isEffectiveGroupOnly && (
1199 <div>
1200 <label
1201 htmlFor="code"
1202 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
1203 >
1204 {__("Coupon Code", "yatra")}{" "}
1205 <span className="text-red-500">*</span>
1206 </label>
1207 <HelpText
1208 text={__(
1209 "Enter a unique coupon code. Only uppercase letters, numbers, hyphens, and underscores are allowed.",
1210 "yatra",
1211 )}
1212 className="mb-2"
1213 />
1214 <Input
1215 id="code"
1216 name="code"
1217 type="text"
1218 value={formData.code}
1219 onChange={(e) =>
1220 handleFieldChange(
1221 "code",
1222 e.target.value.toUpperCase(),
1223 )
1224 }
1225 placeholder={__("e.g., SUMMER2024", "yatra")}
1226 className={errors.code ? "border-red-500" : ""}
1227 required
1228 />
1229 {errors.code && (
1230 <p className="mt-1.5 text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
1231 <Info className="w-4 h-4" />
1232 {errors.code}
1233 </p>
1234 )}
1235 </div>
1236 )}
1237
1238 {/* Internal Name for Group Discount Only */}
1239 {isEffectiveGroupOnly && (
1240 <div>
1241 <label
1242 htmlFor="code"
1243 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
1244 >
1245 {__("Internal Name", "yatra")}{" "}
1246 <span className="text-red-500">*</span>
1247 </label>
1248 <HelpText
1249 text={__(
1250 "Enter a unique internal name for this group discount. This is for your reference only and won't be shown to customers.",
1251 "Enter a unique internal name for this group discount. This is for your reference only and won't be shown to customers.",
1252 )}
1253 className="mb-2"
1254 />
1255 <Input
1256 id="code"
1257 name="code"
1258 type="text"
1259 value={formData.code}
1260 onChange={(e) =>
1261 handleFieldChange(
1262 "code",
1263 e.target.value.toUpperCase().replace(/\s+/g, "_"),
1264 )
1265 }
1266 placeholder={__("e.g., GROUP_DISCOUNT_2024", "yatra")}
1267 className={errors.code ? "border-red-500" : ""}
1268 required
1269 />
1270 {errors.code && (
1271 <p className="mt-1.5 text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
1272 <Info className="w-4 h-4" />
1273 {errors.code}
1274 </p>
1275 )}
1276 </div>
1277 )}
1278
1279 {/* Description */}
1280 <div>
1281 <label
1282 htmlFor="description"
1283 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
1284 >
1285 {__("Description", "yatra")}
1286 </label>
1287 <HelpText
1288 text={__(
1289 "Optional description for this discount coupon. This helps you identify the purpose of the coupon.",
1290 "yatra",
1291 )}
1292 className="mb-2"
1293 />
1294 <textarea
1295 id="description"
1296 value={formData.description}
1297 onChange={(e) =>
1298 handleFieldChange("description", e.target.value)
1299 }
1300 placeholder={__(
1301 "e.g., Summer discount for all trips",
1302 "yatra",
1303 )}
1304 rows={3}
1305 className="flex w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:ring-offset-gray-900 dark:placeholder:text-gray-400 dark:focus-visible:ring-blue-400 resize-none"
1306 />
1307 </div>
1308
1309 {/* Discount Type and Amount - Only show for promo modes */}
1310 {!isEffectiveGroupOnly && (
1311 <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
1312 {/* Discount Type */}
1313 <div>
1314 <label
1315 htmlFor="type"
1316 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
1317 >
1318 {__("Discount Type", "yatra")}{" "}
1319 <span className="text-red-500">*</span>
1320 </label>
1321 <Select
1322 id="type"
1323 name="type"
1324 value={formData.type}
1325 onChange={(e) =>
1326 handleFieldChange(
1327 "type",
1328 e.target.value as "percentage" | "fixed",
1329 )
1330 }
1331 className={errors.type ? "border-red-500" : ""}
1332 required
1333 >
1334 <option value="percentage">
1335 {__("Percentage", "yatra")}
1336 </option>
1337 <option value="fixed">
1338 {__("Fixed Amount", "yatra")}
1339 </option>
1340 </Select>
1341 {errors.type && (
1342 <p className="mt-1.5 text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
1343 <Info className="w-4 h-4" />
1344 {errors.type}
1345 </p>
1346 )}
1347 </div>
1348
1349 {/* Discount Amount */}
1350 <div>
1351 <label
1352 htmlFor="amount"
1353 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
1354 >
1355 {__("Discount Amount", "yatra")}{" "}
1356 <span className="text-red-500">*</span>
1357 </label>
1358 <div className="relative">
1359 {formData.type === "percentage" ? (
1360 <Input
1361 id="amount"
1362 name="amount"
1363 type="number"
1364 min="0"
1365 max="100"
1366 step="0.01"
1367 value={formData.amount}
1368 onChange={(e) =>
1369 handleFieldChange("amount", e.target.value)
1370 }
1371 placeholder={__("e.g., 15", "yatra")}
1372 className={errors.amount ? "border-red-500" : ""}
1373 required
1374 />
1375 ) : (
1376 <div className="relative">
1377 <span className={`absolute ${currencyOnRight ? "right-3" : "left-3"} top-1/2 transform -translate-y-1/2 text-gray-500 text-sm`}>
1378 {currencySymbol} </span>
1379 <Input
1380 id="amount"
1381 name="amount"
1382 type="number"
1383 min="0"
1384 step="0.01"
1385 value={formData.amount}
1386 onChange={(e) =>
1387 handleFieldChange("amount", e.target.value)
1388 }
1389 placeholder={__("e.g., 50", "yatra")}
1390 className={`${currencyPad} ${errors.amount ? "border-red-500" : ""}`}
1391 required
1392 />
1393 </div>
1394 )}
1395 {formData.type === "percentage" && (
1396 <span className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500 text-sm">
1397 %
1398 </span>
1399 )}
1400 </div>
1401 {errors.amount && (
1402 <p className="mt-1.5 text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
1403 <Info className="w-4 h-4" />
1404 {errors.amount}
1405 </p>
1406 )}
1407 </div>
1408 </div>
1409 )}
1410
1411 {/* Maximum Discount Amount (for percentage discounts) - Only show for promo modes */}
1412 {!isEffectiveGroupOnly && formData.type === "percentage" && (
1413 <div>
1414 <label
1415 htmlFor="max_discount_amount"
1416 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
1417 >
1418 {__("Maximum Discount Amount", "yatra")}
1419 </label>
1420 <HelpText
1421 text={__(
1422 "Optional maximum discount cap. For example, if discount is 20% with max $500, a $5000 booking gets $500 off (not $1000).",
1423 "yatra",
1424 )}
1425 className="mb-2"
1426 />
1427 <div className="relative">
1428 <span className={`absolute ${currencyOnRight ? "right-3" : "left-3"} top-1/2 transform -translate-y-1/2 text-gray-500 text-sm`}>
1429 {currencySymbol} </span>
1430 <Input
1431 id="max_discount_amount"
1432 type="number"
1433 min="0"
1434 step="0.01"
1435 value={formData.max_discount_amount}
1436 onChange={(e) =>
1437 handleFieldChange(
1438 "max_discount_amount",
1439 e.target.value,
1440 )
1441 }
1442 placeholder={__("e.g., 500 (optional)", "yatra")}
1443 className={`${currencyPad} ${errors.max_discount_amount ? "border-red-500" : ""}`}
1444 />
1445 </div>
1446 {errors.max_discount_amount && (
1447 <p className="mt-1.5 text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
1448 <Info className="w-4 h-4" />
1449 {errors.max_discount_amount}
1450 </p>
1451 )}
1452 </div>
1453 )}
1454 </CardContent>
1455 </Card>
1456
1457 {/* Usage & Restrictions */}
1458 <Card>
1459 <CardHeader className="pb-2">
1460 <CardTitle className="text-base">
1461 {__("Usage & Restrictions", "yatra")}
1462 </CardTitle>
1463 </CardHeader>
1464 <CardContent className="space-y-3">
1465 {/* Usage Limit (Total) */}
1466 <div>
1467 <label
1468 htmlFor="usage_limit"
1469 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
1470 >
1471 {__("Total Usage Limit", "yatra")}
1472 </label>
1473 <HelpText
1474 text={__(
1475 "Maximum number of times this coupon can be used across all customers. Enter 0 for unlimited usage.",
1476 "yatra",
1477 )}
1478 className="mb-2"
1479 />
1480 <Input
1481 id="usage_limit"
1482 type="number"
1483 min="0"
1484 value={formData.usage_limit}
1485 onChange={(e) =>
1486 handleFieldChange("usage_limit", e.target.value)
1487 }
1488 placeholder={__("0 for unlimited", "yatra")}
1489 className={errors.usage_limit ? "border-red-500" : ""}
1490 />
1491 {errors.usage_limit && (
1492 <p className="mt-1.5 text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
1493 <Info className="w-4 h-4" />
1494 {errors.usage_limit}
1495 </p>
1496 )}
1497 </div>
1498
1499 {/* Usage Limit Per Customer */}
1500 <div>
1501 <label
1502 htmlFor="usage_limit_per_customer"
1503 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
1504 >
1505 {__("Usage Limit Per Customer", "yatra")}
1506 </label>
1507 <HelpText
1508 text={__(
1509 "Maximum number of times a single customer can use this coupon. Enter 0 for unlimited usage per customer.",
1510 "yatra",
1511 )}
1512 className="mb-2"
1513 />
1514 <Input
1515 id="usage_limit_per_customer"
1516 type="number"
1517 min="0"
1518 value={formData.usage_limit_per_customer}
1519 onChange={(e) =>
1520 handleFieldChange(
1521 "usage_limit_per_customer",
1522 e.target.value,
1523 )
1524 }
1525 placeholder={__("0 for unlimited", "yatra")}
1526 className={
1527 errors.usage_limit_per_customer ? "border-red-500" : ""
1528 }
1529 />
1530 {errors.usage_limit_per_customer && (
1531 <p className="mt-1.5 text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
1532 <Info className="w-4 h-4" />
1533 {errors.usage_limit_per_customer}
1534 </p>
1535 )}
1536 </div>
1537
1538 {/* First-Time Customer Only */}
1539 <div>
1540 <label className="flex items-center gap-2 cursor-pointer">
1541 <input
1542 type="checkbox"
1543 checked={formData.first_time_customer_only}
1544 onChange={(e) =>
1545 handleFieldChange(
1546 "first_time_customer_only",
1547 e.target.checked,
1548 )
1549 }
1550 className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
1551 />
1552 <span className="text-sm font-medium text-gray-700 dark:text-gray-300">
1553 {__("First-Time Customers Only", "yatra")}
1554 </span>
1555 </label>
1556 <HelpText
1557 text={__(
1558 "If enabled, only customers who have never made a booking before can use this coupon.",
1559 "yatra",
1560 )}
1561 className="mb-2 mt-1"
1562 />
1563 </div>
1564
1565 {/* Minimum Amount */}
1566 <div>
1567 <label
1568 htmlFor="min_amount"
1569 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
1570 >
1571 {__("Minimum Booking Amount", "yatra")}
1572 </label>
1573 <HelpText
1574 text={__(
1575 "Minimum total booking amount required to use this coupon. This applies to the total booking value (trip price × number of travelers).",
1576 "yatra",
1577 )}
1578 className="mb-2"
1579 />
1580 <div className="relative">
1581 <span className={`absolute ${currencyOnRight ? "right-3" : "left-3"} top-1/2 transform -translate-y-1/2 text-gray-500 text-sm`}>
1582 {currencySymbol} </span>
1583 <Input
1584 id="min_amount"
1585 type="number"
1586 min="0"
1587 step="0.01"
1588 value={formData.min_amount}
1589 onChange={(e) =>
1590 handleFieldChange("min_amount", e.target.value)
1591 }
1592 placeholder={__("e.g., 100", "yatra")}
1593 className={`${currencyPad} ${errors.min_amount ? "border-red-500" : ""}`}
1594 />
1595 </div>
1596 {errors.min_amount && (
1597 <p className="mt-1.5 text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
1598 <Info className="w-4 h-4" />
1599 {errors.min_amount}
1600 </p>
1601 )}
1602 </div>
1603
1604 {/* Valid From Date and Expiry Date - Same Line */}
1605 <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
1606 {/* Valid From Date */}
1607 <div>
1608 <label
1609 htmlFor="valid_from"
1610 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
1611 >
1612 {__("Valid From", "yatra")}
1613 </label>
1614 <DatePicker
1615 value={formData.valid_from}
1616 onChange={(value) =>
1617 handleFieldChange("valid_from", value)
1618 }
1619 placeholder={__("Select start date", "yatra")}
1620 error={!!errors.valid_from}
1621 className={errors.valid_from ? "border-red-500" : ""}
1622 />
1623 {errors.valid_from && (
1624 <p className="mt-1.5 text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
1625 <Info className="w-4 h-4" />
1626 {errors.valid_from}
1627 </p>
1628 )}
1629 </div>
1630
1631 {/* Expiry Date */}
1632 <div>
1633 <label
1634 htmlFor="expiry_date"
1635 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
1636 >
1637 {__("Expiry Date", "yatra")}
1638 </label>
1639 <DatePicker
1640 value={formData.expiry_date}
1641 onChange={(value) =>
1642 handleFieldChange("expiry_date", value)
1643 }
1644 placeholder={__("Select expiry date", "yatra")}
1645 minDate={
1646 formData.valid_from
1647 ? toDateValue(formData.valid_from)
1648 : new Date()
1649 }
1650 error={!!errors.expiry_date}
1651 className={errors.expiry_date ? "border-red-500" : ""}
1652 />
1653 {errors.expiry_date && (
1654 <p className="mt-1.5 text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
1655 <Info className="w-4 h-4" />
1656 {errors.expiry_date}
1657 </p>
1658 )}
1659 </div>
1660 </div>
1661 </CardContent>
1662 </Card>
1663
1664 {/* Group Discount - Hide for promo-only mode */}
1665 {!isEffectivePromoOnly && (
1666 <Card>
1667 <CardHeader className="pb-2">
1668 <CardTitle className="text-base">
1669 {__("Group Discount Settings", "yatra")}
1670 </CardTitle>
1671 </CardHeader>
1672 <CardContent className="space-y-3">
1673 {/* Enable Group Discount - Hide checkbox for group-only mode (already enabled) */}
1674 {!isEffectiveGroupOnly && (
1675 <div>
1676 <label className="flex items-center gap-2 cursor-pointer">
1677 <input
1678 type="checkbox"
1679 checked={formData.is_group_discount}
1680 onChange={(e) =>
1681 handleGroupDiscountToggle(e.target.checked)
1682 }
1683 className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
1684 />
1685 <span className="text-sm font-medium text-gray-700 dark:text-gray-300">
1686 {__("Enable Group Discount", "yatra")}
1687 </span>
1688 </label>
1689 <HelpText
1690 text={__(
1691 "Apply discounts for group bookings based on traveler count.",
1692 "yatra",
1693 )}
1694 className="mb-2 mt-1"
1695 />
1696 </div>
1697 )}
1698
1699 {formData.is_group_discount && (
1700 <>
1701 {/* Group Discount Mode */}
1702 <div>
1703 <label
1704 htmlFor="group_discount_mode"
1705 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
1706 >
1707 {__("Discount Mode", "yatra")}{" "}
1708 <span className="text-red-500">*</span>
1709 </label>
1710 <HelpText
1711 text={__(
1712 "Choose how to apply the group discount: Total discount on entire booking, or category-based rates per traveler type.",
1713 "yatra",
1714 )}
1715 className="mb-2"
1716 />
1717 <Select
1718 id="group_discount_mode"
1719 value={formData.group_discount_mode}
1720 onChange={(e) =>
1721 handleFieldChange(
1722 "group_discount_mode",
1723 e.target.value as "total" | "category_based",
1724 )
1725 }
1726 className={
1727 errors.group_discount_mode ? "border-red-500" : ""
1728 }
1729 required={formData.is_group_discount}
1730 >
1731 <option value="total">
1732 {__("Total Discount", "yatra")}
1733 </option>
1734 <option value="category_based">
1735 {__("Category-Based Discount", "yatra")}
1736 </option>
1737 </Select>
1738 {errors.group_discount_mode && (
1739 <p className="mt-1.5 text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
1740 <Info className="w-4 h-4" />
1741 {errors.group_discount_mode}
1742 </p>
1743 )}
1744 </div>
1745
1746 {/* Ranges - Only show for Total Discount mode */}
1747 {formData.group_discount_mode === "total" && (
1748 <>
1749 <div className="flex items-center justify-between">
1750 <div>
1751 <h4 className="text-sm font-semibold text-gray-900 dark:text-white">
1752 {__("Group Size Ranges", "yatra")}
1753 </h4>
1754 <p className="text-xs text-gray-500 dark:text-gray-400">
1755 {__(
1756 "Add one or more ranges (e.g., 1-10, 10-12, 12-15, 15+). The first matching range will be applied.",
1757 "yatra",
1758 )}
1759 </p>
1760 </div>
1761 <Button
1762 type="button"
1763 variant="outline"
1764 onClick={addRange}
1765 className="flex items-center gap-2"
1766 >
1767 <Plus className="w-4 h-4" />
1768 {__("Add Range", "yatra")}
1769 </Button>
1770 </div>
1771
1772 <div className="space-y-3">
1773 {formData.group_discount_ranges.map(
1774 (range, idx) => (
1775 <Card
1776 key={range.id}
1777 className="border border-gray-200 dark:border-gray-700"
1778 >
1779 <CardContent className="p-4 space-y-3">
1780 <div className="flex items-start justify-between gap-3">
1781 <div className="text-sm font-semibold text-gray-900 dark:text-white">
1782 {__("Range", "yatra")} #{idx + 1}
1783 </div>
1784 <Button
1785 type="button"
1786 variant="outline"
1787 onClick={() => removeRange(range.id)}
1788 disabled={
1789 formData.group_discount_ranges
1790 .length === 1
1791 }
1792 className="flex items-center gap-2"
1793 >
1794 <Trash2 className="w-4 h-4" />
1795 {__("Remove", "yatra")}
1796 </Button>
1797 </div>
1798
1799 <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
1800 <div>
1801 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
1802 {__("Minimum Group Size", "yatra")}{" "}
1803 <span className="text-red-500">
1804 *
1805 </span>
1806 </label>
1807 <Input
1808 type="number"
1809 min="1"
1810 value={range.min_group_size}
1811 onChange={(e) =>
1812 updateRange(range.id, {
1813 min_group_size: e.target.value,
1814 })
1815 }
1816 placeholder={__(
1817 "e.g., 10",
1818 "yatra",
1819 )}
1820 className={
1821 errors[
1822 `group_discount_ranges_${idx}_min`
1823 ]
1824 ? "border-red-500"
1825 : ""
1826 }
1827 />
1828 {errors[
1829 `group_discount_ranges_${idx}_min`
1830 ] && (
1831 <p className="mt-1.5 text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
1832 <Info className="w-4 h-4" />
1833 {
1834 errors[
1835 `group_discount_ranges_${idx}_min`
1836 ]
1837 }
1838 </p>
1839 )}
1840 </div>
1841
1842 <div>
1843 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
1844 {__("Maximum Group Size", "yatra")}
1845 </label>
1846 <Input
1847 type="number"
1848 min={
1849 range.min_group_size
1850 ? parseInt(
1851 range.min_group_size,
1852 10,
1853 ) + 1
1854 : 2
1855 }
1856 value={range.max_group_size}
1857 onChange={(e) =>
1858 updateRange(range.id, {
1859 max_group_size: e.target.value,
1860 })
1861 }
1862 placeholder={__(
1863 "Leave empty for unlimited",
1864 "yatra",
1865 )}
1866 className={
1867 errors[
1868 `group_discount_ranges_${idx}_max`
1869 ]
1870 ? "border-red-500"
1871 : ""
1872 }
1873 />
1874 {errors[
1875 `group_discount_ranges_${idx}_max`
1876 ] && (
1877 <p className="mt-1.5 text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
1878 <Info className="w-4 h-4" />
1879 {
1880 errors[
1881 `group_discount_ranges_${idx}_max`
1882 ]
1883 }
1884 </p>
1885 )}
1886 </div>
1887 </div>
1888
1889 {formData.group_discount_mode ===
1890 "total" && (
1891 <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
1892 <div>
1893 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
1894 {__("Discount Type", "yatra")}{" "}
1895 <span className="text-red-500">
1896 *
1897 </span>
1898 </label>
1899 <Select
1900 value={
1901 range.discount_type ||
1902 "percentage"
1903 }
1904 onChange={(e) =>
1905 updateRange(range.id, {
1906 discount_type: e.target
1907 .value as
1908 | "percentage"
1909 | "fixed",
1910 })
1911 }
1912 className={
1913 errors[
1914 `group_discount_ranges_${idx}_type`
1915 ]
1916 ? "border-red-500"
1917 : ""
1918 }
1919 >
1920 <option value="percentage">
1921 {__("Percentage", "yatra")}
1922 </option>
1923 <option value="fixed">
1924 {__("Fixed Amount", "yatra")}
1925 </option>
1926 <option value="percentage_of_total">
1927 {__(
1928 "Percentage of Total",
1929 "yatra",
1930 )}
1931 </option>
1932 </Select>
1933 {errors[
1934 `group_discount_ranges_${idx}_type`
1935 ] && (
1936 <p className="mt-1.5 text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
1937 <Info className="w-4 h-4" />
1938 {
1939 errors[
1940 `group_discount_ranges_${idx}_type`
1941 ]
1942 }
1943 </p>
1944 )}
1945 </div>
1946
1947 <div>
1948 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
1949 {__("Discount Amount", "yatra")}{" "}
1950 <span className="text-red-500">
1951 *
1952 </span>
1953 </label>
1954 <div className="relative">
1955 {range.discount_type ===
1956 "fixed" && (
1957 <span className={`absolute ${currencyOnRight ? "right-3" : "left-3"} top-1/2 transform -translate-y-1/2 text-gray-500 text-sm`}>
1958 {currencySymbol} </span>
1959 )}
1960 <Input
1961 type="number"
1962 min="0"
1963 max={
1964 range.discount_type ===
1965 "percentage"
1966 ? 100
1967 : undefined
1968 }
1969 step="0.01"
1970 value={
1971 range.discount_amount || ""
1972 }
1973 onChange={(e) =>
1974 updateRange(range.id, {
1975 discount_amount:
1976 e.target.value,
1977 })
1978 }
1979 placeholder={
1980 range.discount_type ===
1981 "percentage"
1982 ? __("e.g., 10", "yatra")
1983 : __("e.g., 50", "yatra")
1984 }
1985 className={`${range.discount_type === "fixed" ? currencyPad : ""} ${errors[`group_discount_ranges_${idx}_amount`] ? "border-red-500" : ""}`}
1986 />
1987 {range.discount_type ===
1988 "percentage" && (
1989 <span className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500 text-sm">
1990 %
1991 </span>
1992 )}
1993 </div>
1994 {errors[
1995 `group_discount_ranges_${idx}_amount`
1996 ] && (
1997 <p className="mt-1.5 text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
1998 <Info className="w-4 h-4" />
1999 {
2000 errors[
2001 `group_discount_ranges_${idx}_amount`
2002 ]
2003 }
2004 </p>
2005 )}
2006 </div>
2007 </div>
2008 )}
2009 </CardContent>
2010 </Card>
2011 ),
2012 )}
2013 </div>
2014 </>
2015 )}
2016
2017 {formData.group_discount_mode === "category_based" && (
2018 <div className="space-y-4">
2019 <div className="flex items-center justify-between">
2020 <div>
2021 <h4 className="text-sm font-semibold text-gray-900 dark:text-white">
2022 {__("Traveler Categories", "yatra")}
2023 </h4>
2024 <p className="text-xs text-gray-500 dark:text-gray-400">
2025 {__(
2026 "Add traveler categories and define discount ranges for each category.",
2027 "yatra",
2028 )}
2029 </p>
2030 </div>
2031 <div className="relative">
2032 <Button
2033 type="button"
2034 onClick={addTravelerCategory}
2035 className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
2036 >
2037 <Plus className="w-4 h-4" />
2038 {__("Add Category", "yatra")}
2039 </Button>
2040
2041 {/* Category Selection Dropdown */}
2042 {showCategoryDropdown && (
2043 <div className="absolute right-0 top-full mt-2 z-50 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 shadow-lg min-w-[320px]">
2044 <div className="p-3 border-b border-gray-200 dark:border-gray-700">
2045 <p className="text-sm text-gray-600 dark:text-gray-400">
2046 {__(
2047 "Select a category to add pricing",
2048 "yatra",
2049 )}
2050 </p>
2051 </div>
2052
2053 {travelerCategoryOptions.length > 0 ? (
2054 <div className="divide-y divide-gray-100 dark:divide-gray-700 max-h-[300px] overflow-y-auto">
2055 {travelerCategoryOptions.map((cat) => (
2056 <div
2057 key={cat.value}
2058 onClick={() =>
2059 selectCategory(
2060 cat.value,
2061 cat.label,
2062 )
2063 }
2064 className="px-4 py-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
2065 >
2066 <div className="flex flex-col">
2067 <div className="flex items-center gap-2">
2068 <span className="font-semibold text-gray-900 dark:text-white">
2069 {cat.label}
2070 </span>
2071 <span className="text-sm text-gray-500 dark:text-gray-400">
2072 ({cat.age_min || "null"}-
2073 {cat.age_max || "null"}{" "}
2074 {__("years", "yatra")})
2075 </span>
2076 </div>
2077 <div className="text-sm text-gray-500 dark:text-gray-400">
2078 {cat.description && (
2079 <span>
2080 {cat.description} •{" "}
2081 </span>
2082 )}
2083 <span className="capitalize">
2084 {cat.pricing_mode?.replace(
2085 "_",
2086 " ",
2087 ) || "Per person"}
2088 </span>
2089 </div>
2090 </div>
2091 </div>
2092 ))}
2093 </div>
2094 ) : (
2095 <div className="p-4 text-center text-sm text-gray-500 dark:text-gray-400">
2096 {__(
2097 "No traveler categories available. Please create categories first.",
2098 "yatra",
2099 )}
2100 </div>
2101 )}
2102 </div>
2103 )}
2104 </div>
2105 </div>
2106
2107 {errors.category_discounts && (
2108 <p className="text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
2109 <Info className="w-4 h-4" />
2110 {errors.category_discounts}
2111 </p>
2112 )}
2113
2114 {formData.category_discounts.length === 0 ? (
2115 <div className="text-center py-12 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-800/50">
2116 <div className="flex flex-col items-center space-y-4">
2117 <div className="w-16 h-16 bg-gray-200 dark:bg-gray-700 rounded-full flex items-center justify-center">
2118 <Users className="w-8 h-8 text-gray-500 dark:text-gray-400" />
2119 </div>
2120 <div className="text-gray-500 dark:text-gray-400">
2121 <div className="text-lg font-medium mb-2">
2122 {__(
2123 "No traveler categories added yet",
2124 "yatra",
2125 )}
2126 </div>
2127 <div className="text-sm mb-4">
2128 {__(
2129 "Select traveler categories to apply group-specific discounts",
2130 "yatra",
2131 )}
2132 </div>
2133 </div>
2134 <Button
2135 type="button"
2136 onClick={addTravelerCategory}
2137 className="flex items-center gap-2 px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
2138 >
2139 <Plus className="w-4 h-4" />
2140 {__("Add Your First Category", "yatra")}
2141 </Button>
2142 </div>
2143 </div>
2144 ) : (
2145 <div className="space-y-4">
2146 {formData.category_discounts.map(
2147 (category, catIdx) => (
2148 <Card
2149 key={catIdx}
2150 className="border border-gray-200 dark:border-gray-700"
2151 >
2152 <CardContent className="p-4 space-y-3">
2153 <div className="flex items-start justify-between gap-3">
2154 <div className="flex-1">
2155 <div className="text-sm font-semibold text-gray-900 dark:text-white">
2156 {__("Traveler Category", "yatra")}{" "}
2157 #{catIdx + 1}
2158 </div>
2159 <div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
2160 {__(
2161 "Configure discount ranges for this traveler category.",
2162 "yatra",
2163 )}
2164 </div>
2165 </div>
2166 <Button
2167 type="button"
2168 variant="outline"
2169 onClick={() =>
2170 removeTravelerCategory(catIdx)
2171 }
2172 disabled={
2173 formData.category_discounts
2174 .length === 1
2175 }
2176 className="flex items-center gap-2"
2177 >
2178 <Trash2 className="w-4 h-4" />
2179 {__("Remove", "yatra")}
2180 </Button>
2181 </div>
2182
2183 <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
2184 <div>
2185 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
2186 {__(
2187 "Select Traveler Category",
2188 "yatra",
2189 )}{" "}
2190 <span className="text-red-500">
2191 *
2192 </span>
2193 </label>
2194 <Select
2195 value={
2196 category.traveler_category_id
2197 }
2198 onChange={(e) => {
2199 const selected =
2200 travelerCategoryOptions.find(
2201 (opt) =>
2202 opt.value ===
2203 e.target.value,
2204 );
2205 updateTravelerCategory(catIdx, {
2206 traveler_category_id:
2207 e.target.value,
2208 traveler_category_label:
2209 selected?.label || "",
2210 });
2211 }}
2212 className={
2213 errors[
2214 `category_discounts_${catIdx}_id`
2215 ]
2216 ? "border-red-500"
2217 : ""
2218 }
2219 >
2220 {travelerCategoryOptions.map(
2221 (opt) => (
2222 <option
2223 key={opt.value}
2224 value={opt.value}
2225 >
2226 {opt.label}
2227 </option>
2228 ),
2229 )}
2230 </Select>
2231 {errors[
2232 `category_discounts_${catIdx}_id`
2233 ] && (
2234 <p className="mt-1.5 text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
2235 <Info className="w-4 h-4" />
2236 {
2237 errors[
2238 `category_discounts_${catIdx}_id`
2239 ]
2240 }
2241 </p>
2242 )}
2243 </div>
2244 </div>
2245
2246 <div className="flex items-center justify-between">
2247 <div>
2248 <div className="text-sm font-medium text-gray-900 dark:text-white">
2249 {__("Discount Ranges", "yatra")}
2250 </div>
2251 <div className="text-xs text-gray-500 dark:text-gray-400">
2252 {__(
2253 "Add multiple group size ranges for this category (e.g., 1-10, 10-12, 12-15, 15+).",
2254 "yatra",
2255 )}
2256 </div>
2257 </div>
2258 <Button
2259 type="button"
2260 variant="outline"
2261 onClick={() =>
2262 addRangeToCategory(catIdx)
2263 }
2264 className="flex items-center gap-2"
2265 >
2266 <Plus className="w-4 h-4" />
2267 {__("Add Range", "yatra")}
2268 </Button>
2269 </div>
2270
2271 {errors[
2272 `category_discounts_${catIdx}_ranges`
2273 ] && (
2274 <p className="text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
2275 <Info className="w-4 h-4" />
2276 {
2277 errors[
2278 `category_discounts_${catIdx}_ranges`
2279 ]
2280 }
2281 </p>
2282 )}
2283
2284 {category.ranges.length === 0 ? (
2285 <div className="text-center py-6 border-2 border-dashed border-gray-200 dark:border-gray-600 rounded-lg">
2286 <div className="text-gray-500 dark:text-gray-400">
2287 <div className="text-sm font-medium mb-1">
2288 {__(
2289 "No discount ranges added yet",
2290 "yatra",
2291 )}
2292 </div>
2293 <div className="text-xs">
2294 {__(
2295 'Click "Add Range" to create discount tiers',
2296 "yatra",
2297 )}
2298 </div>
2299 </div>
2300 </div>
2301 ) : (
2302 <div className="space-y-3">
2303 {category.ranges.map(
2304 (range, rIdx) => (
2305 <div
2306 key={range.id}
2307 className="border border-gray-200 dark:border-gray-600 rounded-lg p-4 space-y-3 bg-gray-50 dark:bg-gray-800"
2308 >
2309 <div className="flex items-start justify-between gap-3">
2310 <div className="text-sm font-medium text-gray-900 dark:text-white">
2311 {__("Range", "yatra")} #
2312 {rIdx + 1}
2313 </div>
2314 <Button
2315 type="button"
2316 variant="outline"
2317 size="sm"
2318 onClick={() =>
2319 removeRangeFromCategory(
2320 catIdx,
2321 range.id,
2322 )
2323 }
2324 disabled={
2325 category.ranges
2326 .length === 1
2327 }
2328 className="flex items-center gap-1"
2329 >
2330 <Trash2 className="w-3 h-3" />
2331 {__("Remove", "yatra")}
2332 </Button>
2333 </div>
2334
2335 <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
2336 <div>
2337 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
2338 {__(
2339 "Minimum Group Size",
2340 "yatra",
2341 )}{" "}
2342 <span className="text-red-500">
2343 *
2344 </span>
2345 </label>
2346 <Input
2347 type="number"
2348 min="1"
2349 value={
2350 range.min_group_size
2351 }
2352 onChange={(e) =>
2353 updateRangeInCategory(
2354 catIdx,
2355 range.id,
2356 {
2357 min_group_size:
2358 e.target.value,
2359 },
2360 )
2361 }
2362 placeholder={__(
2363 "e.g., 10",
2364 "yatra",
2365 )}
2366 className={
2367 errors[
2368 `category_discounts_${catIdx}_ranges_${rIdx}_min`
2369 ]
2370 ? "border-red-500"
2371 : ""
2372 }
2373 />
2374 {errors[
2375 `category_discounts_${catIdx}_ranges_${rIdx}_min`
2376 ] && (
2377 <p className="mt-1.5 text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
2378 <Info className="w-4 h-4" />
2379 {
2380 errors[
2381 `category_discounts_${catIdx}_ranges_${rIdx}_min`
2382 ]
2383 }
2384 </p>
2385 )}
2386 </div>
2387
2388 <div>
2389 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
2390 {__(
2391 "Maximum Group Size",
2392 "yatra",
2393 )}
2394 </label>
2395 <Input
2396 type="number"
2397 min={
2398 range.min_group_size
2399 ? parseInt(
2400 range.min_group_size,
2401 10,
2402 ) + 1
2403 : 2
2404 }
2405 value={
2406 range.max_group_size
2407 }
2408 onChange={(e) =>
2409 updateRangeInCategory(
2410 catIdx,
2411 range.id,
2412 {
2413 max_group_size:
2414 e.target.value,
2415 },
2416 )
2417 }
2418 placeholder={__(
2419 "Leave empty for unlimited",
2420 "yatra",
2421 )}
2422 className={
2423 errors[
2424 `category_discounts_${catIdx}_ranges_${rIdx}_max`
2425 ]
2426 ? "border-red-500"
2427 : ""
2428 }
2429 />
2430 {errors[
2431 `category_discounts_${catIdx}_ranges_${rIdx}_max`
2432 ] && (
2433 <p className="mt-1.5 text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
2434 <Info className="w-4 h-4" />
2435 {
2436 errors[
2437 `category_discounts_${catIdx}_ranges_${rIdx}_max`
2438 ]
2439 }
2440 </p>
2441 )}
2442 </div>
2443 </div>
2444
2445 <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
2446 <div>
2447 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
2448 {__(
2449 "Discount Type",
2450 "yatra",
2451 )}{" "}
2452 <span className="text-red-500">
2453 *
2454 </span>
2455 </label>
2456 <Select
2457 value={
2458 range.discount_type ||
2459 "percentage"
2460 }
2461 onChange={(e) =>
2462 updateRangeInCategory(
2463 catIdx,
2464 range.id,
2465 {
2466 discount_type: e
2467 .target
2468 .value as
2469 | "percentage"
2470 | "fixed",
2471 },
2472 )
2473 }
2474 className={
2475 errors[
2476 `category_discounts_${catIdx}_ranges_${rIdx}_type`
2477 ]
2478 ? "border-red-500"
2479 : ""
2480 }
2481 >
2482 <option value="percentage">
2483 {__(
2484 "Percentage",
2485 "yatra",
2486 )}
2487 </option>
2488 <option value="fixed">
2489 {__(
2490 "Fixed Amount",
2491 "yatra",
2492 )}
2493 </option>
2494 </Select>
2495 {errors[
2496 `category_discounts_${catIdx}_ranges_${rIdx}_type`
2497 ] && (
2498 <p className="mt-1.5 text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
2499 <Info className="w-4 h-4" />
2500 {
2501 errors[
2502 `category_discounts_${catIdx}_ranges_${rIdx}_type`
2503 ]
2504 }
2505 </p>
2506 )}
2507 </div>
2508
2509 <div>
2510 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
2511 {__(
2512 "Discount Amount",
2513 "yatra",
2514 )}{" "}
2515 <span className="text-red-500">
2516 *
2517 </span>
2518 </label>
2519 <div className="relative">
2520 {range.discount_type ===
2521 "fixed" && (
2522 <span className={`absolute ${currencyOnRight ? "right-3" : "left-3"} top-1/2 transform -translate-y-1/2 text-gray-500 text-sm`}>
2523 {currencySymbol} </span>
2524 )}
2525 <Input
2526 type="number"
2527 min="0"
2528 max={
2529 range.discount_type ===
2530 "percentage"
2531 ? 100
2532 : undefined
2533 }
2534 step="0.01"
2535 value={
2536 range.discount_amount ||
2537 ""
2538 }
2539 onChange={(e) =>
2540 updateRangeInCategory(
2541 catIdx,
2542 range.id,
2543 {
2544 discount_amount:
2545 e.target
2546 .value,
2547 },
2548 )
2549 }
2550 placeholder={
2551 range.discount_type ===
2552 "percentage"
2553 ? __(
2554 "e.g., 10",
2555 "yatra",
2556 )
2557 : __(
2558 "e.g., 50",
2559 "yatra",
2560 )
2561 }
2562 className={`${range.discount_type === "fixed" ? currencyPad : ""} ${errors[`category_discounts_${catIdx}_ranges_${rIdx}_amount`] ? "border-red-500" : ""}`}
2563 />
2564 {range.discount_type ===
2565 "percentage" && (
2566 <span className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500 text-sm">
2567 %
2568 </span>
2569 )}
2570 </div>
2571 {errors[
2572 `category_discounts_${catIdx}_ranges_${rIdx}_amount`
2573 ] && (
2574 <p className="mt-1.5 text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
2575 <Info className="w-4 h-4" />
2576 {
2577 errors[
2578 `category_discounts_${catIdx}_ranges_${rIdx}_amount`
2579 ]
2580 }
2581 </p>
2582 )}
2583 </div>
2584 </div>
2585 </div>
2586 ),
2587 )}
2588 </div>
2589 )}
2590 </CardContent>
2591 </Card>
2592 ),
2593 )}
2594 </div>
2595 )}
2596 </div>
2597 )}
2598 </>
2599 )}
2600 </CardContent>
2601 </Card>
2602 )}
2603 </div>
2604
2605 {/* Sidebar */}
2606 <div className="space-y-3">
2607 {/* Status */}
2608 <Card>
2609 <CardHeader className="pb-2">
2610 <CardTitle className="text-base">
2611 {__("Status", "yatra")}
2612 </CardTitle>
2613 </CardHeader>
2614 <CardContent className="space-y-3">
2615 <Select
2616 value={formData.status}
2617 onChange={(e) =>
2618 handleFieldChange(
2619 "status",
2620 e.target.value as "draft" | "publish" | "trash",
2621 )
2622 }
2623 >
2624 <option value="draft">{__("Draft", "yatra")}</option>
2625 <option value="publish">{__("Publish", "yatra")}</option>
2626 <option value="trash">{__("Trash", "yatra")}</option>
2627 </Select>
2628 <p className="text-xs text-gray-500 dark:text-gray-400">
2629 {__(
2630 "Only published coupons can be used by customers.",
2631 "yatra",
2632 )}
2633 </p>
2634 </CardContent>
2635 </Card>
2636
2637 {/* Applicable To */}
2638 <Card>
2639 <CardHeader className="pb-2">
2640 <CardTitle className="text-base">
2641 {__("Applicable To", "yatra")}
2642 </CardTitle>
2643 </CardHeader>
2644 <CardContent>
2645 <ApplicableTripSelector
2646 value={formData.applicable_to}
2647 onValueChange={(val) =>
2648 handleFieldChange("applicable_to", val)
2649 }
2650 selectedTripIds={formData.trip_ids}
2651 onTripIdsChange={(ids) =>
2652 handleFieldChange("trip_ids", ids)
2653 }
2654 description={__(
2655 "Choose whether this discount applies to all trips or specific ones.",
2656 )}
2657 helperText={__(
2658 "Trips shown with ID for quick identification.",
2659 )}
2660 />
2661 </CardContent>
2662 </Card>
2663
2664 {/* Submit Actions */}
2665 <Card>
2666 <CardContent className="p-3">
2667 <div className="space-y-2">
2668 {errors.submit && (
2669 <div className="p-2 text-sm text-red-500 bg-red-50 dark:bg-red-900/20 rounded-md">
2670 {errors.submit}
2671 </div>
2672 )}
2673 <div className="flex gap-2">
2674 <Button
2675 type="submit"
2676 disabled={isSubmitting}
2677 className="flex-1 flex items-center justify-center gap-2"
2678 >
2679 {isSubmitting ? (
2680 <>
2681 <Loader2 className="w-4 h-4 animate-spin" />
2682 {__("Saving...", "yatra")}
2683 </>
2684 ) : (
2685 <>
2686 <Save className="w-4 h-4" />
2687 {isEditMode
2688 ? __("Update Coupon", "yatra")
2689 : __("Create Coupon", "yatra")}
2690 </>
2691 )}
2692 </Button>
2693 <Button
2694 type="button"
2695 variant="outline"
2696 onClick={handleBack}
2697 disabled={isSubmitting}
2698 >
2699 {__("Cancel", "yatra")}
2700 </Button>
2701 </div>
2702 </div>
2703 </CardContent>
2704 </Card>
2705 </div>
2706 </div>
2707 </form>
2708 </ConditionalRender>
2709
2710 {/* Premium Upgrade Modal */}
2711 <PremiumUpgradeDialog
2712 open={showPremiumModal}
2713 onClose={() => setShowPremiumModal(false)}
2714 moduleName={__("Group Discounts", "yatra")}
2715 purchaseUrl="https://wpyatra.com/pricing?module=group-discounts"
2716 moduleDescription={__(
2717 "Unlock powerful group discount features to automatically apply discounts based on group size and traveler categories.",
2718 "yatra",
2719 )}
2720 />
2721 </div>
2722 );
2723 };
2724
2725 export default DiscountForm;
2726