PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.2.9
Yatra – Travel Booking & Tour Operator Software v3.0.2.9
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 / pages / AdditionalServicesForm.tsx

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

823 lines 32.1 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 >
528 <option value="person">{__("Per Person")}</option>
529 <option value="booking">{__("Per Booking")}</option>
530 <option value="day">{__("Per Day")}</option>
531 </Select>
532 </div>
533 </div>
534
535 <p className="text-sm text-gray-500 dark:text-gray-400">
536 {formData.price_type === "percentage"
537 ? __(
538 "The price will be calculated as a percentage of the trip base price.",
539 )
540 : formData.price_per === "person"
541 ? __(
542 "This price will be multiplied by the number of travelers.",
543 )
544 : formData.price_per === "day"
545 ? __(
546 "This price will be multiplied by the trip duration in days.",
547 )
548 : __("This is a flat fee charged once per booking.")}
549 </p>
550 </CardContent>
551 </Card>
552 </div>
553
554 {/* Sidebar */}
555 <div className="space-y-6">
556 {/* Status & Sort Order */}
557 <Card>
558 <CardHeader>
559 <CardTitle>{__("Publish")}</CardTitle>
560 </CardHeader>
561 <CardContent className="space-y-4">
562 <div>
563 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
564 {__("Status")}
565 </label>
566 <Select
567 value={formData.status}
568 onChange={(e) =>
569 handleFieldChange("status", e.target.value)
570 }
571 >
572 <option value="draft">{__("Draft")}</option>
573 <option value="publish">{__("Published")}</option>
574 <option value="trash">{__("Trash")}</option>
575 </Select>
576 </div>
577
578 <div>
579 <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
580 {__("Sort Order")}
581 </label>
582 <Input
583 type="number"
584 min="0"
585 value={formData.sort_order}
586 onChange={(e) =>
587 handleFieldChange(
588 "sort_order",
589 parseInt(e.target.value) || 0,
590 )
591 }
592 />
593 <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
594 {__("Lower numbers appear first")}
595 </p>
596 </div>
597 </CardContent>
598 </Card>
599
600 {/* Applicable To */}
601 <Card>
602 <CardHeader>
603 <CardTitle>{__("Applicable To")}</CardTitle>
604 </CardHeader>
605 <CardContent className="space-y-3">
606 <Select
607 value={formData.applicable_to}
608 onChange={(e) =>
609 handleFieldChange(
610 "applicable_to",
611 e.target.value as "all" | "specific_trips",
612 )
613 }
614 >
615 <option value="all">{__("All Trips")}</option>
616 <option value="specific_trips">{__("Specific Trips")}</option>
617 </Select>
618 {formData.applicable_to === "specific_trips" && (
619 <div className="mt-3 space-y-2 relative">
620 {/* Selected trips display / Dropdown trigger */}
621 <div
622 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"
623 onClick={() => setShowTripDropdown(!showTripDropdown)}
624 >
625 {formData.trip_ids.length > 0 ? (
626 <div className="flex flex-wrap gap-1.5">
627 {formData.trip_ids.slice(0, 3).map((tripId) => {
628 const trip = tripOptions.find(
629 (t: any) => t.value === tripId,
630 );
631 return (
632 <span
633 key={tripId}
634 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"
635 >
636 {trip?.label || `Trip #${tripId}`}
637 <button
638 type="button"
639 onClick={(e) => {
640 e.stopPropagation();
641 handleFieldChange(
642 "trip_ids",
643 formData.trip_ids.filter(
644 (id) => id !== tripId,
645 ),
646 );
647 }}
648 className="hover:text-blue-600 dark:hover:text-blue-200"
649 >
650 <X className="w-3 h-3" />
651 </button>
652 </span>
653 );
654 })}
655 {formData.trip_ids.length > 3 && (
656 <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">
657 +{formData.trip_ids.length - 3} {__("more")}
658 </span>
659 )}
660 </div>
661 ) : (
662 <span className="text-sm text-gray-500 dark:text-gray-400">
663 {__("Click to select trips...")}
664 </span>
665 )}
666 </div>
667
668 {/* Dropdown panel */}
669 {showTripDropdown && (
670 <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">
671 {/* Search Input */}
672 <div className="p-2 border-b border-gray-200 dark:border-gray-700">
673 <div className="relative">
674 <Input
675 type="text"
676 placeholder={__("Search trips...")}
677 value={tripSearchQuery}
678 onChange={(e) =>
679 setTripSearchQuery(e.target.value)
680 }
681 className="w-full text-sm"
682 onClick={(e) => e.stopPropagation()}
683 />
684 {tripsQuery.isFetching && (
685 <div className="absolute right-3 top-1/2 transform -translate-y-1/2">
686 <Loader2 className="w-4 h-4 animate-spin text-gray-400" />
687 </div>
688 )}
689 </div>
690 </div>
691
692 {/* Trip list */}
693 <div className="max-h-[200px] overflow-y-auto">
694 {tripsQuery.isLoading ? (
695 <div className="p-4 text-center text-sm text-gray-500 dark:text-gray-400">
696 <Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />
697 {__("Loading trips...")}
698 </div>
699 ) : tripOptions.length > 0 ? (
700 <div className="divide-y divide-gray-100 dark:divide-gray-700">
701 {tripOptions.map((trip: any) => (
702 <label
703 key={trip.value}
704 className="flex items-center justify-between gap-3 px-3 py-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50"
705 onClick={(e) => e.stopPropagation()}
706 >
707 <div className="flex items-center gap-3">
708 <input
709 type="checkbox"
710 checked={formData.trip_ids.includes(
711 trip.value,
712 )}
713 onChange={(e) => {
714 if (e.target.checked) {
715 handleFieldChange("trip_ids", [
716 ...formData.trip_ids,
717 trip.value,
718 ]);
719 } else {
720 handleFieldChange(
721 "trip_ids",
722 formData.trip_ids.filter(
723 (id) => id !== trip.value,
724 ),
725 );
726 }
727 }}
728 className="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500"
729 />
730 <span className="text-sm text-gray-700 dark:text-gray-300">
731 {trip.label}
732 </span>
733 </div>
734 <span className="text-xs text-gray-400 dark:text-gray-500">
735 #{trip.value}
736 </span>
737 </label>
738 ))}
739 </div>
740 ) : (
741 <div className="p-4 text-center text-sm text-gray-500 dark:text-gray-400">
742 {__("No trips found")}
743 </div>
744 )}
745 </div>
746
747 {/* Close button */}
748 <div className="p-2 border-t border-gray-200 dark:border-gray-700">
749 <Button
750 type="button"
751 variant="outline"
752 size="sm"
753 className="w-full"
754 onClick={() => setShowTripDropdown(false)}
755 >
756 {__("Done")}
757 </Button>
758 </div>
759 </div>
760 )}
761 </div>
762 )}
763 <p className="text-xs text-gray-500 dark:text-gray-400">
764 {formData.applicable_to === "all"
765 ? __("This service will be available for all trips.")
766 : __(
767 "This service will only be available for the selected trips.",
768 )}
769 </p>
770 </CardContent>
771 </Card>
772
773 {/* Icon/Image */}
774 <Card>
775 <CardHeader>
776 <CardTitle>{__("Service Icon or Image")}</CardTitle>
777 </CardHeader>
778 <CardContent>
779 <IconPicker
780 value={formData.icon}
781 onChange={(value) => handleFieldChange("icon", value)}
782 label={__("Service Icon or Image")}
783 helpText={__(
784 "Select an icon from the library or upload a custom image for this service.",
785 )}
786 allowImageUpload={true}
787 allowIconSelection={true}
788 size="md"
789 />
790 </CardContent>
791 </Card>
792
793 {/* Submit Button */}
794 <Card>
795 <CardContent className="pt-6">
796 <Button
797 type="submit"
798 className="w-full"
799 disabled={saveMutation.isPending}
800 >
801 {saveMutation.isPending ? (
802 <>
803 <Loader2 className="w-4 h-4 mr-2 animate-spin" />
804 {__("Saving...")}
805 </>
806 ) : (
807 <>
808 <Save className="w-4 h-4 mr-2" />
809 {isEditMode ? __("Update Service") : __("Create Service")}
810 </>
811 )}
812 </Button>
813 </CardContent>
814 </Card>
815 </div>
816 </div>
817 </form>
818 </div>
819 );
820 };
821
822 export default AdditionalServicesForm;
823