| @@ -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 | |