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
yatra / resources / js / components / shared / ApplicableTripSelector.tsx

ApplicableTripSelector.tsx in Yatra – Travel Booking & Tour Operator Software 3.0.16, at resources/js/components/shared/ApplicableTripSelector.tsx

313 lines 11.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import React, { useEffect, useMemo, useState } from "react";
2 import { useQuery } from "@tanstack/react-query";
3 import { __ } from "../../lib/i18n";
4 import { apiClient } from "../../lib/api-client";
5 import { Select } from "../ui/select";
6 import { Input } from "../ui/input";
7 import { Button } from "../ui/button";
8 import { Loader2, Search, X } from "lucide-react";
9
10 type ApplicableValue = "all" | "specific_trips";
11
12 interface ApplicableTripSelectorProps {
13 value: ApplicableValue;
14 onValueChange: (value: ApplicableValue) => void;
15 selectedTripIds: number[];
16 onTripIdsChange: (ids: number[]) => void;
17 description?: string;
18 placeholder?: string;
19 helperText?: string;
20 disabled?: boolean;
21 }
22
23 interface TripOption {
24 id: number;
25 label: string;
26 }
27
28 const toTripOption = (item: any): TripOption => {
29 const id = Number(item?.id || 0);
30 const label = item?.title || item?.name || item?.slug || `Trip #${id}`;
31 return { id, label };
32 };
33
34 export const ApplicableTripSelector: React.FC<ApplicableTripSelectorProps> = ({
35 value,
36 onValueChange,
37 selectedTripIds,
38 onTripIdsChange,
39 description = __(
40 "Control whether this applies to all trips or only selected trips.",
41 ),
42 placeholder = __("Click to select trips..."),
43 helperText = __("Only the selected trips will be eligible."),
44 disabled = false,
45 }) => {
46 const [showDropdown, setShowDropdown] = useState(false);
47 const [searchTerm, setSearchTerm] = useState("");
48 const [debouncedSearch, setDebouncedSearch] = useState("");
49
50 useEffect(() => {
51 const timer = setTimeout(() => {
52 setDebouncedSearch(searchTerm);
53 }, 300);
54 return () => clearTimeout(timer);
55 }, [searchTerm]);
56
57 const {
58 data: trips = [],
59 isLoading,
60 isFetching,
61 } = useQuery({
62 queryKey: ["applicable-trips", debouncedSearch],
63 queryFn: async () => {
64 const params: Record<string, any> = { per_page: 100 };
65 if (debouncedSearch.trim()) {
66 params.search = debouncedSearch.trim();
67 }
68 const response = await apiClient.get("/trips", { params });
69 const items = response?.data?.data || response?.data || response || [];
70 return Array.isArray(items) ? items.map(toTripOption) : [];
71 },
72 enabled: value === "specific_trips",
73 staleTime: 5 * 60 * 1000,
74 });
75
76 // Fetch selected trip details using React Query
77 const { data: selectedTripsData = [] } = useQuery({
78 queryKey: ["selected-trips", selectedTripIds],
79 queryFn: async () => {
80 if (selectedTripIds.length === 0) return [];
81
82 // Try using the list endpoint with include parameter instead of individual calls
83 try {
84 const response = await apiClient.get("/trips", {
85 params: {
86 per_page: 100,
87 include: selectedTripIds.join(","), // Try to include specific IDs
88 },
89 });
90
91 const items = response?.data?.data || response?.data || response || [];
92 const trips = Array.isArray(items) ? items.map(toTripOption) : [];
93 return trips;
94 } catch (error) {
95 console.warn("List API failed, trying individual calls:", error);
96
97 // Fallback to individual calls
98 const results = await Promise.all(
99 selectedTripIds.map(async (id) => {
100 try {
101 const response = await apiClient.get(`/trips/${id}`);
102 if (response?.data) {
103 const trip = toTripOption(response.data);
104 return trip;
105 }
106 } catch (error) {
107 console.warn(`Failed to fetch trip ${id}:`, error);
108 }
109 return { id, label: `Trip #${id}` };
110 }),
111 );
112 return results;
113 }
114 },
115 enabled: selectedTripIds.length > 0,
116 staleTime: 10 * 60 * 1000, // Cache for 10 minutes
117 });
118
119 const tripLookup = useMemo(() => {
120 const map = new Map<number, string>();
121 // Add search results
122 trips.forEach((trip) => {
123 map.set(trip.id, trip.label);
124 });
125 // Add selected trips data
126 selectedTripsData.forEach((trip) => {
127 map.set(trip.id, trip.label);
128 });
129 return map;
130 }, [trips, selectedTripsData]);
131
132 const selectedChips = useMemo(() => {
133 const chips = selectedTripIds.map((id) => ({
134 id,
135 label: tripLookup.get(id) || `Trip #${id}`,
136 }));
137 return chips;
138 }, [selectedTripIds, tripLookup]);
139
140 const toggleTrip = (tripId: number, checked: boolean) => {
141 if (checked) {
142 if (!selectedTripIds.includes(tripId)) {
143 onTripIdsChange([...selectedTripIds, tripId]);
144 }
145 } else {
146 onTripIdsChange(selectedTripIds.filter((id) => id !== tripId));
147 }
148 };
149
150 const clearTrips = (e?: React.MouseEvent) => {
151 e?.stopPropagation();
152 onTripIdsChange([]);
153 };
154
155 const handleModeChange = (newValue: ApplicableValue) => {
156 onValueChange(newValue);
157 if (newValue === "all" && selectedTripIds.length > 0) {
158 onTripIdsChange([]);
159 }
160 };
161
162 return (
163 <div className="space-y-3">
164 <div>
165 <p className="text-xs text-gray-500 dark:text-gray-400 mb-2">
166 {description}
167 </p>
168 <Select
169 value={value}
170 onChange={(e) => handleModeChange(e.target.value as ApplicableValue)}
171 disabled={disabled}
172 >
173 <option value="all">{__("All Trips")}</option>
174 <option value="specific_trips">{__("Specific Trips")}</option>
175 </Select>
176 </div>
177
178 {value === "specific_trips" && (
179 <div className="space-y-2 relative">
180 <div
181 className={`border border-gray-300 dark:border-gray-600 rounded-lg p-2 cursor-pointer hover:border-gray-400 dark:hover:border-gray-500 bg-white dark:bg-gray-800 ${disabled ? "opacity-70 cursor-not-allowed" : ""}`}
182 onClick={() => !disabled && setShowDropdown((prev) => !prev)}
183 >
184 {selectedChips.length > 0 ? (
185 <div className="flex flex-wrap items-center gap-1.5">
186 {selectedChips.map((chip) => (
187 <span
188 key={chip.id}
189 className="inline-flex items-center justify-center gap-1 px-2 py-1.5 text-[11px] font-medium bg-blue-50 text-blue-700 border border-blue-100 rounded-full h-6 min-w-[24px]"
190 >
191 <span className="truncate">{chip.label}</span>
192 <span className="text-[9px] text-gray-500 font-normal flex-shrink-0">
193 #{chip.id}
194 </span>
195 <button
196 type="button"
197 onClick={(e) => {
198 e.stopPropagation();
199 toggleTrip(chip.id, false);
200 }}
201 className="hover:text-blue-600 text-[10px] flex-shrink-0 leading-none"
202 >
203 ×
204 </button>
205 </span>
206 ))}
207 <Button
208 type="button"
209 variant="ghost"
210 size="sm"
211 onClick={clearTrips}
212 className="text-xs text-red-500 hover:text-red-600 px-2 py-0 h-6 flex items-center"
213 >
214 {__("Clear")}
215 </Button>
216 </div>
217 ) : (
218 <span className="text-sm text-gray-500 dark:text-gray-400">
219 {placeholder}
220 </span>
221 )}
222 </div>
223
224 {helperText && (
225 <p className="text-xs text-gray-500 dark:text-gray-400">
226 {helperText}
227 </p>
228 )}
229
230 {showDropdown && !disabled && (
231 <div className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg">
232 <div className="p-2 border-b border-gray-200 dark:border-gray-700">
233 <div className="relative">
234 <Input
235 type="text"
236 value={searchTerm}
237 onChange={(e) => setSearchTerm(e.target.value)}
238 placeholder={__("Search trips...")}
239 className="pl-8"
240 />
241 <Search className="w-4 h-4 text-gray-400 absolute left-2.5 top-1/2 -translate-y-1/2" />
242 {isFetching && (
243 <Loader2 className="w-4 h-4 animate-spin text-gray-400 absolute right-2 top-1/2 -translate-y-1/2" />
244 )}
245 </div>
246 </div>
247
248 <div className="max-h-64 overflow-y-auto">
249 {isLoading ? (
250 <div className="p-4 text-center text-sm text-gray-500 dark:text-gray-400">
251 <Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />
252 {__("Loading trips...")}
253 </div>
254 ) : trips.length > 0 ? (
255 <div className="divide-y divide-gray-100 dark:divide-gray-700">
256 {trips.map((trip) => {
257 const checked = selectedTripIds.includes(trip.id);
258 return (
259 <label
260 key={trip.id}
261 className="flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/40"
262 onClick={(e) => e.stopPropagation()}
263 >
264 <input
265 type="checkbox"
266 checked={checked}
267 onChange={(e) =>
268 toggleTrip(trip.id, e.target.checked)
269 }
270 className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
271 />
272 <span className="flex-1 text-sm text-gray-900 dark:text-white truncate">
273 {trip.label}
274 </span>
275 <span className="text-xs text-gray-400 dark:text-gray-500">
276 #{trip.id}
277 </span>
278 </label>
279 );
280 })}
281 </div>
282 ) : (
283 <div className="p-4 text-center text-sm text-gray-500 dark:text-gray-400">
284 {searchTerm
285 ? __("No trips found")
286 : __("No trips available")}
287 </div>
288 )}
289 </div>
290
291 <div className="p-2 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
292 <span className="text-xs text-gray-500 dark:text-gray-400">
293 {selectedTripIds.length} {__("selected")}
294 </span>
295 <Button
296 type="button"
297 variant="ghost"
298 size="sm"
299 onClick={() => setShowDropdown(false)}
300 className="text-xs"
301 >
302 <X className="w-4 h-4 mr-1" />
303 {__("Close")}
304 </Button>
305 </div>
306 </div>
307 )}
308 </div>
309 )}
310 </div>
311 );
312 };
313