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 +194 -56 3.0.4 → 3.0.16 View file →
@@ -18,8 +18,9 @@
18 18 MapPin,
19 19 CheckCircle2,
20 20 } from "lucide-react";
21 21 import { __ } from "../lib/i18n";
22 +import { toDateValue, todayYmd } from "../lib/dateFormat";
22 23 import { Button } from "../components/ui/button";
23 24 import { Input } from "../components/ui/input";
24 25 import { Select } from "../components/ui/select";
25 26 import { SearchableSelect } from "../components/ui/searchable-select";
@@ -86,8 +87,26 @@
86 87 sale_price?: number;
87 88 traveler_pricing?: TravelerPricing[];
88 89 }
89 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 +
90 109 interface RecurringRule {
91 110 id?: number;
92 111 trip_id: number;
93 112 name: string;
@@ -97,9 +116,9 @@
97 116 day_of_week?: number;
98 117 interval_days?: number;
99 118 start_date: string;
100 119 end_date?: string;
101 - excluded_dates: string[];
120 + excluded_dates: ExcludedEntry[];
102 121 months: number[]; // Array of month numbers (1-12) to filter by
103 122 time_slots: TimeSlot[]; // For single-day trips with multiple slots
104 123 pricing_type: "regular" | "traveler_based"; // Allow override of trip's pricing type
105 124 original_price?: number;
@@ -156,9 +175,9 @@
156 175 days_of_week: [0], // Sunday by default
157 176 week_of_month: "first",
158 177 day_of_week: 0,
159 178 interval_days: 7,
160 - start_date: new Date().toISOString().split("T")[0],
179 + start_date: todayYmd(),
161 180 end_date: "",
162 181 excluded_dates: [],
163 182 months: [], // Empty = all months, otherwise specific months (1-12)
164 183 time_slots: [], // For single-day trips with multiple slots
@@ -180,8 +199,11 @@
180 199 status: "active",
181 200 });
182 201
183 202 const [newExcludedDate, setNewExcludedDate] = useState("");
203 + const [newExcludedStart, setNewExcludedStart] = useState("");
204 + const [newExcludedEnd, setNewExcludedEnd] = useState("");
205 + const [excludedError, setExcludedError] = useState("");
184 206 const [previewData, setPreviewData] = useState<{
185 207 total: number;
186 208 dates: any[];
187 209 } | null>(null);
@@ -200,12 +222,14 @@
200 222 // effectively traveler-based (price types configured). Infer the effective
201 223 // pricing type from `price_types` when present so the Rules UI reflects
202 224 // real trip configuration.
203 225 const rawPriceTypes = trip.price_types;
204 - const hasTravelerPricing =
205 - Array.isArray(rawPriceTypes) ? rawPriceTypes.length > 0 : false;
206 - const effectivePricingType =
207 - hasTravelerPricing ? "traveler_based" : trip.pricing_type || "regular";
226 + const hasTravelerPricing = Array.isArray(rawPriceTypes)
227 + ? rawPriceTypes.length > 0
228 + : false;
229 + const effectivePricingType = hasTravelerPricing
230 + ? "traveler_based"
231 + : trip.pricing_type || "regular";
208 232
209 233 return {
210 234 id: Number(trip.id) || 0,
211 235 title: trip.title,
@@ -320,9 +344,11 @@
320 344
321 345 // Prefer the *trip's effective* pricing type over any stale rule.pricing_type.
322 346 // Trips can be traveler-based simply by having price_types configured, even if
323 347 // trip.pricing_type is still "regular".
324 - const tripRow = tripsData.trips.find((t) => t.id === Number(existingRule.trip_id));
348 + const tripRow = tripsData.trips.find(
349 + (t) => t.id === Number(existingRule.trip_id),
350 + );
325 351 const effectivePricingType =
326 352 tripRow?.pricing_type ||
327 353 ((tripForLocations as any)?.price_types &&
328 354 Array.isArray((tripForLocations as any).price_types) &&
@@ -338,10 +364,9 @@
338 364 days_of_week: daysOfWeek.length > 0 ? daysOfWeek : [0],
339 365 week_of_month: existingRule.week_of_month || "first",
340 366 day_of_week: existingRule.day_of_week ?? 0,
341 367 interval_days: existingRule.interval_days || 7,
342 - start_date:
343 - existingRule.start_date || new Date().toISOString().split("T")[0],
368 + start_date: existingRule.start_date || todayYmd(),
344 369 end_date: existingRule.end_date || "",
345 370 excluded_dates: Array.isArray(existingRule.excluded_dates)
346 371 ? existingRule.excluded_dates
347 372 : [],
@@ -375,12 +400,16 @@
375 400 useEffect(() => {
376 401 if (!isEditing && !existingRule) {
377 402 const inferred = (() => {
378 403 const priceTypes = (tripForLocations as any)?.price_types;
379 - const hasTravelerPricing = Array.isArray(priceTypes) && priceTypes.length > 0;
380 - return (hasTravelerPricing
381 - ? "traveler_based"
382 - : (selectedTrip?.pricing_type || (tripForLocations as Trip)?.pricing_type || "regular")
404 + const hasTravelerPricing =
405 + Array.isArray(priceTypes) && priceTypes.length > 0;
406 + return (
407 + hasTravelerPricing
408 + ? "traveler_based"
409 + : selectedTrip?.pricing_type ||
410 + (tripForLocations as Trip)?.pricing_type ||
411 + "regular"
383 412 ) as "regular" | "traveler_based";
384 413 })();
385 414
386 415 setFormData((prev) => ({
@@ -556,24 +585,79 @@
556 585 : [...prev.days_of_week, day].sort((a, b) => a - b),
557 586 }));
558 587 };
559 588
560 - // 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
561 593 const addExcludedDate = () => {
562 - 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("");
563 631 setFormData((prev) => ({
564 632 ...prev,
565 - excluded_dates: [...prev.excluded_dates, newExcludedDate].sort(),
633 + excluded_dates: sortExcluded([...prev.excluded_dates, start]),
566 634 }));
567 - setNewExcludedDate("");
635 + setNewExcludedStart("");
636 + setNewExcludedEnd("");
637 + return;
568 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("");
569 652 };
570 653
571 - // Remove excluded date
572 - const removeExcludedDate = (date: string) => {
654 + // Remove an excluded day or period
655 + const removeExcluded = (id: string) => {
656 + setExcludedError("");
573 657 setFormData((prev) => ({
574 658 ...prev,
575 - excluded_dates: prev.excluded_dates.filter((d) => d !== date),
659 + excluded_dates: prev.excluded_dates.filter((e) => excludedId(e) !== id),
576 660 }));
577 661 };
578 662
579 663 const isLoading = createMutation.isPending || updateMutation.isPending;
@@ -828,9 +912,9 @@
828 912 setFormData((prev) => ({ ...prev, end_date: value }))
829 913 }
830 914 minDate={
831 915 formData.start_date
832 - ? new Date(formData.start_date)
916 + ? toDateValue(formData.start_date)
833 917 : undefined
834 918 }
835 919 placeholder={__("Select end date (optional)", "yatra")}
836 920 />
@@ -899,9 +983,9 @@
899 983 </p>
900 984 )}
901 985 </div>
902 986
903 - {/* Excluded Dates */}
987 + {/* Excluded Dates & Periods */}
904 988 <div className="pt-4 border-t border-gray-200 dark:border-gray-700">
905 989 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
906 990 {__("Excluded Dates", "yatra")}{" "}
907 991 <span className="text-gray-400">
@@ -924,34 +1008,86 @@
924 1008 >
925 1009 <Plus className="w-4 h-4" />
926 1010 </Button>
927 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 +
928 1059 {formData.excluded_dates.length > 0 && (
929 1060 <div className="flex flex-wrap gap-2">
930 - {formData.excluded_dates.map((date) => (
931 - <Badge
932 - key={date}
933 - variant="outline"
934 - className="flex items-center gap-1"
935 - >
936 - {new Date(date + "T00:00:00").toLocaleDateString(
937 - "en-US",
938 - {
939 - weekday: "short",
940 - month: "short",
941 - day: "numeric",
942 - year: "numeric",
943 - },
944 - )}
945 - <button
946 - type="button"
947 - onClick={() => removeExcludedDate(date)}
948 - 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"
949 1075 >
950 - <X className="w-3 h-3" />
951 - </button>
952 - </Badge>
953 - ))}
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 + })}
954 1090 </div>
955 1091 )}
956 1092 </div>
957 1093 </CardContent>
@@ -2006,9 +2142,11 @@
2006 2142 setFormData((prev) => {
2007 2143 const newSlots = [
2008 2144 ...prev.time_slots,
2009 2145 ];
2010 - if (!newSlots[index]) {
2146 + if (
2147 + !newSlots[index]
2148 + ) {
2011 2149 return prev;
2012 2150 }
2013 2151 newSlots[index] = {
2014 2152 ...newSlots[index],
@@ -2023,9 +2161,10 @@
2023 2161 ),
2024 2162 };
2025 2163 return {
2026 2164 ...prev,
2027 - time_slots: newSlots,
2165 + time_slots:
2166 + newSlots,
2028 2167 };
2029 2168 });
2030 2169 }}
2031 2170 className="p-0.5 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
@@ -2549,17 +2688,16 @@
2549 2688 key={index}
2550 2689 className="text-xs px-2 py-1 bg-gray-50 dark:bg-gray-800 rounded flex justify-between"
2551 2690 >
2552 2691 <span>
2553 - {new Date(date.departure_date).toLocaleDateString(
2554 - "en-US",
2555 - {
2556 - weekday: "short",
2557 - month: "short",
2558 - day: "numeric",
2559 - year: "numeric",
2560 - },
2561 - )}
2692 + {toDateValue(
2693 + date.departure_date,
2694 + ).toLocaleDateString("en-US", {
2695 + weekday: "short",
2696 + month: "short",
2697 + day: "numeric",
2698 + year: "numeric",
2699 + })}
2562 2700 </span>
2563 2701 {date.departure_time && (
2564 2702 <span className="text-gray-500">
2565 2703 {date.departure_time}