PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.17
Yatra – Travel Booking & Tour Operator Software v3.0.17
3.0.17 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 All 85 releases
← All changes | resources/js/pages/BookingForm.tsx +182 -72 3.0.10 → 3.0.17 View file →
@@ -23,9 +23,10 @@
23 23 formatDate as formatDateUtil,
24 24 formatDateForInput,
25 25 todayYmd,
26 26 } from "../lib/dateFormat";
27 -import { getCountryOptions } from "../lib/countries";
27 +import { getCountrySelectOptions } from "../lib/countries";
28 +import { SearchableSelect } from "../components/ui/searchable-select";
28 29 import { apiService } from "../lib/api-client";
29 30 import { usePermissions } from "../hooks/usePermissions";
30 31 import { getCurrencySymbol } from "../data/currencies";
31 32 import { Button } from "../components/ui/button";
@@ -83,8 +84,9 @@
83 84 customer_country: string;
84 85 trip_id: string;
85 86 booking_date: string;
86 87 travel_date: string;
88 + departure_time: string;
87 89 travelers: string;
88 90 subtotal: string;
89 91 tax_amount: string;
90 92 total_amount: string;
@@ -99,9 +101,9 @@
99 101 // FormatHelper::getCountries() → localized to window.yatraAdmin.countries).
100 102 // One source of truth; operators that want a curated subset apply
101 103 // the `yatra_countries_list` PHP filter once and every dropdown
102 104 // (admin + public booking + Pro modules) picks it up.
103 -const countryList = getCountryOptions();
105 +const countrySelectOptions = getCountrySelectOptions();
104 106
105 107 // Core contact fields rendered explicitly (name/email/phone/country); everything
106 108 // else in the contact form is treated as an "extra/custom" field.
107 109 const CORE_CONTACT_IDS = [
@@ -134,8 +136,9 @@
134 136 customer_country: "",
135 137 trip_id: "",
136 138 booking_date: todayYmd(),
137 139 travel_date: "",
140 + departure_time: "",
138 141 travelers: "1",
139 142 subtotal: "",
140 143 tax_amount: "",
141 144 total_amount: "",
@@ -177,13 +180,15 @@
177 180 document.addEventListener("mousedown", handleClickOutside);
178 181 return () => document.removeEventListener("mousedown", handleClickOutside);
179 182 }, []);
180 183
181 - // Fetch booking form configuration
184 + // Fetch booking form configuration, resolved for the selected trip so a
185 + // per-trip form version (Pro conditions) shows the right fields here too.
186 + const selectedTripId = formData.trip_id ? Number(formData.trip_id) : null;
182 187 const { data: formConfig } = useQuery<BookingFormConfig>({
183 - queryKey: ["booking-form-config"],
188 + queryKey: ["booking-form-config", selectedTripId],
184 189 queryFn: async () => {
185 - const response = await apiService.getSettings();
190 + const response = await apiService.getBookingFormConfig(selectedTripId);
186 191 return (
187 192 response?.data?.booking_form_config ||
188 193 response?.booking_form_config ||
189 194 null
@@ -269,8 +274,37 @@
269 274 enabled: can("yatra_view_bookings") || can("yatra_view_trips"),
270 275 retry: 1,
271 276 });
272 277
278 + // Departures available on the chosen date. A trip can run several a day, and
279 + // capacity is tracked per departure — so the operator has to say which one this
280 + // booking is for, otherwise it occupies no slot at all.
281 + const { data: availableDatesData } = useQuery({
282 + queryKey: ["booking-form-available-dates", formData.trip_id],
283 + queryFn: async () => {
284 + if (!formData.trip_id) return null;
285 + return await apiService.getTripAvailableDates(formData.trip_id);
286 + },
287 + enabled: !!formData.trip_id,
288 + retry: 1,
289 + });
290 +
291 + const departureTimesForDate = React.useMemo(() => {
292 + const rows =
293 + (availableDatesData as any)?.data ?? (availableDatesData as any) ?? [];
294 + if (!Array.isArray(rows) || !formData.travel_date) return [];
295 + const row = rows.find(
296 + (r: any) => (r?.date ?? r?.departure_date) === formData.travel_date,
297 + );
298 + if (!row) return [];
299 + // `departure_times` lists every departure the date runs; `time` is the
300 + // single-slot fallback for dates that carry one.
301 + const times: string[] = Array.isArray((row as any).departure_times)
302 + ? (row as any).departure_times
303 + : [(row as any).time ?? (row as any).departure_time ?? ""];
304 + return Array.from(new Set(times.filter((t) => !!t)));
305 + }, [availableDatesData, formData.travel_date]);
306 +
273 307 // Fetch booking data if editing
274 308 const { data: bookingData, isLoading: isLoadingBooking } = useQuery({
275 309 queryKey: ["booking", bookingId],
276 310 queryFn: async () => {
@@ -418,8 +452,9 @@
418 452 "",
419 453 trip_id: String(bookingData.trip_id || ""),
420 454 booking_date: bookingData.booking_date || todayYmd(),
421 455 travel_date: bookingData.travel_date || "",
456 + departure_time: (bookingData as any).departure_time || "",
422 457 travelers: String(
423 458 (bookingData as any).travelers_count ||
424 459 bookingData.travelers_data?.length ||
425 460 "1",
@@ -555,21 +590,18 @@
555 590 </Select>
556 591 );
557 592 }
558 593 if (field.type === "country") {
594 + // Searchable list with national flags, matching the public booking form
595 + // and the phone country-code control.
559 596 return (
560 - <Select
561 - id={id}
597 + <SearchableSelect
562 598 value={value}
563 - onChange={(e) => onChange(e.target.value)}
564 - >
565 - <option value="">{field.placeholder || "Select Country"}</option>
566 - {countryList.map((c) => (
567 - <option key={c.code} value={c.code}>
568 - {c.name}
569 - </option>
570 - ))}
571 - </Select>
599 + onChange={onChange}
600 + options={countrySelectOptions}
601 + placeholder={field.placeholder || __("Select Country", "yatra")}
602 + searchPlaceholder={__("Search country", "yatra")}
603 + />
572 604 );
573 605 }
574 606 if (field.type === "textarea") {
575 607 return (
@@ -629,8 +661,47 @@
629 661 return updated;
630 662 });
631 663 };
632 664
665 + // Typing in "Number of Travelers" has to resize the traveller list, not just
666 + // the number itself: the booking is saved with `travelers_count:
667 + // travelersData.length`, so a number typed here used to be dropped on save and
668 + // the change looked like it had never been made. Mirrors addTraveler /
669 + // removeTraveler so both routes stay in step.
670 + const handleTravelersCountChange = (value: string) => {
671 + setFormData((prev) => ({ ...prev, travelers: value }));
672 + if (errors.travelers) {
673 + setErrors((prev) => ({ ...prev, travelers: "" }));
674 + }
675 +
676 + // Allow the field to be cleared while typing without collapsing the list.
677 + if (value.trim() === "") {
678 + return;
679 + }
680 +
681 + const parsed = parseInt(value, 10);
682 + if (isNaN(parsed) || parsed < 1) {
683 + return;
684 + }
685 +
686 + setTravelersData((prev) => {
687 + if (parsed === prev.length) {
688 + return prev;
689 + }
690 + if (parsed < prev.length) {
691 + // Trim from the end so details already entered for earlier travellers stay.
692 + return prev.slice(0, parsed);
693 + }
694 + const grown = [...prev];
695 + while (grown.length < parsed) {
696 + grown.push(createEmptyTraveler());
697 + }
698 + return grown;
699 + });
700 +
701 + setExpandedTravelers((prev) => prev.filter((i) => i < parsed));
702 + };
703 +
633 704 const addTraveler = () => {
634 705 const newTraveler = createEmptyTraveler();
635 706 setTravelersData((prev) => [...prev, newTraveler]);
636 707 setFormData((prev) => ({
@@ -678,8 +749,11 @@
678 749 if (!formData.booking_date) {
679 750 newErrors.booking_date = __("Booking date is required", "yatra");
680 751 }
681 752
753 + if (departureTimesForDate.length > 1 && !formData.departure_time) {
754 + newErrors.departure_time = __("Departure time is required", "yatra");
755 + }
682 756 if (!formData.travel_date) {
683 757 newErrors.travel_date = __("Travel date is required", "yatra");
684 758 }
685 759
@@ -717,8 +791,12 @@
717 791 // contact_country column (previously not submitted, so edits were lost).
718 792 contact_country: data.customer_country || "",
719 793 trip_id: parseInt(data.trip_id),
720 794 travel_date: data.travel_date,
795 + // Which departure the booking occupies. A trip running several
796 + // departures a day counts capacity per (date, time), so a booking saved
797 + // without this matches no slot and never reduces frontend availability.
798 + departure_time: data.departure_time || "",
721 799 travelers_count: travelersData.length,
722 800 total_amount: parseFloat(data.total_amount),
723 801 payment_status: data.payment_status,
724 802 status: data.booking_status,
@@ -1029,23 +1107,15 @@
1029 1107 className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
1030 1108 >
1031 1109 {__("Country", "yatra")}
1032 1110 </label>
1033 - <select
1034 - id="customer_country"
1111 + <SearchableSelect
1035 1112 value={formData.customer_country}
1036 - onChange={(e) =>
1037 - handleFieldChange("customer_country", e.target.value)
1038 - }
1039 - className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
1040 - >
1041 - <option value="">{__("Select country", "yatra")}</option>
1042 - {countryList.map((country) => (
1043 - <option key={country.code} value={country.code}>
1044 - {country.name}
1045 - </option>
1046 - ))}
1047 - </select>
1113 + onChange={(v) => handleFieldChange("customer_country", v)}
1114 + options={countrySelectOptions}
1115 + placeholder={__("Select country", "yatra")}
1116 + searchPlaceholder={__("Search country", "yatra")}
1117 + />
1048 1118 </div>
1049 1119
1050 1120 {/* Extra / custom contact fields (nationality, address, and
1051 1121 any fields the operator added via the Dynamic Form module). */}
@@ -1273,11 +1343,13 @@
1273 1343 <span className="text-red-500">*</span>
1274 1344 </label>
1275 1345 <DatePicker
1276 1346 value={formData.travel_date}
1277 - onChange={(value: string) =>
1278 - handleFieldChange("travel_date", value)
1279 - }
1347 + onChange={(value: string) => {
1348 + handleFieldChange("travel_date", value);
1349 + // A time from the previous date no longer applies.
1350 + handleFieldChange("departure_time", "");
1351 + }}
1280 1352 placeholder={__("Select travel date", "yatra")}
1281 1353 error={!!errors.travel_date}
1282 1354 />
1283 1355 {errors.travel_date && (
@@ -1285,8 +1357,58 @@
1285 1357 {errors.travel_date}
1286 1358 </p>
1287 1359 )}
1288 1360 </div>
1361 +
1362 + {/* Departure time — only when the chosen date actually runs
1363 + more than one departure. Capacity is tracked per departure,
1364 + so without this the booking occupies no slot. */}
1365 + {departureTimesForDate.length > 1 && (
1366 + <div>
1367 + <label
1368 + htmlFor="departure_time"
1369 + className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5"
1370 + >
1371 + {__("Departure Time", "yatra")}{" "}
1372 + <span className="text-red-500">*</span>
1373 + </label>
1374 + <select
1375 + id="departure_time"
1376 + name="departure_time"
1377 + value={formData.departure_time}
1378 + onChange={(e) =>
1379 + handleFieldChange("departure_time", e.target.value)
1380 + }
1381 + className={`w-full rounded-md border px-3 py-2 text-sm bg-white dark:bg-gray-800 dark:text-white ${
1382 + errors.departure_time
1383 + ? "border-red-500"
1384 + : "border-gray-300 dark:border-gray-600"
1385 + }`}
1386 + required
1387 + >
1388 + <option value="">
1389 + {__("Select departure time", "yatra")}
1390 + </option>
1391 + {departureTimesForDate.map((t: string) => (
1392 + <option key={t} value={t}>
1393 + {t}
1394 + </option>
1395 + ))}
1396 + </select>
1397 + {errors.departure_time ? (
1398 + <p className="mt-1 text-sm text-red-500">
1399 + {errors.departure_time}
1400 + </p>
1401 + ) : (
1402 + <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
1403 + {__(
1404 + "This date has several departures. Pick the one this booking is for so its seats are reserved.",
1405 + "yatra",
1406 + )}
1407 + </p>
1408 + )}
1409 + </div>
1410 + )}
1289 1411 </div>
1290 1412
1291 1413 <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
1292 1414 {/* Number of Travelers */}
@@ -1303,9 +1425,9 @@
1303 1425 type="number"
1304 1426 min="1"
1305 1427 value={formData.travelers}
1306 1428 onChange={(e) =>
1307 - handleFieldChange("travelers", e.target.value)
1429 + handleTravelersCountChange(e.target.value)
1308 1430 }
1309 1431 className={errors.travelers ? "border-red-500" : ""}
1310 1432 required
1311 1433 />
@@ -1503,30 +1625,23 @@
1503 1625 </option>
1504 1626 ))}
1505 1627 </Select>
1506 1628 ) : field.type === "country" ? (
1507 - <Select
1508 - id={`emergency-${field.id}`}
1629 + <SearchableSelect
1509 1630 value={emergencyContactData[field.id] || ""}
1510 - onChange={(e) =>
1511 - handleEmergencyContactChange(
1512 - field.id,
1513 - e.target.value,
1514 - )
1631 + onChange={(v) =>
1632 + handleEmergencyContactChange(field.id, v)
1515 1633 }
1516 - >
1517 - <option value="">
1518 - {field.placeholder || "Select Country"}
1519 - </option>
1520 - {countryList.map((country) => (
1521 - <option
1522 - key={country.code}
1523 - value={country.code}
1524 - >
1525 - {country.name}
1526 - </option>
1527 - ))}
1528 - </Select>
1634 + options={countrySelectOptions}
1635 + placeholder={
1636 + field.placeholder ||
1637 + __("Select Country", "yatra")
1638 + }
1639 + searchPlaceholder={__(
1640 + "Search country",
1641 + "yatra",
1642 + )}
1643 + />
1529 1644 ) : field.type === "textarea" ? (
1530 1645 <textarea
1531 1646 id={`emergency-${field.id}`}
1532 1647 value={emergencyContactData[field.id] || ""}
@@ -1767,32 +1882,27 @@
1767 1882 </option>
1768 1883 ))}
1769 1884 </Select>
1770 1885 ) : field.type === "country" ? (
1771 - <Select
1772 - id={`traveler-${travelerIndex}-${field.id}`}
1886 + <SearchableSelect
1773 1887 value={traveler[field.id] || ""}
1774 - onChange={(e) =>
1888 + onChange={(v) =>
1775 1889 handleTravelerChange(
1776 1890 travelerIndex,
1777 1891 field.id,
1778 - e.target.value,
1892 + v,
1779 1893 )
1780 1894 }
1781 - >
1782 - <option value="">
1783 - {field.placeholder ||
1784 - "Select Country"}
1785 - </option>
1786 - {countryList.map((country) => (
1787 - <option
1788 - key={country.code}
1789 - value={country.code}
1790 - >
1791 - {country.name}
1792 - </option>
1793 - ))}
1794 - </Select>
1895 + options={countrySelectOptions}
1896 + placeholder={
1897 + field.placeholder ||
1898 + __("Select Country", "yatra")
1899 + }
1900 + searchPlaceholder={__(
1901 + "Search country",
1902 + "yatra",
1903 + )}
1904 + />
1795 1905 ) : field.type === "textarea" ? (
1796 1906 <textarea
1797 1907 id={`traveler-${travelerIndex}-${field.id}`}
1798 1908 value={traveler[field.id] || ""}