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/TripForm.tsx +723 -179 3.0.2.7 → 3.0.17 View file →
@@ -33,8 +33,9 @@
33 33 return cleanText;
34 34 };
35 35 import {
36 36 Save,
37 + Send,
37 38 Loader2,
38 39 Sparkles,
39 40 Calendar,
40 41 Clock,
@@ -73,9 +74,14 @@
73 74 Lightbulb,
74 75 } from "lucide-react";
75 76 import { RichTextEditor } from "../components/ui/rich-text-editor";
76 77 import { IconPicker, IconPickerValue } from "../components/ui/icon-picker";
77 -import { __ } from "../lib/i18n";
78 +import { AiFieldAffordance } from "../components/ai/AiFieldAffordance";
79 +import { AutoFillTripModal } from "../components/ai/AutoFillTripModal";
80 +import { BuildItineraryModal } from "../components/ai/BuildItineraryModal";
81 +import { isAiEligible, isAiModuleEnabled } from "../lib/ai-availability";
82 +import { __, sprintf } from "../lib/i18n";
83 +import { MEAL_PLAN_SELECT_OPTIONS } from "../lib/meal-plan-labels";
78 84 import { usePermissions } from "../hooks/usePermissions";
79 85 import { fetchSettings } from "../api/settings-api";
80 86 import { apiClient } from "../lib/api-client";
81 87 import { wpService } from "../lib/api-client";
@@ -103,8 +109,9 @@
103 109 import { useToast } from "../components/ui/toast";
104 110 import { MultiSelect } from "../components/ui/multi-select";
105 111 import { TestimonialsSelector } from "../components/trip-form/TestimonialsSelector";
106 112 import { LocationPicker } from "../components/trip-form/LocationPicker";
113 +import { ProFeature, ProBadge } from "../components/ProFeature";
107 114 import { getErrorContext } from "../lib/errors";
108 115 import {
109 116 buildYatraSinglePublicUrls,
110 117 isWordPressPlainPermalink,
@@ -268,8 +275,70 @@
268 275 time: string; // HH:MM format
269 276 label: string;
270 277 }
271 278
279 +/**
280 + * Pull a context payload out of the live formData for AI generation
281 + * calls. Kept narrow on purpose — fewer tokens, no PII, and only the
282 + * fields the prompt templates actually reference. Helpers stay free of
283 + * React state so AiFieldAffordance's `buildContext` callback can be
284 + * invoked from anywhere without re-deriving form deps.
285 + */
286 +function buildTripAiContext(formData: any): Record<string, unknown> {
287 + const amenityTitles = (items: any[] | undefined): string[] =>
288 + Array.isArray(items)
289 + ? items
290 + .map((it) => (typeof it === "string" ? it : it?.title || it?.label))
291 + .filter((s): s is string => typeof s === "string" && s.trim() !== "")
292 + : [];
293 +
294 + return {
295 + name: formData?.title ?? "",
296 + short_description: formData?.short_description ?? "",
297 + description: stripHtml(formData?.description ?? ""),
298 + destinations: namesFromIds(formData?.destinations),
299 + categories: namesFromIds(formData?.categories),
300 + activities: namesFromIds(formData?.activities),
301 + difficulty_level: formData?.difficulty_level ?? "",
302 + duration_days: formData?.duration_days ?? "",
303 + duration_nights: formData?.duration_nights ?? "",
304 + duration_hours: formData?.duration_hours ?? "",
305 + best_season: formData?.best_season ?? "",
306 + price: formData?.price ?? "",
307 + deposit_percentage: formData?.deposit_percentage ?? "",
308 + booking_deadline_hours: formData?.booking_deadline_hours ?? "",
309 + age_min: formData?.age_min ?? "",
310 + age_max: formData?.age_max ?? "",
311 + accommodation_type: formData?.accommodation_type ?? "",
312 + transportation_included: formData?.transportation_included ?? "",
313 + included_items: amenityTitles(formData?.included_items),
314 + excluded_items: amenityTitles(formData?.excluded_items),
315 + };
316 +}
317 +
318 +function stripHtml(value: string): string {
319 + if (!value) return "";
320 + return value
321 + .replace(/<[^>]*>/g, " ")
322 + .replace(/\s+/g, " ")
323 + .trim();
324 +}
325 +
326 +/** Best-effort name resolution for taxonomy fields. The form stores IDs
327 + * in some places and objects in others; we accept either. */
328 +function namesFromIds(value: any): string[] {
329 + if (!Array.isArray(value)) return [];
330 + return value
331 + .map((item) => {
332 + if (typeof item === "string") return item;
333 + if (item && typeof item === "object") {
334 + return item.name || item.label || item.title || "";
335 + }
336 + return "";
337 + })
338 + .filter((s): s is string => typeof s === "string" && s.trim() !== "");
339 +}
340 +
272 341 interface TripFormData {
273 342 // Overview
274 343 title: string;
275 344 slug: string;
@@ -298,8 +367,9 @@
298 367 // Duration & Schedule
299 368 trip_type: "single_day" | "multi_day" | "flexible";
300 369 duration_days: string;
301 370 duration_nights: string;
371 + duration_hours: string;
302 372 available_from: string;
303 373 available_to: string;
304 374 booking_window_days: string;
305 375 seasonal_availability: string;
@@ -341,8 +411,9 @@
341 411 age_max: string;
342 412 physical_requirements: string;
343 413 visa_requirements: string;
344 414 vaccination_requirements: string;
415 + disable_booking: boolean; // Pro: enquiry-only mode (stored in custom_fields)
345 416
346 417 // Fallback Settings (for trips without availability dates/rules)
347 418 has_default_time_slots: boolean; // For day tours: enable multiple time slots
348 419 default_time_slots: TimeSlot[]; // Array of time slot objects for day tours
@@ -465,9 +536,9 @@
465 536 location_longitude?: string;
466 537 duration?: string;
467 538 start_time: string;
468 539 end_time: string;
469 - time_type: "exact" | "approximate" | "all_day" | "flexible";
540 + time_type: "exact" | "duration" | "flexible";
470 541 cost?: string;
471 542 cost_per_person: boolean;
472 543 notes?: string;
473 544 included_items: string[];
@@ -485,13 +556,24 @@
485 556 // getCurrencySymbol is now imported from '../data/currencies'
486 557
487 558 const TripForm: React.FC = () => {
488 559 const queryClient = useQueryClient();
489 - const { can } = usePermissions();
560 + const { can, isPro } = usePermissions();
490 561 const { showToast } = useToast();
491 562 // Downloads is now a FREE feature - always show the UI
492 563 const showDownloadsUI = true;
493 564
565 + // AI Assistant — modal state for the "Auto-fill" / "Generate itinerary"
566 + // workflows. `aiModalMode` controls which preset the modal opens with.
567 + const [aiModalOpen, setAiModalOpen] = useState(false);
568 + const [aiModalMode, setAiModalMode] = useState<"all" | "itinerary">("all");
569 + // Itinerary-builder modal is the SAME component the standalone
570 + // Itinerary page uses. Going through it (instead of
571 + // AutoFillTripModal's itineraryOnly mode) means all three entry
572 + // points for "build itinerary with AI" — wizard, this trip-form
573 + // tab, and the Itinerary page — converge on the same agent +
574 + // applyItinerary persistence path.
575 + const [itineraryBuildOpen, setItineraryBuildOpen] = useState(false);
494 576 const [featuredImagePreview, setFeaturedImagePreview] = useState<string>("");
495 577 const [isResolvingFeaturedImage, setIsResolvingFeaturedImage] =
496 578 useState<boolean>(false);
497 579 const featuredImageCache = useRef<Record<number, string>>({});
@@ -635,8 +717,9 @@
635 717 ],
636 718 trip_type: "multi_day",
637 719 duration_days: "7",
638 720 duration_nights: "6",
721 + duration_hours: "",
639 722 available_from: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
640 723 .toISOString()
641 724 .split("T")[0], // 30 days from now
642 725 available_to: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000)
@@ -685,8 +768,9 @@
685 768 visa_requirements:
686 769 "Visa on arrival available for most nationalities. Valid passport required with at least 6 months validity.",
687 770 vaccination_requirements:
688 771 "No mandatory vaccinations. Recommended: Hepatitis A, Typhoid, and routine vaccinations.",
772 + disable_booking: false,
689 773 has_default_time_slots: false,
690 774 default_time_slots: [],
691 775 departure_time: "",
692 776 included_items: [
@@ -891,8 +975,9 @@
891 975 ],
892 976 trip_type: "multi_day",
893 977 duration_days: "14",
894 978 duration_nights: "13",
979 + duration_hours: "",
895 980 available_from: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000)
896 981 .toISOString()
897 982 .split("T")[0],
898 983 available_to: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000)
@@ -936,8 +1021,9 @@
936 1021 visa_requirements:
937 1022 "Tourist visa required for Nepal. Can be obtained on arrival at airport or in advance. Valid passport required.",
938 1023 vaccination_requirements:
939 1024 "Recommended: Hepatitis A, Typhoid, Japanese Encephalitis, and routine vaccinations. Consult with travel health clinic.",
1025 + disable_booking: false,
940 1026 has_default_time_slots: false,
941 1027 default_time_slots: [],
942 1028 departure_time: "",
943 1029 included_items: [
@@ -1152,8 +1238,9 @@
1152 1238 ],
1153 1239 trip_type: "multi_day",
1154 1240 duration_days: "10",
1155 1241 duration_nights: "9",
1242 + duration_hours: "",
1156 1243 available_from: new Date(Date.now() + 45 * 24 * 60 * 60 * 1000)
1157 1244 .toISOString()
1158 1245 .split("T")[0],
1159 1246 available_to: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000)
@@ -1198,8 +1285,9 @@
1198 1285 visa_requirements:
1199 1286 "Schengen visa required for most non-EU nationals. Apply well in advance as processing can take several weeks.",
1200 1287 vaccination_requirements:
1201 1288 "No mandatory vaccinations. Routine vaccinations recommended.",
1289 + disable_booking: false,
1202 1290 has_default_time_slots: false,
1203 1291 default_time_slots: [],
1204 1292 departure_time: "",
1205 1293 included_items: [
@@ -1390,8 +1478,9 @@
1390 1478 landmarks: [],
1391 1479 trip_type: "multi_day",
1392 1480 duration_days: "",
1393 1481 duration_nights: "",
1482 + duration_hours: "",
1394 1483 available_from: "",
1395 1484 available_to: "",
1396 1485 booking_window_days: "",
1397 1486 seasonal_availability: "",
@@ -1425,8 +1514,9 @@
1425 1514 age_max: "65",
1426 1515 physical_requirements: "Moderate",
1427 1516 visa_requirements: "Schengen visa",
1428 1517 vaccination_requirements: "COVID-19 vaccination",
1518 + disable_booking: false,
1429 1519 has_default_time_slots: false,
1430 1520 default_time_slots: [],
1431 1521 departure_time: "09:00",
1432 1522 included_items: [],
@@ -2132,8 +2222,12 @@
2132 2222 ? "single_day"
2133 2223 : "multi_day")) as "single_day" | "multi_day" | "flexible",
2134 2224 duration_days: tripData.duration_days?.toString() || "",
2135 2225 duration_nights: tripData.duration_nights?.toString() || "",
2226 + // 0 / null both mean "not hour-based" — show an empty field, not "0".
2227 + duration_hours: tripData.duration_hours
2228 + ? String(tripData.duration_hours)
2229 + : "",
2136 2230 available_from: tripData.available_from || "",
2137 2231 available_to: tripData.available_to || "",
2138 2232 booking_window_days: tripData.booking_window_days?.toString() || "",
2139 2233 seasonal_availability: tripData.seasonal_availability || "",
@@ -2179,9 +2273,16 @@
2179 2273 age_max: tripData.age_max?.toString() || "",
2180 2274 physical_requirements: tripData.physical_requirements || "",
2181 2275 visa_requirements: tripData.visa_requirements || "",
2182 2276 vaccination_requirements: tripData.vaccination_requirements || "",
2183 - has_default_time_slots: tripData.has_default_time_slots || false,
2277 + disable_booking: Boolean(tripData.custom_fields?.disable_booking),
2278 + // tinyint(1) columns can serialize from PHP/wpdb as the string "0"/"1".
2279 + // JS treats "0" as truthy, so a plain `value || false` would leave the
2280 + // checkbox stuck on after the user un-checked + saved. Coerce explicitly.
2281 + has_default_time_slots:
2282 + tripData.has_default_time_slots === true ||
2283 + tripData.has_default_time_slots === 1 ||
2284 + tripData.has_default_time_slots === "1",
2184 2285 default_time_slots: Array.isArray(tripData.default_time_slots)
2185 2286 ? tripData.default_time_slots
2186 2287 : tripData.default_time_slots
2187 2288 ? JSON.parse(tripData.default_time_slots)
@@ -2323,8 +2424,9 @@
2323 2424 }
2324 2425 } else if (!tripData.featured_image) {
2325 2426 setFeaturedImagePreview("");
2326 2427 }
2428 + // eslint-disable-next-line react-hooks/exhaustive-deps
2327 2429 }, [tripData, tripAttributesData, destinationsData, isEditMode, tripId]);
2328 2430
2329 2431 useEffect(() => {
2330 2432 let isMounted = true;
@@ -2387,8 +2489,9 @@
2387 2489 "featured_image",
2388 2490 "trip_type",
2389 2491 "duration_days",
2390 2492 "duration_nights",
2493 + "duration_hours",
2391 2494 ],
2392 2495 location: ["destinations", "starting_location", "ending_location"],
2393 2496 duration: ["available_from", "available_to", "booking_window_days"],
2394 2497 pricing: ["original_price", "discounted_price", "price_types"],
@@ -2601,8 +2704,9 @@
2601 2704 duration_nights: String(Math.max(0, days - 1)),
2602 2705 }));
2603 2706 }
2604 2707 }
2708 + // eslint-disable-next-line react-hooks/exhaustive-deps
2605 2709 }, [formData.duration_days, formData.trip_type]);
2606 2710
2607 2711 // Auto-calculate days from nights (only if days is empty)
2608 2712 useEffect(() => {
@@ -2615,8 +2719,9 @@
2615 2719 if (nights > 0 && !isNaN(nights)) {
2616 2720 setFormData((prev) => ({ ...prev, duration_days: String(nights + 1) }));
2617 2721 }
2618 2722 }
2723 + // eslint-disable-next-line react-hooks/exhaustive-deps
2619 2724 }, [formData.duration_nights, formData.trip_type]);
2620 2725
2621 2726 // Auto-generate slug from title
2622 2727 const generateSlug = (title: string): string => {
@@ -3161,11 +3266,11 @@
3161 3266 }
3162 3267
3163 3268 if (!formData.slug.trim()) {
3164 3269 newErrors.slug = __("Slug is required", "yatra");
3165 - } else if (!/^[a-z0-9-]+$/.test(formData.slug)) {
3270 + } else if (!/^[\p{L}\p{N}-]+$/u.test(formData.slug)) {
3166 3271 newErrors.slug = __(
3167 - "Slug can only contain lowercase letters, numbers, and hyphens",
3272 + "Slug can only contain letters, numbers, and hyphens",
3168 3273 "yatra",
3169 3274 );
3170 3275 }
3171 3276
@@ -3221,8 +3326,20 @@
3221 3326 duration_days: data.duration_days ? parseInt(data.duration_days) : null,
3222 3327 duration_nights: data.duration_nights
3223 3328 ? parseInt(data.duration_nights)
3224 3329 : null,
3330 + // Hour-based day tours. This key was missing from the payload, so the
3331 + // Duration (Hours) field could never be saved: the form kept the value,
3332 + // the request dropped it, and the field reloaded empty. Send 0 (never
3333 + // null) when the field is blank — the validator treats null as "not
3334 + // provided" and would leave a stale hours value on a trip switched
3335 + // back to multi-day, which the storefront would then show as hours.
3336 + duration_hours:
3337 + data.duration_hours !== "" &&
3338 + data.duration_hours != null &&
3339 + !isNaN(parseInt(data.duration_hours))
3340 + ? parseInt(data.duration_hours)
3341 + : 0,
3225 3342 available_from: data.available_from || null,
3226 3343 available_to: data.available_to || null,
3227 3344 booking_window_days: data.booking_window_days
3228 3345 ? parseInt(data.booking_window_days)
@@ -3276,9 +3393,9 @@
3276 3393 category_id: pt.category_id,
3277 3394 is_default: Boolean((pt as any).is_default),
3278 3395 original_price: pt.original_price
3279 3396 ? parseFloat(pt.original_price)
3280 - : 0,
3397 + : null,
3281 3398 discounted_price: pt.discounted_price
3282 3399 ? parseFloat(pt.discounted_price)
3283 3400 : null,
3284 3401 }))
@@ -3293,8 +3410,9 @@
3293 3410 max_travelers: data.max_travelers ? parseInt(data.max_travelers) : null,
3294 3411 min_travelers: data.min_travelers ? parseInt(data.min_travelers) : null,
3295 3412 booking_deadline_hours: data.booking_deadline_hours || null,
3296 3413 cancellation_policy: data.cancellation_policy || "",
3414 + custom_fields: { disable_booking: data.disable_booking },
3297 3415 age_min: data.age_min ? parseInt(data.age_min) : null,
3298 3416 age_max: data.age_max ? parseInt(data.age_max) : null,
3299 3417 physical_requirements: data.physical_requirements.trim(),
3300 3418 visa_requirements: data.visa_requirements.trim(),
@@ -3456,8 +3574,9 @@
3456 3574 setIsSubmitting(true);
3457 3575 saveMutation.mutate({ ...formData, status: "draft" });
3458 3576 };
3459 3577
3578 + // eslint-disable-next-line react-hooks/exhaustive-deps
3460 3579 const handlePublish = async () => {
3461 3580 if (!validateForm()) {
3462 3581 const firstError = Object.keys(errors)[0];
3463 3582 if (firstError) {
@@ -3992,9 +4111,9 @@
3992 4111 </div>
3993 4112 <HelpText
3994 4113 text={__(
3995 4114 "💡 This is automatically created from your title. You usually don't need to change it unless you want a custom web address.",
3996 - "💡 This is automatically created from your title. You usually don't need to change it unless you want a custom web address.",
4115 + "yatra",
3997 4116 )}
3998 4117 className="mb-2"
3999 4118 />
4000 4119 <Input
@@ -4054,9 +4173,9 @@
4054 4173
4055 4174 {/* Short Description */}
4056 4175 <div className="mb-4">
4057 4176 <div className="flex items-center justify-between mb-1.5">
4058 - <label className="block text-xs font-normal text-gray-500 dark:text-gray-400 flex items-center gap-1.5">
4177 + <label className="text-xs font-normal text-gray-500 dark:text-gray-400 flex items-center gap-1.5">
4059 4178 {__("Short Description", "yatra")}
4060 4179 <Badge
4061 4180 variant="outline"
4062 4181 className="ml-1 text-[10px] px-1.5 py-0 bg-amber-50 dark:bg-amber-900/20 text-amber-600 dark:text-amber-400 border-amber-200 dark:border-amber-800"
@@ -4064,9 +4183,9 @@
4064 4183 {__("Recommended", "yatra")}
4065 4184 </Badge>
4066 4185 <button
4067 4186 type="button"
4068 - className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 ml-auto"
4187 + className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
4069 4188 title={__(
4070 4189 "A brief summary that appears in listings. Recommended: 100-150 characters for best results.",
4071 4190 "yatra",
4072 4191 )}
@@ -4073,22 +4192,33 @@
4073 4192 >
4074 4193 <HelpCircle className="w-3.5 h-3.5" />
4075 4194 </button>
4076 4195 </label>
4077 - <span
4078 - className={`text-xs font-medium ${
4079 - formData.short_description.length > 200
4080 - ? "text-red-600 dark:text-red-400"
4081 - : formData.short_description.length >= 100 &&
4082 - formData.short_description.length <= 150
4083 - ? "text-green-600 dark:text-green-400"
4084 - : formData.short_description.length > 0
4085 - ? "text-yellow-600 dark:text-yellow-400"
4086 - : "text-gray-500 dark:text-gray-400"
4087 - }`}
4088 - >
4089 - {formData.short_description.length}/200
4090 - </span>
4196 + <div className="flex items-center gap-2">
4197 + <span
4198 + className={`text-xs font-medium ${
4199 + formData.short_description.length > 200
4200 + ? "text-red-600 dark:text-red-400"
4201 + : formData.short_description.length >= 100 &&
4202 + formData.short_description.length <= 150
4203 + ? "text-green-600 dark:text-green-400"
4204 + : formData.short_description.length > 0
4205 + ? "text-yellow-600 dark:text-yellow-400"
4206 + : "text-gray-500 dark:text-gray-400"
4207 + }`}
4208 + >
4209 + {formData.short_description.length}/200
4210 + </span>
4211 + <AiFieldAffordance
4212 + task="trip-short-description"
4213 + label={__("Short Description", "yatra")}
4214 + value={formData.short_description}
4215 + onAccept={(v) =>
4216 + handleFieldChange("short_description", v)
4217 + }
4218 + buildContext={() => buildTripAiContext(formData)}
4219 + />
4220 + </div>
4091 4221 </div>
4092 4222 <HelpText
4093 4223 text={__(
4094 4224 '💡 Write 2-3 sentences that grab attention! This appears in trip listings. Example: "Escape to paradise with our 7-day Bali beach retreat. Experience stunning sunsets, world-class diving, and authentic local culture."',
@@ -4120,17 +4250,26 @@
4120 4250 </div>
4121 4251
4122 4252 {/* Tour Description */}
4123 4253 <div className="mb-4">
4124 - <label className="block text-xs font-normal text-gray-500 dark:text-gray-400 mb-1.5 flex items-center gap-1.5">
4125 - {__("Trip Description", "yatra")}
4126 - <Badge
4127 - variant="outline"
4128 - className="ml-1 text-[10px] px-1.5 py-0 bg-amber-50 dark:bg-amber-900/20 text-amber-600 dark:text-amber-400 border-amber-200 dark:border-amber-800"
4129 - >
4130 - {__("Recommended", "yatra")}
4131 - </Badge>
4132 - </label>
4254 + <div className="flex items-center justify-between mb-1.5">
4255 + <label className="text-xs font-normal text-gray-500 dark:text-gray-400 flex items-center gap-1.5">
4256 + {__("Trip Description", "yatra")}
4257 + <Badge
4258 + variant="outline"
4259 + className="ml-1 text-[10px] px-1.5 py-0 bg-amber-50 dark:bg-amber-900/20 text-amber-600 dark:text-amber-400 border-amber-200 dark:border-amber-800"
4260 + >
4261 + {__("Recommended", "yatra")}
4262 + </Badge>
4263 + </label>
4264 + <AiFieldAffordance
4265 + task="trip-description"
4266 + label={__("Description", "yatra")}
4267 + value={formData.description}
4268 + onAccept={(v) => handleFieldChange("description", v)}
4269 + buildContext={() => buildTripAiContext(formData)}
4270 + />
4271 + </div>
4133 4272 <HelpText
4134 4273 text={__(
4135 4274 "💡 Tell travelers what makes your trip special! Describe the experience, highlights, and what they'll see. Write 2-4 paragraphs. Be enthusiastic and detailed!",
4136 4275 "💡 Tell travelers what makes your trip special! Describe the experience, highlights, and what they'll see. Write 2-4 paragraphs. Be enthusiastic and detailed!",
@@ -4268,15 +4407,36 @@
4268 4407
4269 4408 {/* Trip Highlights */}
4270 4409 <Card className="mt-6">
4271 4410 <CardHeader>
4272 - <CardTitle>{__("Trip Highlights", "yatra")}</CardTitle>
4273 - <CardDescription>
4274 - {__(
4275 - "Add key highlights that make your trip special. These will be displayed prominently on your trip page.",
4276 - "yatra",
4277 - )}
4278 - </CardDescription>
4411 + <div className="flex items-start justify-between gap-2">
4412 + <div className="flex-1">
4413 + <CardTitle>{__("Trip Highlights", "yatra")}</CardTitle>
4414 + <CardDescription>
4415 + {__(
4416 + "Add key highlights that make your trip special. These will be displayed prominently on your trip page.",
4417 + "yatra",
4418 + )}
4419 + </CardDescription>
4420 + </div>
4421 + {/* AI: generates 5-7 highlight strings. The
4422 + affordance hands back a multi-line string;
4423 + we split into the highlights array shape
4424 + TripForm stores. */}
4425 + <AiFieldAffordance
4426 + task="trip-highlights"
4427 + label={__("Highlights", "yatra")}
4428 + value={(formData.highlights ?? []).join("\n")}
4429 + onAccept={(raw) => {
4430 + const list = raw
4431 + .split(/\r?\n/)
4432 + .map((l) => l.replace(/^[\s\-\*•·●]+/, "").trim())
4433 + .filter((l) => l !== "");
4434 + handleFieldChange("highlights", list as any);
4435 + }}
4436 + buildContext={() => buildTripAiContext(formData)}
4437 + />
4438 + </div>
4279 4439 </CardHeader>
4280 4440 <CardContent className="space-y-3">
4281 4441 {formData.highlights.length > 0 ? (
4282 4442 <div className="space-y-2">
@@ -4415,11 +4575,17 @@
4415 4575 type="radio"
4416 4576 name="trip_type"
4417 4577 value="multi_day"
4418 4578 checked={formData.trip_type === "multi_day"}
4419 - onChange={(e) =>
4420 - handleFieldChange("trip_type", e.target.value)
4421 - }
4579 + onChange={(e) => {
4580 + handleFieldChange("trip_type", e.target.value);
4581 + // A multi-day trip is never hour-based — drop any
4582 + // hours set while it was a single-day tour.
4583 + setFormData((prev) => ({
4584 + ...prev,
4585 + duration_hours: "",
4586 + }));
4587 + }}
4422 4588 className="sr-only"
4423 4589 />
4424 4590 <div className="flex flex-1">
4425 4591 <div className="flex flex-col">
@@ -4562,8 +4728,46 @@
4562 4728 </p>
4563 4729 )}
4564 4730 </div>
4565 4731 </div>
4732 +
4733 + {/* Duration (Hours) — hour-based single-day tours */}
4734 + {formData.trip_type === "single_day" && (
4735 + <div className="mt-4">
4736 + <label
4737 + htmlFor="duration_hours"
4738 + className="block text-xs font-normal text-gray-500 dark:text-gray-400 mb-1.5"
4739 + >
4740 + {__("Duration (Hours)", "yatra")}
4741 + </label>
4742 + <Input
4743 + id="duration_hours"
4744 + type="number"
4745 + min="1"
4746 + max="24"
4747 + value={formData.duration_hours}
4748 + onChange={(e) =>
4749 + handleFieldChange("duration_hours", e.target.value)
4750 + }
4751 + placeholder={__("e.g., 8", "yatra")}
4752 + className={
4753 + errors.duration_hours ? "border-red-500" : ""
4754 + }
4755 + />
4756 + <p className="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
4757 + {__(
4758 + "Optional — for an hour-based day tour (e.g. an 8-hour experience). When set, the tour's duration shows in hours and Google Calendar creates a same-day timed event instead of an all-day one. Leave empty for a normal day tour.",
4759 + "yatra",
4760 + )}
4761 + </p>
4762 + {errors.duration_hours && (
4763 + <p className="mt-1.5 text-sm text-red-600 dark:text-red-400 flex items-center gap-1">
4764 + <AlertCircle className="w-4 h-4" />
4765 + {errors.duration_hours}
4766 + </p>
4767 + )}
4768 + </div>
4769 + )}
4566 4770 </div>
4567 4771 </div>
4568 4772 </div>
4569 4773 </div>
@@ -4628,9 +4832,14 @@
4628 4832 }
4629 4833 options={destinationsData.map((destination: any) => ({
4630 4834 value: destination.id.toString(),
4631 4835 label:
4632 - destination.name || `Destination #${destination.id}`,
4836 + destination.name ||
4837 + sprintf(
4838 + // translators: %s: numeric destination ID, used as a fallback when the destination has no name.
4839 + __("Destination #%s", "yatra"),
4840 + String(destination.id),
4841 + ),
4633 4842 }))}
4634 4843 placeholder={__("Select destinations...", "yatra")}
4635 4844 searchPlaceholder={__("Search destinations...", "yatra")}
4636 4845 error={!!errors.destinations}
@@ -4737,9 +4946,9 @@
4737 4946 <div className="flex items-center gap-2">
4738 4947 {formData.starting_location && (
4739 4948 <div
4740 4949 className="w-2 h-2 bg-green-500 rounded-full animate-pulse"
4741 - title="Location set"
4950 + title={__("Location set", "yatra")}
4742 4951 ></div>
4743 4952 )}
4744 4953 {formData.starting_latitude &&
4745 4954 formData.starting_longitude && (
@@ -4744,9 +4953,9 @@
4744 4953 {formData.starting_latitude &&
4745 4954 formData.starting_longitude && (
4746 4955 <div
4747 4956 className="w-2 h-2 bg-blue-500 rounded-full animate-pulse"
4748 - title="Coordinates set"
4957 + title={__("Coordinates set", "yatra")}
4749 4958 ></div>
4750 4959 )}
4751 4960 </div>
4752 4961 </div>
@@ -4813,11 +5022,16 @@
4813 5022 ? [
4814 5023 parseFloat(formData.starting_latitude),
4815 5024 parseFloat(formData.starting_longitude),
4816 5025 ]
4817 - : [-8.3405, 115.092]
5026 + : [20, 0]
4818 5027 }
4819 - defaultZoom={13}
5028 + defaultZoom={
5029 + formData.starting_latitude &&
5030 + formData.starting_longitude
5031 + ? 13
5032 + : 2
5033 + }
4820 5034 mapHeight="300px"
4821 5035 showMapButton={false}
4822 5036 searchLimit={8}
4823 5037 __={__}
@@ -5000,9 +5214,9 @@
5000 5214 <div className="flex items-center gap-2">
5001 5215 {formData.ending_location && (
5002 5216 <div
5003 5217 className="w-2 h-2 bg-green-500 rounded-full animate-pulse"
5004 - title="Location set"
5218 + title={__("Location set", "yatra")}
5005 5219 ></div>
5006 5220 )}
5007 5221 </div>
5008 5222 </div>
@@ -5069,11 +5283,25 @@
5069 5283 ? [
5070 5284 parseFloat(formData.ending_latitude),
5071 5285 parseFloat(formData.ending_longitude),
5072 5286 ]
5073 - : [-8.5069, 115.2625]
5287 + : formData.starting_latitude &&
5288 + formData.starting_longitude
5289 + ? [
5290 + parseFloat(formData.starting_latitude),
5291 + parseFloat(formData.starting_longitude),
5292 + ]
5293 + : [20, 0]
5074 5294 }
5075 - defaultZoom={13}
5295 + defaultZoom={
5296 + formData.ending_latitude &&
5297 + formData.ending_longitude
5298 + ? 13
5299 + : formData.starting_latitude &&
5300 + formData.starting_longitude
5301 + ? 13
5302 + : 2
5303 + }
5076 5304 mapHeight="300px"
5077 5305 showMapButton={false}
5078 5306 searchLimit={8}
5079 5307 __={__}
@@ -5539,9 +5767,9 @@
5539 5767 onClick={() => {
5540 5768 const newSlot: TimeSlot = {
5541 5769 id: `slot-${Date.now()}`,
5542 5770 time: "09:00",
5543 - label: "Morning Tour",
5771 + label: __("Morning Tour", "yatra"),
5544 5772 };
5545 5773 handleFieldChange("default_time_slots", [
5546 5774 newSlot,
5547 5775 ]);
@@ -5879,11 +6107,22 @@
5879 6107 </div>
5880 6108
5881 6109 {/* Cancellation Policy */}
5882 6110 <div>
5883 - <h4 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
5884 - {__("Cancellation Policy", "yatra")}
5885 - </h4>
6111 + <div className="flex items-center justify-between mb-3">
6112 + <h4 className="text-sm font-medium text-gray-900 dark:text-white">
6113 + {__("Cancellation Policy", "yatra")}
6114 + </h4>
6115 + <AiFieldAffordance
6116 + task="trip-cancellation-policy"
6117 + label={__("Cancellation Policy", "yatra")}
6118 + value={formData.cancellation_policy}
6119 + onAccept={(v) =>
6120 + handleFieldChange("cancellation_policy", v)
6121 + }
6122 + buildContext={() => buildTripAiContext(formData)}
6123 + />
6124 + </div>
5886 6125 <textarea
5887 6126 id="cancellation_policy"
5888 6127 value={formData.cancellation_policy}
5889 6128 onChange={(e) =>
@@ -5899,8 +6138,55 @@
5899 6138 </div>
5900 6139 </div>
5901 6140 </div>
5902 6141
6142 + {/* SECTION 3b: Disable Booking (Pro) */}
6143 + <div className="border border-gray-200 dark:border-gray-700 rounded-lg p-5 bg-gray-50 dark:bg-gray-800/30">
6144 + <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
6145 + <Calendar className="w-4 h-4 text-blue-500" />
6146 + {__("Booking Availability", "yatra")}
6147 + <ProBadge isProActive={isPro} />
6148 + </h3>
6149 + <ProFeature
6150 + description={__(
6151 + "disable the booking calendar and make trips enquiry-only",
6152 + "yatra",
6153 + )}
6154 + moduleName={__("Enquiry Only Mode", "yatra")}
6155 + pricingUrl="https://wpyatra.com/pricing"
6156 + isProActive={isPro}
6157 + isModuleEnabled={isPro}
6158 + >
6159 + <div className="flex items-start gap-3">
6160 + <div className="flex items-center h-5 mt-0.5">
6161 + <input
6162 + id="disable_booking"
6163 + type="checkbox"
6164 + checked={formData.disable_booking}
6165 + onChange={(e) =>
6166 + handleFieldChange("disable_booking", e.target.checked)
6167 + }
6168 + className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 cursor-pointer"
6169 + />
6170 + </div>
6171 + <div>
6172 + <label
6173 + htmlFor="disable_booking"
6174 + className="block text-sm font-medium text-gray-900 dark:text-white cursor-pointer"
6175 + >
6176 + {__("Disable Booking Calendar", "yatra")}
6177 + </label>
6178 + <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
6179 + {__(
6180 + "When enabled, the booking form and date picker will be hidden. Visitors can only send an enquiry for this trip.",
6181 + "yatra",
6182 + )}
6183 + </p>
6184 + </div>
6185 + </div>
6186 + </ProFeature>
6187 + </div>
6188 +
5903 6189 {/* SECTION 4: Accommodation */}
5904 6190 <div className="border border-gray-200 dark:border-gray-700 rounded-lg p-5 bg-gray-50 dark:bg-gray-800/30">
5905 6191 <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
5906 6192 <Home className="w-4 h-4 text-blue-500" />
@@ -5944,27 +6230,23 @@
5944 6230 onChange={(e) =>
5945 6231 handleFieldChange("meal_plan", e.target.value)
5946 6232 }
5947 6233 >
5948 - <option value="">
5949 - {__("Select meal plan", "yatra")}
5950 - </option>
5951 - <option value="breakfast">
5952 - {__("Breakfast Only", "yatra")}
5953 - </option>
5954 - <option value="half_board">
5955 - {__("Half Board (Breakfast + Dinner)", "yatra")}
5956 - </option>
5957 - <option value="full_board">
5958 - {__("Full Board (All Meals)", "yatra")}
5959 - </option>
5960 - <option value="all_inclusive">
5961 - {__("All Inclusive", "yatra")}
5962 - </option>
5963 - <option value="none">
5964 - {__("No Meals Included", "yatra")}
5965 - </option>
6234 + {MEAL_PLAN_SELECT_OPTIONS.map(({ value, label }) => (
6235 + <option
6236 + key={value || "meal-plan-empty"}
6237 + value={value}
6238 + >
6239 + {label}
6240 + </option>
6241 + ))}
5966 6242 </Select>
6243 + <p className="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
6244 + {__(
6245 + "Tells travelers which meals are included with lodging (e.g. breakfast only vs full board).",
6246 + "yatra",
6247 + )}
6248 + </p>
5967 6249 </div>
5968 6250 </div>
5969 6251 <div>
5970 6252 <label
@@ -7064,9 +7346,10 @@
7064 7346 <div className="space-y-4">
7065 7347 <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
7066 7348 <div>
7067 7349 <label className="block text-xs font-normal text-gray-500 dark:text-gray-400 mb-1.5">
7068 - {__("Original Price", "yatra")}
7350 + {__("Original Price", "yatra")} (
7351 + {__("Optional", "yatra")})
7069 7352 </label>
7070 7353 <div className="relative">
7071 7354 <span className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 text-sm">
7072 7355 {getCurrencySymbol(globalCurrency)}
@@ -7151,9 +7434,9 @@
7151 7434 type="button"
7152 7435 variant="outline"
7153 7436 onClick={() =>
7154 7437 (window.location.href =
7155 - "?subpage=traveler-categories&action=create")
7438 + "?page=yatra&subpage=traveler-categories&action=create")
7156 7439 }
7157 7440 className="flex items-center gap-2 mx-auto"
7158 7441 >
7159 7442 <Plus className="w-4 h-4" />
@@ -7394,9 +7677,10 @@
7394 7677 </div>
7395 7678 <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
7396 7679 <div>
7397 7680 <label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5">
7398 - {__("Original Price", "yatra")}
7681 + {__("Original Price", "yatra")} (
7682 + {__("Optional", "yatra")})
7399 7683 </label>
7400 7684 <div className="relative">
7401 7685 <span className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 text-sm">
7402 7686 {getCurrencySymbol(globalCurrency)}
@@ -7512,91 +7796,198 @@
7512 7796 )}
7513 7797 </div>
7514 7798 )}
7515 7799
7516 - {/* Deposit */}
7517 - <div className="border-t border-gray-200 dark:border-gray-700 pt-4">
7518 - <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">
7519 - {__("Deposit & Payment Terms", "yatra")}
7520 - </h3>
7521 - <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
7522 - <div>
7523 - <label
7524 - htmlFor="deposit_amount"
7525 - className="block text-xs font-normal text-gray-500 dark:text-gray-400 mb-1.5"
7800 + {/*
7801 + Deposit & Payment Terms — Pro feature (FlexiblePayments module).
7802 + The DB columns live in the free TripsTable schema so existing data
7803 + survives toggling Pro on/off. Editing is gated on
7804 + window.yatraAdmin.flexiblePaymentsEnabled (set true by Pro's
7805 + FlexiblePaymentsModule::addAdminData() on yatra_admin_localized_data).
7806 +
7807 + We deliberately render the section in all three states (free / Pro
7808 + installed-but-module-off / Pro+module-on) so the feature is
7809 + discoverable in the free plugin. When unlocked → editable. When
7810 + locked → fields shown disabled inside a <fieldset disabled>, with a
7811 + PRO badge on the heading and a context-aware info banner above:
7812 + - free: "Upgrade to Pro" link
7813 + - Pro installed but module disabled: "Activate Module" link
7814 + */}
7815 + {(() => {
7816 + const flexiblePaymentsEnabled = Boolean(
7817 + (window as any)?.yatraAdmin?.flexiblePaymentsEnabled,
7818 + );
7819 + const isLocked = !flexiblePaymentsEnabled;
7820 + const siteUrl =
7821 + (window as any)?.yatraAdmin?.siteUrl ||
7822 + "/wp-admin/admin.php?page=yatra";
7823 + const modulesPageUrl = `${siteUrl}/wp-admin/admin.php?page=yatra&subpage=modules`;
7824 + const pricingUrl = "https://mantrabrain.com/yatra-pricing/";
7825 +
7826 + return (
7827 + <div className="border-t border-gray-200 dark:border-gray-700 pt-4">
7828 + <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-3 flex items-center">
7829 + {__("Deposit & Payment Terms", "yatra")}
7830 + {isLocked && <ProBadge isProActive={isPro} />}
7831 + </h3>
7832 +
7833 + {isLocked && (
7834 + <div className="mb-3 p-3 bg-gradient-to-r from-purple-50 to-blue-50 dark:from-purple-900/20 dark:to-blue-900/20 border border-purple-200 dark:border-purple-800 rounded-md text-xs text-purple-900 dark:text-purple-100 flex items-start gap-2">
7835 + <svg
7836 + className="w-4 h-4 mt-0.5 text-purple-500 flex-shrink-0"
7837 + fill="none"
7838 + stroke="currentColor"
7839 + viewBox="0 0 24 24"
7840 + aria-hidden="true"
7841 + >
7842 + <path
7843 + strokeLinecap="round"
7844 + strokeLinejoin="round"
7845 + strokeWidth={2}
7846 + d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
7847 + />
7848 + </svg>
7849 + <div className="flex-1">
7850 + {isPro ? (
7851 + <>
7852 + {__(
7853 + "Per-trip deposits need the Flexible Payments module.",
7854 + "yatra",
7855 + )}{" "}
7856 + <a
7857 + href={modulesPageUrl}
7858 + target="_blank"
7859 + rel="noopener noreferrer"
7860 + className="underline font-medium"
7861 + >
7862 + {__("Activate module", "yatra")}
7863 + </a>
7864 + </>
7865 + ) : (
7866 + <>
7867 + {__(
7868 + "Per-trip deposit amount, percentage and custom payment terms are a Yatra Pro feature.",
7869 + "yatra",
7870 + )}{" "}
7871 + <a
7872 + href={pricingUrl}
7873 + target="_blank"
7874 + rel="noopener noreferrer"
7875 + className="underline font-medium"
7876 + >
7877 + {__("Upgrade to Pro", "yatra")}
7878 + </a>
7879 + </>
7880 + )}
7881 + </div>
7882 + </div>
7883 + )}
7884 +
7885 + {/*
7886 + <fieldset disabled> is the HTML-standard way to make every
7887 + form control inside it non-interactive in one go — works
7888 + for our <Input> and <textarea> without per-field disabled props.
7889 + */}
7890 + <fieldset
7891 + disabled={isLocked}
7892 + className={
7893 + isLocked
7894 + ? "opacity-60 cursor-not-allowed select-none"
7895 + : ""
7896 + }
7897 + aria-label={
7898 + isLocked
7899 + ? __(
7900 + "Deposit & Payment Terms (Pro feature, disabled)",
7901 + "yatra",
7902 + )
7903 + : undefined
7904 + }
7526 7905 >
7527 - {__("Deposit Amount", "yatra")}
7528 - </label>
7529 - <div className="relative">
7530 - <span className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500">
7531 - {getCurrencySymbol(globalCurrency)}
7532 - </span>
7533 - <Input
7534 - id="deposit_amount"
7535 - type="number"
7536 - step="0.01"
7537 - min="0"
7538 - value={formData.deposit_amount}
7539 - onChange={(e) =>
7540 - handleFieldChange("deposit_amount", e.target.value)
7541 - }
7542 - placeholder="0.00"
7543 - className="pl-7"
7544 - />
7545 - </div>
7906 + <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
7907 + <div>
7908 + <label
7909 + htmlFor="deposit_amount"
7910 + className="block text-xs font-normal text-gray-500 dark:text-gray-400 mb-1.5"
7911 + >
7912 + {__("Deposit Amount", "yatra")}
7913 + </label>
7914 + <div className="relative">
7915 + <span className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500">
7916 + {getCurrencySymbol(globalCurrency)}
7917 + </span>
7918 + <Input
7919 + id="deposit_amount"
7920 + type="number"
7921 + step="0.01"
7922 + min="0"
7923 + value={formData.deposit_amount}
7924 + onChange={(e) =>
7925 + handleFieldChange(
7926 + "deposit_amount",
7927 + e.target.value,
7928 + )
7929 + }
7930 + placeholder="0.00"
7931 + className="pl-7"
7932 + />
7933 + </div>
7934 + </div>
7935 + <div>
7936 + <label
7937 + htmlFor="deposit_percentage"
7938 + className="block text-xs font-normal text-gray-500 dark:text-gray-400 mb-1.5"
7939 + >
7940 + {__("Deposit Percentage", "yatra")}
7941 + </label>
7942 + <div className="relative">
7943 + <Input
7944 + id="deposit_percentage"
7945 + type="number"
7946 + step="0.01"
7947 + min="0"
7948 + max="100"
7949 + value={formData.deposit_percentage}
7950 + onChange={(e) =>
7951 + handleFieldChange(
7952 + "deposit_percentage",
7953 + e.target.value,
7954 + )
7955 + }
7956 + placeholder="0"
7957 + className="pr-7"
7958 + />
7959 + <span className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500">
7960 + %
7961 + </span>
7962 + </div>
7963 + </div>
7964 + </div>
7965 + <div className="mt-4">
7966 + <label
7967 + htmlFor="payment_terms"
7968 + className="block text-xs font-normal text-gray-500 dark:text-gray-400 mb-1.5"
7969 + >
7970 + {__("Payment Terms", "yatra")}
7971 + </label>
7972 + <textarea
7973 + id="payment_terms"
7974 + value={formData.payment_terms}
7975 + onChange={(e) =>
7976 + handleFieldChange("payment_terms", e.target.value)
7977 + }
7978 + placeholder={__(
7979 + "e.g., 50% deposit required at booking, balance due 30 days before departure",
7980 + "yatra",
7981 + )}
7982 + rows={2}
7983 + className="flex w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:ring-offset-gray-900 dark:placeholder:text-gray-400 dark:focus-visible:ring-blue-400 resize-none"
7984 + />
7985 + </div>
7986 + </fieldset>
7546 7987 </div>
7547 - <div>
7548 - <label
7549 - htmlFor="deposit_percentage"
7550 - className="block text-xs font-normal text-gray-500 dark:text-gray-400 mb-1.5"
7551 - >
7552 - {__("Deposit Percentage", "yatra")}
7553 - </label>
7554 - <div className="relative">
7555 - <Input
7556 - id="deposit_percentage"
7557 - type="number"
7558 - step="0.01"
7559 - min="0"
7560 - max="100"
7561 - value={formData.deposit_percentage}
7562 - onChange={(e) =>
7563 - handleFieldChange(
7564 - "deposit_percentage",
7565 - e.target.value,
7566 - )
7567 - }
7568 - placeholder="0"
7569 - className="pr-7"
7570 - />
7571 - <span className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500">
7572 - %
7573 - </span>
7574 - </div>
7575 - </div>
7576 - </div>
7577 - <div className="mt-4">
7578 - <label
7579 - htmlFor="payment_terms"
7580 - className="block text-xs font-normal text-gray-500 dark:text-gray-400 mb-1.5"
7581 - >
7582 - {__("Payment Terms", "yatra")}
7583 - </label>
7584 - <textarea
7585 - id="payment_terms"
7586 - value={formData.payment_terms}
7587 - onChange={(e) =>
7588 - handleFieldChange("payment_terms", e.target.value)
7589 - }
7590 - placeholder={__(
7591 - "e.g., 50% deposit required at booking, balance due 30 days before departure",
7592 - "yatra",
7593 - )}
7594 - rows={2}
7595 - className="flex w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-white placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:ring-offset-gray-900 dark:placeholder:text-gray-400 dark:focus-visible:ring-blue-400 resize-none"
7596 - />
7597 - </div>
7598 - </div>
7988 + );
7989 + })()}
7599 7990
7600 7991 {/* Group Pricing */}
7601 7992 {/* Removed */}
7602 7993 </div>
@@ -7668,13 +8059,49 @@
7668 8059
7669 8060 {/* Tab Content */}
7670 8061 <div className="mt-6">
7671 8062 {tripDetailsTab === "itinerary" && (
7672 - <ItinerarySection
7673 - formData={formData as any}
7674 - isEditMode={isEditMode}
7675 - tripId={tripId}
7676 - />
8063 + <>
8064 + {isAiEligible() && isAiModuleEnabled() && (
8065 + <div className="mb-4 flex items-center justify-between rounded-md border border-blue-200 bg-blue-50/40 px-3 py-2 dark:border-blue-500/40 dark:bg-blue-900/20">
8066 + <div className="text-xs text-blue-900 dark:text-blue-200">
8067 + <Sparkles className="mr-1 inline h-3.5 w-3.5" />
8068 + {isEditMode
8069 + ? __(
8070 + "Let AI draft a day-by-day plan with real activity blocks — you can edit each day after.",
8071 + "yatra",
8072 + )
8073 + : __(
8074 + "Save the trip first to enable AI itinerary generation — the agent needs a trip ID to attach days to.",
8075 + "yatra",
8076 + )}
8077 + </div>
8078 + <Button
8079 + type="button"
8080 + size="sm"
8081 + variant="outline"
8082 + className="border-blue-300 text-blue-700 hover:bg-blue-100 dark:border-blue-500/60 dark:text-blue-200"
8083 + disabled={!isEditMode || !tripId}
8084 + onClick={() => {
8085 + // Same modal the Itinerary page uses, so all
8086 + // three entry points share one agent + one
8087 + // persistence path.
8088 + if (isEditMode && tripId) {
8089 + setItineraryBuildOpen(true);
8090 + }
8091 + }}
8092 + >
8093 + <Sparkles className="mr-1 h-3.5 w-3.5" />
8094 + {__("Build with AI", "yatra")}
8095 + </Button>
8096 + </div>
8097 + )}
8098 + <ItinerarySection
8099 + formData={formData as any}
8100 + isEditMode={isEditMode}
8101 + tripId={tripId}
8102 + />
8103 + </>
7677 8104 )}
7678 8105 {tripDetailsTab === "included" && (
7679 8106 <IncludedSection
7680 8107 formData={formData}
@@ -7732,14 +8159,25 @@
7732 8159 {__("Search Engine Optimization", "yatra")}
7733 8160 </h3>
7734 8161 <div className="space-y-4">
7735 8162 <div>
7736 - <label
7737 - htmlFor="meta_title"
7738 - className="block text-xs font-normal text-gray-500 dark:text-gray-400 mb-1.5"
7739 - >
7740 - {__("Meta Title", "yatra")}
7741 - </label>
8163 + <div className="flex items-center justify-between mb-1.5">
8164 + <label
8165 + htmlFor="meta_title"
8166 + className="block text-xs font-normal text-gray-500 dark:text-gray-400"
8167 + >
8168 + {__("Meta Title", "yatra")}
8169 + </label>
8170 + <AiFieldAffordance
8171 + task="seo-meta-title"
8172 + label={__("Meta Title", "yatra")}
8173 + value={formData.meta_title}
8174 + onAccept={(v) =>
8175 + handleFieldChange("meta_title", v.slice(0, 60))
8176 + }
8177 + buildContext={() => buildTripAiContext(formData)}
8178 + />
8179 + </div>
7742 8180 <Input
7743 8181 id="meta_title"
7744 8182 type="text"
7745 8183 value={formData.meta_title}
@@ -7757,14 +8195,25 @@
7757 8195 className="mt-1"
7758 8196 />
7759 8197 </div>
7760 8198 <div>
7761 - <label
7762 - htmlFor="meta_description"
7763 - className="block text-xs font-normal text-gray-500 dark:text-gray-400 mb-1.5"
7764 - >
7765 - {__("Meta Description", "yatra")}
7766 - </label>
8199 + <div className="flex items-center justify-between mb-1.5">
8200 + <label
8201 + htmlFor="meta_description"
8202 + className="block text-xs font-normal text-gray-500 dark:text-gray-400"
8203 + >
8204 + {__("Meta Description", "yatra")}
8205 + </label>
8206 + <AiFieldAffordance
8207 + task="seo-meta-description"
8208 + label={__("Meta Description", "yatra")}
8209 + value={formData.meta_description}
8210 + onAccept={(v) =>
8211 + handleFieldChange("meta_description", v.slice(0, 160))
8212 + }
8213 + buildContext={() => buildTripAiContext(formData)}
8214 + />
8215 + </div>
7767 8216 <textarea
7768 8217 id="meta_description"
7769 8218 value={formData.meta_description}
7770 8219 onChange={(e) =>
@@ -7858,12 +8307,49 @@
7858 8307 </div>
7859 8308
7860 8309 {/* SECTION 2: FAQs */}
7861 8310 <div className="border border-gray-200 dark:border-gray-700 rounded-lg p-5 bg-gray-50 dark:bg-gray-800/30">
7862 - <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
7863 - <HelpCircle className="w-4 h-4 text-blue-500" />
7864 - {__("Frequently Asked Questions", "yatra")}
7865 - </h3>
8311 + <div className="mb-4 flex items-center justify-between gap-2">
8312 + <h3 className="text-sm font-semibold text-gray-900 dark:text-white flex items-center gap-2">
8313 + <HelpCircle className="w-4 h-4 text-blue-500" />
8314 + {__("Frequently Asked Questions", "yatra")}
8315 + </h3>
8316 + {/* AI: generates 5-7 Q&A pairs. Result text is
8317 + parsed from "Q: …\nA: …" blocks separated by
8318 + blank lines — same shape as the trip-faq
8319 + prompt's documented output. Accept replaces
8320 + the existing FAQ list wholesale. */}
8321 + <AiFieldAffordance
8322 + task="trip-faq"
8323 + label={__("FAQ", "yatra")}
8324 + value={(formData.faqs ?? [])
8325 + .map((f: any) => `Q: ${f.question}\nA: ${f.answer}`)
8326 + .join("\n\n")}
8327 + onAccept={(raw) => {
8328 + const parsed: Array<{
8329 + question: string;
8330 + answer: string;
8331 + }> = [];
8332 + const blocks = raw
8333 + .replace(/\r\n?/g, "\n")
8334 + .split(/\n{2,}/);
8335 + for (const block of blocks) {
8336 + const q = block.match(/^\s*Q[:\.\-]\s*(.+)/im);
8337 + const a = block.match(/A[:\.\-]\s*([\s\S]+)/im);
8338 + if (q) {
8339 + parsed.push({
8340 + question: q[1].trim(),
8341 + answer: a ? a[1].trim() : "",
8342 + });
8343 + }
8344 + }
8345 + if (parsed.length > 0) {
8346 + handleFieldChange("faqs", parsed as any);
8347 + }
8348 + }}
8349 + buildContext={() => buildTripAiContext(formData)}
8350 + />
8351 + </div>
7866 8352 <p className="text-xs text-gray-500 dark:text-gray-400 mb-4">
7867 8353 {__(
7868 8354 "Answer common questions to build trust and reduce support inquiries. FAQs also help with SEO.",
7869 8355 "yatra",
@@ -8473,8 +8959,25 @@
8473 8959 <History className="w-4 h-4 mr-2" />
8474 8960 {__("Revisions", "yatra")}
8475 8961 </Button>
8476 8962 )}
8963 + {isAiEligible() && isAiModuleEnabled() && (
8964 + <Button
8965 + type="button"
8966 + onClick={() => {
8967 + setAiModalMode("all");
8968 + setAiModalOpen(true);
8969 + }}
8970 + className="bg-gradient-to-r from-purple-600 to-indigo-600 text-white border-0 hover:from-purple-700 hover:to-indigo-700"
8971 + title={__(
8972 + "Generate description, itinerary, included items, SEO meta, and more — from this trip's facts.",
8973 + "yatra",
8974 + )}
8975 + >
8976 + <Sparkles className="w-4 h-4 mr-2" />
8977 + {__("Auto-fill with AI", "yatra")}
8978 + </Button>
8979 + )}
8477 8980 <Button
8478 8981 variant="outline"
8479 8982 onClick={handlePreview}
8480 8983 className="text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"
@@ -8503,9 +9006,14 @@
8503 9006 {isSubmitting ? (
8504 9007 <Loader2 className="w-4 h-4 mr-2 animate-spin" />
8505 9008 ) : (
8506 9009 <>
8507 - <Sparkles className="w-4 h-4 mr-2" />
9010 + {/* Send icon (paper-plane) — used to be Sparkles,
9011 + which made it look like an AI feature; replaced
9012 + so the publish button reads as a regular submit
9013 + action, distinct from the actual AI affordances
9014 + sprinkled across the form. */}
9015 + <Send className="w-4 h-4 mr-2" />
8508 9016 {isEditMode
8509 9017 ? __("Update Trip", "yatra")
8510 9018 : __("Publish Trip", "yatra")}
8511 9019 <ChevronDown className="w-4 h-4 ml-2" />
@@ -9169,8 +9677,44 @@
9169 9677 )}
9170 9678 </div>
9171 9679 </div>
9172 9680 </Modal>
9681 + {/* AI Assistant — auto-fill / itinerary modal. Mounted at the root
9682 + so it overlays the whole form regardless of which section is
9683 + currently active. */}
9684 + <AutoFillTripModal
9685 + open={aiModalOpen}
9686 + onClose={() => setAiModalOpen(false)}
9687 + buildContext={() => buildTripAiContext(formData)}
9688 + itineraryOnly={aiModalMode === "itinerary"}
9689 + onFieldsAccepted={(updates) => {
9690 + for (const [key, value] of Object.entries(updates)) {
9691 + handleFieldChange(key as any, value as any);
9692 + }
9693 + }}
9694 + />
9695 + {/* Same modal as the standalone Itinerary page. We mount it
9696 + here so the Itinerary tab's "Build with AI" button can drive
9697 + the SAME agent + applyItinerary flow that the other two
9698 + surfaces use, instead of forking through AutoFillTripModal's
9699 + single-shot legacy parser. */}
9700 + {isEditMode && tripId && (
9701 + <BuildItineraryModal
9702 + open={itineraryBuildOpen}
9703 + onClose={() => setItineraryBuildOpen(false)}
9704 + tripId={tripId}
9705 + tripName={(formData as any)?.title || __("Trip", "yatra")}
9706 + tripDurationDays={Number((formData as any)?.duration_days) || 0}
9707 + onApplied={() => {
9708 + // Persistence happened server-side via applyItinerary; the
9709 + // trip form's local itinerary state needs to refresh so the
9710 + // newly-created day rows show up. The TripForm reloads
9711 + // itinerary data on a routing event — we just close the
9712 + // modal and let the user navigate / save normally.
9713 + setItineraryBuildOpen(false);
9714 + }}
9715 + />
9716 + )}
9173 9717 </div>
9174 9718 );
9175 9719 };
9176 9720