PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
← All changes | resources/js/pages/RecurringRuleForm.tsx +160 -32 3.0.13 → 3.0.16 View file →
@@ -87,8 +87,26 @@
87 87 sale_price?: number;
88 88 traveler_pricing?: TravelerPricing[];
89 89 }
90 90
91 +/**
92 + * One entry in a rule's exclusion list: either a single day ("2027-03-08", the
93 + * original format) or an inclusive period. Both are stored in the same
94 + * `excluded_dates` column, so rules saved before periods existed load as-is.
95 + */
96 +type ExcludedEntry = string | { start: string; end: string };
97 +
98 +const isPeriod = (e: ExcludedEntry): e is { start: string; end: string } =>
99 + typeof e === "object" && e !== null && "start" in e && "end" in e;
100 +
101 +/** Sort key: a period sorts by its first day, alongside single dates. */
102 +const excludedSortKey = (e: ExcludedEntry): string =>
103 + isPeriod(e) ? e.start : e;
104 +
105 +/** Stable identity for list keys and removal, for both shapes. */
106 +const excludedId = (e: ExcludedEntry): string =>
107 + isPeriod(e) ? `${e.start}..${e.end}` : e;
108 +
91 109 interface RecurringRule {
92 110 id?: number;
93 111 trip_id: number;
94 112 name: string;
@@ -98,9 +116,9 @@
98 116 day_of_week?: number;
99 117 interval_days?: number;
100 118 start_date: string;
101 119 end_date?: string;
102 - excluded_dates: string[];
120 + excluded_dates: ExcludedEntry[];
103 121 months: number[]; // Array of month numbers (1-12) to filter by
104 122 time_slots: TimeSlot[]; // For single-day trips with multiple slots
105 123 pricing_type: "regular" | "traveler_based"; // Allow override of trip's pricing type
106 124 original_price?: number;
@@ -181,8 +199,11 @@
181 199 status: "active",
182 200 });
183 201
184 202 const [newExcludedDate, setNewExcludedDate] = useState("");
203 + const [newExcludedStart, setNewExcludedStart] = useState("");
204 + const [newExcludedEnd, setNewExcludedEnd] = useState("");
205 + const [excludedError, setExcludedError] = useState("");
185 206 const [previewData, setPreviewData] = useState<{
186 207 total: number;
187 208 dates: any[];
188 209 } | null>(null);
@@ -564,24 +585,79 @@
564 585 : [...prev.days_of_week, day].sort((a, b) => a - b),
565 586 }));
566 587 };
567 588
568 - // Add excluded date
589 + const sortExcluded = (list: ExcludedEntry[]): ExcludedEntry[] =>
590 + [...list].sort((a, b) => excludedSortKey(a).localeCompare(excludedSortKey(b)));
591 +
592 + // Add a single excluded day
569 593 const addExcludedDate = () => {
570 - if (newExcludedDate && !formData.excluded_dates.includes(newExcludedDate)) {
594 + if (!newExcludedDate) {
595 + return;
596 + }
597 + const exists = formData.excluded_dates.some(
598 + (e) => excludedId(e) === newExcludedDate,
599 + );
600 + if (exists) {
601 + setExcludedError(__("That date is already excluded.", "yatra"));
602 + return;
603 + }
604 + setExcludedError("");
605 + setFormData((prev) => ({
606 + ...prev,
607 + excluded_dates: sortExcluded([...prev.excluded_dates, newExcludedDate]),
608 + }));
609 + setNewExcludedDate("");
610 + };
611 +
612 + // Add an excluded period — one entry covering every day from start to end,
613 + // so a shutdown doesn't have to be added a day at a time.
614 + const addExcludedPeriod = () => {
615 + if (!newExcludedStart || !newExcludedEnd) {
616 + return;
617 + }
618 + // Accept the two dates in either order rather than rejecting the input.
619 + const start =
620 + newExcludedStart <= newExcludedEnd ? newExcludedStart : newExcludedEnd;
621 + const end =
622 + newExcludedStart <= newExcludedEnd ? newExcludedEnd : newExcludedStart;
623 +
624 + if (start === end) {
625 + // A one-day period is just a single date; keep the list in its simplest form.
626 + if (formData.excluded_dates.some((e) => excludedId(e) === start)) {
627 + setExcludedError(__("That date is already excluded.", "yatra"));
628 + return;
629 + }
630 + setExcludedError("");
571 631 setFormData((prev) => ({
572 632 ...prev,
573 - excluded_dates: [...prev.excluded_dates, newExcludedDate].sort(),
633 + excluded_dates: sortExcluded([...prev.excluded_dates, start]),
574 634 }));
575 - setNewExcludedDate("");
635 + setNewExcludedStart("");
636 + setNewExcludedEnd("");
637 + return;
576 638 }
639 +
640 + const id = `${start}..${end}`;
641 + if (formData.excluded_dates.some((e) => excludedId(e) === id)) {
642 + setExcludedError(__("That period is already excluded.", "yatra"));
643 + return;
644 + }
645 + setExcludedError("");
646 + setFormData((prev) => ({
647 + ...prev,
648 + excluded_dates: sortExcluded([...prev.excluded_dates, { start, end }]),
649 + }));
650 + setNewExcludedStart("");
651 + setNewExcludedEnd("");
577 652 };
578 653
579 - // Remove excluded date
580 - const removeExcludedDate = (date: string) => {
654 + // Remove an excluded day or period
655 + const removeExcluded = (id: string) => {
656 + setExcludedError("");
581 657 setFormData((prev) => ({
582 658 ...prev,
583 - excluded_dates: prev.excluded_dates.filter((d) => d !== date),
659 + excluded_dates: prev.excluded_dates.filter((e) => excludedId(e) !== id),
584 660 }));
585 661 };
586 662
587 663 const isLoading = createMutation.isPending || updateMutation.isPending;
@@ -907,9 +983,9 @@
907 983 </p>
908 984 )}
909 985 </div>
910 986
911 - {/* Excluded Dates */}
987 + {/* Excluded Dates & Periods */}
912 988 <div className="pt-4 border-t border-gray-200 dark:border-gray-700">
913 989 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
914 990 {__("Excluded Dates", "yatra")}{" "}
915 991 <span className="text-gray-400">
@@ -932,34 +1008,86 @@
932 1008 >
933 1009 <Plus className="w-4 h-4" />
934 1010 </Button>
935 1011 </div>
1012 +
1013 + {/* A whole period in one go — a shutdown shouldn't have to be
1014 + added a day at a time. Named "Periods" so it can't be
1015 + confused with the rule's own Date Range above. */}
1016 + <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 mt-4">
1017 + {__("Excluded Periods", "yatra")}{" "}
1018 + <span className="text-gray-400">
1019 + ({__("holiday closures, vacations", "yatra")})
1020 + </span>
1021 + </label>
1022 + <div className="flex flex-col sm:flex-row gap-2 mb-1">
1023 + <div className="flex-1">
1024 + <DatePicker
1025 + value={newExcludedStart}
1026 + onChange={(value: string) => setNewExcludedStart(value)}
1027 + placeholder={__("First day", "yatra")}
1028 + />
1029 + </div>
1030 + <div className="flex-1">
1031 + <DatePicker
1032 + value={newExcludedEnd}
1033 + onChange={(value: string) => setNewExcludedEnd(value)}
1034 + placeholder={__("Last day", "yatra")}
1035 + />
1036 + </div>
1037 + <Button
1038 + type="button"
1039 + variant="outline"
1040 + onClick={addExcludedPeriod}
1041 + disabled={!newExcludedStart || !newExcludedEnd}
1042 + >
1043 + <Plus className="w-4 h-4" />
1044 + </Button>
1045 + </div>
1046 + <p className="text-xs text-gray-500 dark:text-gray-400 mb-2">
1047 + {__(
1048 + "Both days are included. Every date in between is skipped by this rule.",
1049 + "yatra",
1050 + )}
1051 + </p>
1052 +
1053 + {excludedError && (
1054 + <p className="text-xs text-red-600 dark:text-red-400 mb-2">
1055 + {excludedError}
1056 + </p>
1057 + )}
1058 +
936 1059 {formData.excluded_dates.length > 0 && (
937 1060 <div className="flex flex-wrap gap-2">
938 - {formData.excluded_dates.map((date) => (
939 - <Badge
940 - key={date}
941 - variant="outline"
942 - className="flex items-center gap-1"
943 - >
944 - {new Date(date + "T00:00:00").toLocaleDateString(
945 - "en-US",
946 - {
947 - weekday: "short",
948 - month: "short",
949 - day: "numeric",
950 - year: "numeric",
951 - },
952 - )}
953 - <button
954 - type="button"
955 - onClick={() => removeExcludedDate(date)}
956 - className="ml-1 hover:text-red-500"
1061 + {formData.excluded_dates.map((entry) => {
1062 + const id = excludedId(entry);
1063 + const fmt = (d: string) =>
1064 + new Date(d + "T00:00:00").toLocaleDateString("en-US", {
1065 + weekday: "short",
1066 + month: "short",
1067 + day: "numeric",
1068 + year: "numeric",
1069 + });
1070 + return (
1071 + <Badge
1072 + key={id}
1073 + variant="outline"
1074 + className="flex items-center gap-1"
957 1075 >
958 - <X className="w-3 h-3" />
959 - </button>
960 - </Badge>
961 - ))}
1076 + {isPeriod(entry)
1077 + ? `${fmt(entry.start)} → ${fmt(entry.end)}`
1078 + : fmt(entry)}
1079 + <button
1080 + type="button"
1081 + onClick={() => removeExcluded(id)}
1082 + className="ml-1 hover:text-red-500"
1083 + aria-label={__("Remove exclusion", "yatra")}
1084 + >
1085 + <X className="w-3 h-3" />
1086 + </button>
1087 + </Badge>
1088 + );
1089 + })}
962 1090 </div>
963 1091 )}
964 1092 </div>
965 1093 </CardContent>