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

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

836 lines 32.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Additional Services Form
3 *
4 * Form component for creating and editing additional services.
5 * This is part of the premium module - functionality provided by Yatra Pro.
6 *
7 * @package Yatra
8 * @since 3.0.0
9 */
10
11 import React, { useState, useEffect } from "react";
12 import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
13 import {
14 Card,
15 CardContent,
16 CardHeader,
17 CardTitle,
18 } from "../components/ui/card";
19 import { Button } from "../components/ui/button";
20 import { Input } from "../components/ui/input";
21 import { Select } from "../components/ui/select";
22 import { PageHeader } from "../components/common/PageHeader";
23 import { useToast } from "../components/ui/toast";
24 import { usePermissions } from "../hooks/usePermissions";
25 import { IconPicker, IconPickerValue } from "../components/ui/icon-picker";
26 import { apiClient } from "../lib/api-client";
27 import { __ } from "../lib/i18n";
28 import { ArrowLeft, Save, Package, Loader2, X } from "lucide-react";
29
30 // Types
31 interface AdditionalService {
32 id?: number;
33 name: string;
34 description: string;
35 price: number;
36 price_type: "fixed" | "percentage";
37 price_per: "person" | "booking" | "day";
38 icon: IconPickerValue | null;
39 status: "publish" | "draft" | "trash";
40 sort_order: number;
41 applicable_to: "all" | "specific_trips";
42 trip_ids: number[];
43 is_required: boolean;
44 }
45
46 const defaultService: AdditionalService = {
47 name: "",
48 description: "",
49 price: 0,
50 price_type: "fixed",
51 price_per: "person",
52 icon: null,
53 status: "publish",
54 sort_order: 0,
55 applicable_to: "all",
56 trip_ids: [],
57 is_required: false,
58 };
59
60 // Check if module is available
61 const isModuleAvailable = (): boolean => {
62 const yatraAdmin = (window as any)?.yatraAdmin;
63 return Boolean(yatraAdmin?.isPro && yatraAdmin?.additionalServicesEnabled);
64 };
65
66 const AdditionalServicesForm: React.FC = () => {
67 const [formData, setFormData] = useState<AdditionalService>(defaultService);
68 const [errors, setErrors] = useState<Record<string, string>>({});
69 const [showTripDropdown, setShowTripDropdown] = useState(false);
70 const [tripSearchQuery, setTripSearchQuery] = useState("");
71 const [debouncedTripSearch, setDebouncedTripSearch] = useState("");
72 const { showToast } = useToast();
73 const queryClient = useQueryClient();
74 const { can } = usePermissions();
75
76 // Get ID from URL for edit mode
77 const params = new URLSearchParams(window.location.search);
78 const serviceId = params.get("id");
79 const isEditMode = !!serviceId;
80
81 // Debounce trip search
82 useEffect(() => {
83 const timer = setTimeout(() => {
84 setDebouncedTripSearch(tripSearchQuery);
85 }, 300);
86 return () => clearTimeout(timer);
87 }, [tripSearchQuery]);
88
89 // Fetch trips for applicable_to selection
90 const tripsQuery = useQuery({
91 queryKey: ["trips-for-service", debouncedTripSearch],
92 queryFn: async () => {
93 try {
94 const params: Record<string, any> = {
95 per_page: 100,
96 };
97 if (debouncedTripSearch.trim()) {
98 params.search = debouncedTripSearch.trim();
99 }
100 const response = await apiClient.get("/trips", { params });
101 const trips = response?.data?.data || response?.data || response || [];
102 return Array.isArray(trips) ? trips : [];
103 } catch (error: any) {
104 console.error("Failed to load trips:", error);
105 return [];
106 }
107 },
108 enabled: formData.applicable_to === "specific_trips",
109 staleTime: 5 * 60 * 1000,
110 });
111
112 // Trip options for dropdown
113 const tripOptions = (tripsQuery.data || [])
114 .filter(
115 (t: any) => !!t && (typeof t.id === "number" || typeof t.id === "string"),
116 )
117 .map((trip: any) => ({
118 value: Number(trip.id),
119 label: trip.title || trip.name || `Trip #${trip.id}`,
120 }));
121
122 // Fetch service data for edit mode
123 const { data: serviceData, isLoading: isLoadingService } = useQuery({
124 queryKey: ["additional-service", serviceId],
125 queryFn: async () => {
126 const response = await apiClient.get(`/additional-services/${serviceId}`);
127 // API returns { success: true, data: service }, extract the service object
128 return response.data?.data || response.data;
129 },
130 enabled: isEditMode && isModuleAvailable(),
131 });
132
133 // Populate form with existing data
134 useEffect(() => {
135 if (serviceData) {
136 // Handle icon - could be old format (image_id/image_url) or new format (IconPickerValue)
137 let iconValue: IconPickerValue | null = null;
138 if (serviceData.icon) {
139 iconValue = serviceData.icon as IconPickerValue;
140 } else if (serviceData.image_url) {
141 // Convert old format to new IconPickerValue format
142 iconValue = { type: "image", value: serviceData.image_url };
143 }
144
145 setFormData({
146 id: serviceData.id,
147 name: serviceData.name || "",
148 description: serviceData.description || "",
149 price: parseFloat(serviceData.price) || 0,
150 price_type: serviceData.price_type || "fixed",
151 price_per: serviceData.price_per || "person",
152 icon: iconValue,
153 status: serviceData.status || "draft",
154 sort_order: serviceData.sort_order || 0,
155 applicable_to: serviceData.applicable_to || "all",
156 trip_ids: serviceData.trip_ids || [],
157 is_required:
158 serviceData.is_required === true ||
159 serviceData.is_required === 1 ||
160 serviceData.is_required === "1",
161 });
162 }
163 }, [serviceData]);
164
165 // Save mutation
166 const saveMutation = useMutation({
167 mutationFn: async (data: AdditionalService) => {
168 if (isEditMode) {
169 return apiClient.put(`/additional-services/${serviceId}`, data);
170 }
171 return apiClient.post("/additional-services", data);
172 },
173 onSuccess: () => {
174 showToast(
175 isEditMode
176 ? __("Service updated successfully")
177 : __("Service created successfully"),
178 "success",
179 );
180 queryClient.invalidateQueries({ queryKey: ["additional-services"] });
181 // Only navigate back on create, stay on page for updates
182 if (!isEditMode) {
183 navigateBack();
184 }
185 },
186 onError: (error: any) => {
187 const message =
188 error?.response?.data?.message || __("Failed to save service");
189 showToast(message, "error");
190 },
191 });
192
193 const navigateBack = () => {
194 const params = new URLSearchParams(window.location.search);
195 params.delete("action");
196 params.delete("id");
197 params.set("subpage", "trips");
198 params.set("tab", "additional-services");
199 window.history.pushState({}, "", `${window.location.pathname}?${params}`);
200 window.dispatchEvent(new PopStateEvent("popstate"));
201 };
202
203 const handleFieldChange = (field: keyof AdditionalService, value: any) => {
204 setFormData((prev) => ({ ...prev, [field]: value }));
205 // Clear error when field is modified
206 if (errors[field]) {
207 setErrors((prev) => {
208 const newErrors = { ...prev };
209 delete newErrors[field];
210 return newErrors;
211 });
212 }
213 };
214
215 const validateForm = (): boolean => {
216 const newErrors: Record<string, string> = {};
217
218 if (!formData.name.trim()) {
219 newErrors.name = __("Service name is required");
220 }
221
222 if (formData.price < 0) {
223 newErrors.price = __("Price cannot be negative");
224 }
225
226 if (formData.price_type === "percentage" && formData.price > 100) {
227 newErrors.price = __("Percentage cannot exceed 100%");
228 }
229
230 setErrors(newErrors);
231 return Object.keys(newErrors).length === 0;
232 };
233
234 const handleSubmit = (e: React.FormEvent) => {
235 e.preventDefault();
236
237 if (!validateForm()) {
238 showToast(__("Please fix the errors before saving"), "error");
239 return;
240 }
241
242 saveMutation.mutate(formData);
243 };
244
245 // Check permissions - allow admins and users with yatra capabilities
246 const hasPermission =
247 can("manage_options") ||
248 can("yatra_manage_settings") ||
249 can("yatra_edit_trips");
250 if (!hasPermission) {
251 return (
252 <Card>
253 <CardContent className="p-12 text-center">
254 <p className="text-gray-500 dark:text-gray-400">
255 {__("You do not have permission to manage services.")}
256 </p>
257 </CardContent>
258 </Card>
259 );
260 }
261
262 // Show premium gate if module not available
263 if (!isModuleAvailable()) {
264 return (
265 <Card>
266 <CardContent className="p-12 text-center">
267 <Package className="w-12 h-12 mx-auto text-gray-400 mb-4" />
268 <h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
269 {__("Premium Feature")}
270 </h3>
271 <p className="text-gray-500 dark:text-gray-400 mb-6">
272 {__(
273 "Additional Services is a premium feature. Upgrade to Yatra Pro to unlock.",
274 )}
275 </p>
276 <Button
277 onClick={() =>
278 window.open(
279 "https://wpyatra.com/pricing?module=additional-services",
280 "_blank",
281 )
282 }
283 >
284 {__("Upgrade to Pro")}
285 </Button>
286 </CardContent>
287 </Card>
288 );
289 }
290
291 if (isEditMode && isLoadingService) {
292 return (
293 <div className="space-y-6">
294 {/* Header Skeleton */}
295 <div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6">
296 <div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-1/4 mb-2 animate-pulse"></div>
297 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/2 animate-pulse"></div>
298 </div>
299
300 {/* Form Skeleton */}
301 <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
302 {/* Main Fields */}
303 <div className="lg:col-span-2 space-y-6">
304 {/* Service Details Card */}
305 <Card>
306 <CardHeader>
307 <div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-32 animate-pulse"></div>
308 </CardHeader>
309 <CardContent className="space-y-4">
310 {/* Name field */}
311 <div>
312 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-2 animate-pulse"></div>
313 <div className="h-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
314 </div>
315 {/* Description field */}
316 <div>
317 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-2 animate-pulse"></div>
318 <div className="h-24 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
319 </div>
320 </CardContent>
321 </Card>
322
323 {/* Pricing Card */}
324 <Card>
325 <CardHeader>
326 <div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-20 animate-pulse"></div>
327 </CardHeader>
328 <CardContent className="space-y-4">
329 <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
330 <div>
331 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-16 mb-2 animate-pulse"></div>
332 <div className="h-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
333 </div>
334 <div>
335 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2 animate-pulse"></div>
336 <div className="h-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
337 </div>
338 <div>
339 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2 animate-pulse"></div>
340 <div className="h-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
341 </div>
342 </div>
343 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4 animate-pulse"></div>
344 </CardContent>
345 </Card>
346 </div>
347
348 {/* Sidebar */}
349 <div className="space-y-6">
350 {/* Publish Card */}
351 <Card>
352 <CardHeader>
353 <div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-20 animate-pulse"></div>
354 </CardHeader>
355 <CardContent className="space-y-4">
356 <div>
357 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-16 mb-2 animate-pulse"></div>
358 <div className="h-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
359 </div>
360 <div>
361 <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2 animate-pulse"></div>
362 <div className="h-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
363 </div>
364 <div className="pt-4 border-t border-gray-200 dark:border-gray-700">
365 <div className="h-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
366 </div>
367 </CardContent>
368 </Card>
369
370 {/* Icon/Image Card */}
371 <Card>
372 <CardHeader>
373 <div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-36 animate-pulse"></div>
374 </CardHeader>
375 <CardContent>
376 <div className="h-40 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
377 </CardContent>
378 </Card>
379 </div>
380 </div>
381 </div>
382 );
383 }
384
385 return (
386 <div className="space-y-6">
387 <PageHeader
388 description={
389 isEditMode
390 ? __("Update the details of this additional service.")
391 : __(
392 "Create a new additional service that customers can add to their bookings.",
393 )
394 }
395 actions={
396 <Button variant="outline" onClick={navigateBack}>
397 <ArrowLeft className="w-4 h-4 mr-2" />
398 {__("Back to Services")}
399 </Button>
400 }
401 />
402
403 <form onSubmit={handleSubmit}>
404 <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
405 {/* Main Content */}
406 <div className="lg:col-span-2 space-y-6">
407 {/* Basic Information */}
408 <Card>
409 <CardHeader>
410 <CardTitle>{__("Service Details")}</CardTitle>
411 </CardHeader>
412 <CardContent className="space-y-4">
413 <div>
414 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
415 {__("Service Name")} <span className="text-red-500">*</span>
416 </label>
417 <Input
418 value={formData.name}
419 onChange={(e) => handleFieldChange("name", e.target.value)}
420 placeholder={__("e.g., Airport Transfer, Travel Insurance")}
421 className={errors.name ? "border-red-500" : ""}
422 />
423 {errors.name && (
424 <p className="mt-1 text-sm text-red-500">{errors.name}</p>
425 )}
426 </div>
427
428 <div>
429 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
430 {__("Description")}
431 </label>
432 <textarea
433 value={formData.description}
434 onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
435 handleFieldChange("description", e.target.value)
436 }
437 placeholder={__("Describe what this service includes...")}
438 rows={4}
439 className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-800 dark:text-white"
440 />
441 </div>
442
443 <div className="flex items-start gap-3 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
444 <input
445 type="checkbox"
446 id="is_required"
447 checked={formData.is_required}
448 onChange={(e) =>
449 handleFieldChange("is_required", e.target.checked)
450 }
451 className="mt-0.5 h-4 w-4 text-amber-600 focus:ring-amber-500 border-gray-300 rounded"
452 />
453 <div>
454 <label
455 htmlFor="is_required"
456 className="text-sm font-medium text-gray-900 dark:text-white cursor-pointer"
457 >
458 {__("Required Service")}
459 </label>
460 <p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
461 {__(
462 "If enabled, customers must select this service when booking.",
463 )}
464 </p>
465 </div>
466 </div>
467 </CardContent>
468 </Card>
469
470 {/* Pricing */}
471 <Card>
472 <CardHeader>
473 <CardTitle>{__("Pricing")}</CardTitle>
474 </CardHeader>
475 <CardContent className="space-y-4">
476 <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
477 <div>
478 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
479 {__("Price")}
480 </label>
481 <Input
482 type="number"
483 min="0"
484 step="0.01"
485 value={formData.price}
486 onChange={(e) =>
487 handleFieldChange(
488 "price",
489 parseFloat(e.target.value) || 0,
490 )
491 }
492 className={errors.price ? "border-red-500" : ""}
493 />
494 {errors.price && (
495 <p className="mt-1 text-sm text-red-500">
496 {errors.price}
497 </p>
498 )}
499 </div>
500
501 <div>
502 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
503 {__("Price Type")}
504 </label>
505 <Select
506 value={formData.price_type}
507 onChange={(e) =>
508 handleFieldChange("price_type", e.target.value)
509 }
510 >
511 <option value="fixed">{__("Fixed Amount")}</option>
512 <option value="percentage">
513 {__("Percentage of Trip Price")}
514 </option>
515 </Select>
516 </div>
517
518 <div>
519 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
520 {__("Price Per")}
521 </label>
522 <Select
523 value={formData.price_per}
524 onChange={(e) =>
525 handleFieldChange("price_per", e.target.value)
526 }
527 disabled={formData.price_type === "percentage"}
528 title={
529 formData.price_type === "percentage"
530 ? __(
531 "Price Per does not apply to percentage services — they are always a percentage of the whole trip price.",
532 )
533 : undefined
534 }
535 className={
536 formData.price_type === "percentage"
537 ? "opacity-60 cursor-not-allowed"
538 : ""
539 }
540 >
541 <option value="person">{__("Per Person")}</option>
542 <option value="booking">{__("Per Booking")}</option>
543 <option value="day">{__("Per Day")}</option>
544 </Select>
545 </div>
546 </div>
547
548 <p className="text-sm text-gray-500 dark:text-gray-400">
549 {formData.price_type === "percentage"
550 ? __(
551 "The price will be calculated as a percentage of the trip base price.",
552 )
553 : formData.price_per === "person"
554 ? __(
555 "This price will be multiplied by the number of travelers.",
556 )
557 : formData.price_per === "day"
558 ? __(
559 "This price will be multiplied by the trip duration in days.",
560 )
561 : __("This is a flat fee charged once per booking.")}
562 </p>
563 </CardContent>
564 </Card>
565 </div>
566
567 {/* Sidebar */}
568 <div className="space-y-6">
569 {/* Status & Sort Order */}
570 <Card>
571 <CardHeader>
572 <CardTitle>{__("Publish")}</CardTitle>
573 </CardHeader>
574 <CardContent className="space-y-4">
575 <div>
576 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
577 {__("Status")}
578 </label>
579 <Select
580 value={formData.status}
581 onChange={(e) =>
582 handleFieldChange("status", e.target.value)
583 }
584 >
585 <option value="draft">{__("Draft")}</option>
586 <option value="publish">{__("Published")}</option>
587 <option value="trash">{__("Trash")}</option>
588 </Select>
589 </div>
590
591 <div>
592 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
593 {__("Sort Order")}
594 </label>
595 <Input
596 type="number"
597 min="0"
598 value={formData.sort_order}
599 onChange={(e) =>
600 handleFieldChange(
601 "sort_order",
602 parseInt(e.target.value) || 0,
603 )
604 }
605 />
606 <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
607 {__("Lower numbers appear first")}
608 </p>
609 </div>
610 </CardContent>
611 </Card>
612
613 {/* Applicable To */}
614 <Card>
615 <CardHeader>
616 <CardTitle>{__("Applicable To")}</CardTitle>
617 </CardHeader>
618 <CardContent className="space-y-3">
619 <Select
620 value={formData.applicable_to}
621 onChange={(e) =>
622 handleFieldChange(
623 "applicable_to",
624 e.target.value as "all" | "specific_trips",
625 )
626 }
627 >
628 <option value="all">{__("All Trips")}</option>
629 <option value="specific_trips">{__("Specific Trips")}</option>
630 </Select>
631 {formData.applicable_to === "specific_trips" && (
632 <div className="mt-3 space-y-2 relative">
633 {/* Selected trips display / Dropdown trigger */}
634 <div
635 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"
636 onClick={() => setShowTripDropdown(!showTripDropdown)}
637 >
638 {formData.trip_ids.length > 0 ? (
639 <div className="flex flex-wrap gap-1.5">
640 {formData.trip_ids.slice(0, 3).map((tripId) => {
641 const trip = tripOptions.find(
642 (t: any) => t.value === tripId,
643 );
644 return (
645 <span
646 key={tripId}
647 className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 rounded"
648 >
649 {trip?.label || `Trip #${tripId}`}
650 <button
651 type="button"
652 onClick={(e) => {
653 e.stopPropagation();
654 handleFieldChange(
655 "trip_ids",
656 formData.trip_ids.filter(
657 (id) => id !== tripId,
658 ),
659 );
660 }}
661 className="hover:text-blue-600 dark:hover:text-blue-200"
662 >
663 <X className="w-3 h-3" />
664 </button>
665 </span>
666 );
667 })}
668 {formData.trip_ids.length > 3 && (
669 <span className="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded">
670 +{formData.trip_ids.length - 3} {__("more")}
671 </span>
672 )}
673 </div>
674 ) : (
675 <span className="text-sm text-gray-500 dark:text-gray-400">
676 {__("Click to select trips...")}
677 </span>
678 )}
679 </div>
680
681 {/* Dropdown panel */}
682 {showTripDropdown && (
683 <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">
684 {/* Search Input */}
685 <div className="p-2 border-b border-gray-200 dark:border-gray-700">
686 <div className="relative">
687 <Input
688 type="text"
689 placeholder={__("Search trips...")}
690 value={tripSearchQuery}
691 onChange={(e) =>
692 setTripSearchQuery(e.target.value)
693 }
694 className="w-full text-sm"
695 onClick={(e) => e.stopPropagation()}
696 />
697 {tripsQuery.isFetching && (
698 <div className="absolute right-3 top-1/2 transform -translate-y-1/2">
699 <Loader2 className="w-4 h-4 animate-spin text-gray-400" />
700 </div>
701 )}
702 </div>
703 </div>
704
705 {/* Trip list */}
706 <div className="max-h-[200px] overflow-y-auto">
707 {tripsQuery.isLoading ? (
708 <div className="p-4 text-center text-sm text-gray-500 dark:text-gray-400">
709 <Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />
710 {__("Loading trips...")}
711 </div>
712 ) : tripOptions.length > 0 ? (
713 <div className="divide-y divide-gray-100 dark:divide-gray-700">
714 {tripOptions.map((trip: any) => (
715 <label
716 key={trip.value}
717 className="flex items-center justify-between gap-3 px-3 py-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50"
718 onClick={(e) => e.stopPropagation()}
719 >
720 <div className="flex items-center gap-3">
721 <input
722 type="checkbox"
723 checked={formData.trip_ids.includes(
724 trip.value,
725 )}
726 onChange={(e) => {
727 if (e.target.checked) {
728 handleFieldChange("trip_ids", [
729 ...formData.trip_ids,
730 trip.value,
731 ]);
732 } else {
733 handleFieldChange(
734 "trip_ids",
735 formData.trip_ids.filter(
736 (id) => id !== trip.value,
737 ),
738 );
739 }
740 }}
741 className="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
742 />
743 <span className="text-sm text-gray-700 dark:text-gray-300">
744 {trip.label}
745 </span>
746 </div>
747 <span className="text-xs text-gray-400 dark:text-gray-500">
748 #{trip.value}
749 </span>
750 </label>
751 ))}
752 </div>
753 ) : (
754 <div className="p-4 text-center text-sm text-gray-500 dark:text-gray-400">
755 {__("No trips found")}
756 </div>
757 )}
758 </div>
759
760 {/* Close button */}
761 <div className="p-2 border-t border-gray-200 dark:border-gray-700">
762 <Button
763 type="button"
764 variant="outline"
765 size="sm"
766 className="w-full"
767 onClick={() => setShowTripDropdown(false)}
768 >
769 {__("Done")}
770 </Button>
771 </div>
772 </div>
773 )}
774 </div>
775 )}
776 <p className="text-xs text-gray-500 dark:text-gray-400">
777 {formData.applicable_to === "all"
778 ? __("This service will be available for all trips.")
779 : __(
780 "This service will only be available for the selected trips.",
781 )}
782 </p>
783 </CardContent>
784 </Card>
785
786 {/* Icon/Image */}
787 <Card>
788 <CardHeader>
789 <CardTitle>{__("Service Icon or Image")}</CardTitle>
790 </CardHeader>
791 <CardContent>
792 <IconPicker
793 value={formData.icon}
794 onChange={(value) => handleFieldChange("icon", value)}
795 label={__("Service Icon or Image")}
796 helpText={__(
797 "Select an icon from the library or upload a custom image for this service.",
798 )}
799 allowImageUpload={true}
800 allowIconSelection={true}
801 size="md"
802 />
803 </CardContent>
804 </Card>
805
806 {/* Submit Button */}
807 <Card>
808 <CardContent className="pt-6">
809 <Button
810 type="submit"
811 className="w-full"
812 disabled={saveMutation.isPending}
813 >
814 {saveMutation.isPending ? (
815 <>
816 <Loader2 className="w-4 h-4 mr-2 animate-spin" />
817 {__("Saving...")}
818 </>
819 ) : (
820 <>
821 <Save className="w-4 h-4 mr-2" />
822 {isEditMode ? __("Update Service") : __("Create Service")}
823 </>
824 )}
825 </Button>
826 </CardContent>
827 </Card>
828 </div>
829 </div>
830 </form>
831 </div>
832 );
833 };
834
835 export default AdditionalServicesForm;
836