/** * Trip Form Page - Wizard Style * Multi-step form for creating/editing trips with sidebar navigation */ import React, { useState, useEffect, useMemo, useRef } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; // Utility function to strip HTML and sanitize text for SEO preview const sanitizeTextForSEO = (text: string, maxLength: number = 160): string => { if (!text) return ""; // Remove HTML tags const plainText = text.replace(/<[^>]*>/g, ""); // Decode HTML entities const decodedText = plainText .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, '"') .replace(/'/g, "'") .replace(/ /g, " "); // Remove extra whitespace and trim const cleanText = decodedText.replace(/\s+/g, " ").trim(); // Truncate if needed if (cleanText.length > maxLength) { return cleanText.substring(0, maxLength) + "..."; } return cleanText; }; import { Save, Send, Loader2, Sparkles, Calendar, Clock, Image, HelpCircle, Search, Settings, CheckCircle2, CheckSquare, FileText, DollarSign, AlertCircle, Plus, Upload, MapPin, Tag, GripVertical, ChevronUp, ChevronDown, ChevronLeft, ChevronRight, Copy, Check, BookOpen, Download, Eye, X, Trash2, Users, Box, BarChart3, Database, History, Home, Car, Lightbulb, } from "lucide-react"; import { RichTextEditor } from "../components/ui/rich-text-editor"; import { IconPicker, IconPickerValue } from "../components/ui/icon-picker"; import { AiFieldAffordance } from "../components/ai/AiFieldAffordance"; import { AutoFillTripModal } from "../components/ai/AutoFillTripModal"; import { BuildItineraryModal } from "../components/ai/BuildItineraryModal"; import { isAiEligible, isAiModuleEnabled } from "../lib/ai-availability"; import { __, sprintf } from "../lib/i18n"; import { MEAL_PLAN_SELECT_OPTIONS } from "../lib/meal-plan-labels"; import { usePermissions } from "../hooks/usePermissions"; import { fetchSettings } from "../api/settings-api"; import { apiClient } from "../lib/api-client"; import { wpService } from "../lib/api-client"; import { Button } from "../components/ui/button"; import { Input } from "@/components/ui/input"; import { DatePicker } from "@/components/ui/date-picker"; import { prepareWordPressMediaFrameOpen } from "../lib/wp-media-open"; import { Modal } from "../components/ui/modal"; import { Select } from "../components/ui/select"; import { Alert } from "../components/ui/alert"; import { Card, CardContent, CardHeader, CardTitle, CardDescription, } from "../components/ui/card"; import { Badge } from "../components/ui/badge"; import TripAttributesSection from "../components/trip-form/TripAttributesSection"; import { HelpText } from "../components/ui/help-text"; import { getCurrencySymbol } from "../data/currencies"; import { ItinerarySection } from "../components/trip-form/sections/ItinerarySection"; import { IncludedSection } from "../components/trip-form/sections/IncludedSection"; import { ConfirmationDialog } from "../components/ui/confirmation-dialog"; import { useToast } from "../components/ui/toast"; import { MultiSelect } from "../components/ui/multi-select"; import { TestimonialsSelector } from "../components/trip-form/TestimonialsSelector"; import { LocationPicker } from "../components/trip-form/LocationPicker"; import { ProFeature, ProBadge } from "../components/ProFeature"; import { getErrorContext } from "../lib/errors"; import { buildYatraSinglePublicUrls, isWordPressPlainPermalink, } from "../lib/frontend-permalink-urls"; type SectionId = | "basic" // 1. Basic Information (title, description, highlights, featured image, trip type, duration) | "location" // 2. Location & Geography | "duration" // 3. Schedule & Availability (renamed) | "pricing" // 4. Pricing & Payment | "booking" // 5. Booking Requirements | "attributes" // 6. Trip Attributes | "itinerary" // 7. Itinerary Builder (includes Included/Excluded) | "included" // 8. What's Included/Excluded (deprecated - merged into itinerary) | "media" // 9. Media & Content (gallery, video, story, testimonials) | "downloads" // 9b. Downloads (Pro module) | "categorization" // 10. Categorization & Tags (category, activities, difficulty, tags) | "faqs" // 11. FAQs | "seo" // 12. SEO Settings | "advanced"; // 13. Advanced Settings (status, scheduling, frontend tabs) interface Section { id: SectionId; label: string; icon: React.ComponentType<{ className?: string }>; required: boolean; completed: boolean; hasErrors?: boolean; } interface FAQ { question: string; answer: string; } interface TravelerCategory { id: number; label: string; description: string; age_min?: number; age_max?: number; status: "active" | "inactive" | "publish" | "draft"; pricing_mode?: "per_person" | "per_group"; min_pax?: number | null; max_pax?: number | null; } interface PriceType { category_id: number; original_price: string; discounted_price: string; is_default?: boolean; } interface TripCategoryOption { id: number; name: string; slug?: string; description?: string; parent_id: number | null; status?: string; subcategories?: TripCategoryOption[]; } interface DifficultyLevelOption { id: number; name: string; slug?: string; description?: string; level_order?: number; status?: string; } interface TripAmenityItem { title: string; description: string; } interface DownloadableItem { id?: number | null; title: string; description: string; attachment_id: number | null; attachment_url?: string; attachment_title?: string; visibility: "public" | "logged_in" | "booked_only"; enabled: boolean; sort_order?: number; } const buildCategoryOptionNodes = ( categories: TripCategoryOption[], depth = 0, ): React.ReactNode[] => { return categories.flatMap((category) => { const optionValue = category.slug || category.name || category.id?.toString() || ""; const labelPrefix = depth > 0 ? `${"-- ".repeat(depth)}${category.name}` : category.name; const nodes: React.ReactNode[] = [ , ]; if ( Array.isArray(category.subcategories) && category.subcategories.length > 0 ) { nodes.push( ...buildCategoryOptionNodes(category.subcategories, depth + 1), ); } return nodes; }); }; const extractArrayPayload = (payload: any): any[] => { if (!payload) return []; if (Array.isArray(payload)) return payload; if (Array.isArray(payload.data)) return payload.data; if (Array.isArray(payload?.data?.data)) return payload.data.data; return []; }; const normalizeAmenityItems = (items: unknown): TripAmenityItem[] => { if (!items) return []; if (Array.isArray(items)) { return items .map((item) => { if (typeof item === "string") { return { title: item, description: "" }; } if (item && typeof item === "object") { const obj = item as Partial; return { title: (obj.title ?? "").toString(), description: (obj.description ?? "").toString(), }; } return { title: String(item), description: "" }; }) .filter((item) => item.title.trim().length > 0); } if (typeof items === "string") { try { const parsed = JSON.parse(items); if (Array.isArray(parsed)) { return normalizeAmenityItems(parsed); } } catch { return [{ title: items, description: "" }]; } } return []; }; interface TimeSlot { id: string; time: string; // HH:MM format label: string; } /** * Pull a context payload out of the live formData for AI generation * calls. Kept narrow on purpose — fewer tokens, no PII, and only the * fields the prompt templates actually reference. Helpers stay free of * React state so AiFieldAffordance's `buildContext` callback can be * invoked from anywhere without re-deriving form deps. */ function buildTripAiContext(formData: any): Record { const amenityTitles = (items: any[] | undefined): string[] => Array.isArray(items) ? items .map((it) => (typeof it === "string" ? it : it?.title || it?.label)) .filter((s): s is string => typeof s === "string" && s.trim() !== "") : []; return { name: formData?.title ?? "", short_description: formData?.short_description ?? "", description: stripHtml(formData?.description ?? ""), destinations: namesFromIds(formData?.destinations), categories: namesFromIds(formData?.categories), activities: namesFromIds(formData?.activities), difficulty_level: formData?.difficulty_level ?? "", duration_days: formData?.duration_days ?? "", duration_nights: formData?.duration_nights ?? "", best_season: formData?.best_season ?? "", price: formData?.price ?? "", deposit_percentage: formData?.deposit_percentage ?? "", booking_deadline_hours: formData?.booking_deadline_hours ?? "", age_min: formData?.age_min ?? "", age_max: formData?.age_max ?? "", accommodation_type: formData?.accommodation_type ?? "", transportation_included: formData?.transportation_included ?? "", included_items: amenityTitles(formData?.included_items), excluded_items: amenityTitles(formData?.excluded_items), }; } function stripHtml(value: string): string { if (!value) return ""; return value .replace(/<[^>]*>/g, " ") .replace(/\s+/g, " ") .trim(); } /** Best-effort name resolution for taxonomy fields. The form stores IDs * in some places and objects in others; we accept either. */ function namesFromIds(value: any): string[] { if (!Array.isArray(value)) return []; return value .map((item) => { if (typeof item === "string") return item; if (item && typeof item === "object") { return item.name || item.label || item.title || ""; } return ""; }) .filter((s): s is string => typeof s === "string" && s.trim() !== ""); } interface TripFormData { // Overview title: string; slug: string; description: string; highlights: string[]; trip_details: string; short_description: string; what_makes_special: string; // What Makes This Trip Special trip_story: string; // Trip Story/Narrative video_url: string; // Video Embed URL virtual_tour_url: string; // 360° Virtual Tour URL testimonial_review_ids: number[]; // Array of review IDs to display as testimonials // Location & Geography destinations: number[]; // Array of destination IDs starting_location: string; ending_location: string; countries: string[]; regions: string[]; starting_latitude: string; starting_longitude: string; ending_latitude: string; ending_longitude: string; landmarks: string[]; // Geographic Tags - Landmarks // Duration & Schedule trip_type: "single_day" | "multi_day" | "flexible"; duration_days: string; duration_nights: string; available_from: string; available_to: string; booking_window_days: string; seasonal_availability: string; best_season: string; // Best season indicator peak_season: string; // Peak season indicator off_season: string; // Off-season indicator // Activity & Category activity_types: number[]; // Array of activity IDs (changed from string[]) difficulty_level: string; trip_category: number[]; // Array of category IDs tags: string[]; featured_priority: "none" | "featured" | "new" | "limited"; // Featured Priority // Accommodation accommodation_type: string; meal_plan: string; accommodation_details: string; // Transportation transportation_included: boolean; pickup_location: string; dropoff_location: string; transportation_details: string; // Pricing pricing_type: "regular" | "traveler_based"; original_price: string; discounted_price: string; price_types: PriceType[]; deposit_amount: string; deposit_percentage: string; payment_terms: string; max_travelers: string; min_travelers: string; booking_deadline_hours: string; cancellation_policy: string; age_min: string; age_max: string; physical_requirements: string; visa_requirements: string; vaccination_requirements: string; disable_booking: boolean; // Pro: enquiry-only mode (stored in custom_fields) // Fallback Settings (for trips without availability dates/rules) has_default_time_slots: boolean; // For day tours: enable multiple time slots default_time_slots: TimeSlot[]; // Array of time slot objects for day tours departure_time: string; // Default departure time // Included/Excluded included_items: TripAmenityItem[]; excluded_items: TripAmenityItem[]; // Attributes & Properties attributes: Record; // attribute_id -> value mapping // Itinerary itinerary_days: ItineraryDay[]; // Gallery gallery_images: Array<{ id: number; url: string; thumbnail_url?: string; alt_text?: string; caption?: string; }>; featured_image: number | null; // Downloads downloadable_items: DownloadableItem[]; // FAQs faqs: FAQ[]; // Frontend Tabs frontend_tabs: FrontendTab[]; // Availability availability_dates: AvailabilityDate[]; // Status & Lifecycle status: | "draft" | "review" | "approved" | "publish" | "archived" | "suspended"; scheduled_publish_date: string; // Scheduled Publishing scheduled_unpublish_date: string; // Scheduled Unpublishing version: number; // Version Control seasonal_auto_enable: boolean; // Auto-enable/disable based on dates seasonal_enable_date: string; // Date to auto-enable seasonal_disable_date: string; // Date to auto-disable // SEO meta_title: string; meta_description: string; meta_keywords: string; } interface FrontendTab { id: string; label: string; enabled: boolean; order: number; content_type: | "overview" | "itinerary" | "included_excluded" | "location" | "important_info" | "downloads" | "faq" | "trip_story" | "what_makes_special" | "testimonials" | "reviews" | "downloads" | "custom" | "general" | "gallery" | "faqs"; custom_content?: string; icon?: IconPickerValue | null; } interface AvailabilityDate { id: string; /** Present when availability row is tied to a specific trip */ trip_id?: number; departure_date: string; arrival_date: string; seats_remaining: string; original_price: string; discounted_price: string; discount_percentage: string; status: "available" | "sold_out" | "limited" | "closed"; from_location?: string; to_location?: string; from_latitude?: string; from_longitude?: string; to_latitude?: string; to_longitude?: string; } interface ItineraryEntry { id: string; day: number; day_title?: string; // Use IDs to match ItineraryForm structure item_type_id: string; // ID of the item type (Activity, Meal, Accommodation, Transportation, Rest) item_id: string; // ID of the specific item (Hiking, Breakfast, Hotel, etc.) // Keep legacy fields for backward compatibility item_type?: "Meal" | "Activity" | "Accommodation" | "Transportation"; item_name?: string; item_icon?: string; // Entry details title: string; description: string; location?: string; location_latitude?: string; location_longitude?: string; duration?: string; start_time: string; end_time: string; time_type: "exact" | "duration" | "flexible"; cost?: string; cost_per_person: boolean; notes?: string; included_items: string[]; excluded_items: string[]; images: string[]; status?: "active" | "inactive"; } interface ItineraryDay { day: number; day_title?: string; entries: ItineraryEntry[]; } // getCurrencySymbol is now imported from '../data/currencies' const TripForm: React.FC = () => { const queryClient = useQueryClient(); const { can, isPro } = usePermissions(); const { showToast } = useToast(); // Downloads is now a FREE feature - always show the UI const showDownloadsUI = true; // AI Assistant — modal state for the "Auto-fill" / "Generate itinerary" // workflows. `aiModalMode` controls which preset the modal opens with. const [aiModalOpen, setAiModalOpen] = useState(false); const [aiModalMode, setAiModalMode] = useState<"all" | "itinerary">("all"); // Itinerary-builder modal is the SAME component the standalone // Itinerary page uses. Going through it (instead of // AutoFillTripModal's itineraryOnly mode) means all three entry // points for "build itinerary with AI" — wizard, this trip-form // tab, and the Itinerary page — converge on the same agent + // applyItinerary persistence path. const [itineraryBuildOpen, setItineraryBuildOpen] = useState(false); const [featuredImagePreview, setFeaturedImagePreview] = useState(""); const [isResolvingFeaturedImage, setIsResolvingFeaturedImage] = useState(false); const featuredImageCache = useRef>({}); const mediaBaseUrl = useMemo(() => { const apiUrl = (window as any)?.yatraAdmin?.apiUrl; return apiUrl ? apiUrl.replace(/\/yatra\/v1\/?$/, "") : ""; }, []); // Get section from URL on initial load const getInitialSection = (): SectionId => { const urlParams = new URLSearchParams(window.location.search); const sectionFromUrl = urlParams.get("section") as SectionId | null; const validSections: SectionId[] = [ "basic", "location", "duration", "pricing", "booking", "attributes", "itinerary", "included", "media", "downloads", "categorization", "faqs", "seo", "advanced", ]; if (sectionFromUrl && validSections.includes(sectionFromUrl)) { return sectionFromUrl; } return "basic"; }; const [currentSection, setCurrentSection] = useState(getInitialSection); const [visitedSections, setVisitedSections] = useState>( () => new Set([getInitialSection()]), ); const [showCategorySelector, setShowCategorySelector] = useState(false); const [showLandmarkDialog, setShowLandmarkDialog] = useState(false); const [landmarkInput, setLandmarkInput] = useState(""); // Sub-tab state for Trip Details section (itinerary) const [tripDetailsTab, setTripDetailsTab] = useState< "itinerary" | "included" >("itinerary"); // Track visited sections for lazy loading and update URL useEffect(() => { setVisitedSections((prev) => new Set([...prev, currentSection])); // Update URL with current section (without page reload) const url = new URL(window.location.href); url.searchParams.set("section", currentSection); window.history.replaceState({}, "", url.toString()); }, [currentSection]); // Modal states for adding items const [showHighlightModal, setShowHighlightModal] = useState(false); const [modalInput, setModalInput] = useState({ text: "", question: "", answer: "", }); // Revision states const [showRevisionsDialog, setShowRevisionsDialog] = useState(false); const [selectedRevisionId, setSelectedRevisionId] = useState( null, ); const [showRevisionConfirm, setShowRevisionConfirm] = useState(false); // UI Enhancement states const [simpleMode, setSimpleMode] = useState(false); // Quick Start mode const [showSlugPreview, setShowSlugPreview] = useState(true); const [dummyDataIndex, setDummyDataIndex] = useState(0); // Track which dummy data set to use // Static/dummy revisions data for UI only const dummyRevisions = [ { id: 1, version: 3, created_by_name: "Admin User", created_at: new Date().toISOString(), }, { id: 2, version: 2, created_by_name: "Admin User", created_at: new Date(Date.now() - 86400000).toISOString(), }, { id: 3, version: 1, created_by_name: "Admin User", created_at: new Date(Date.now() - 172800000).toISOString(), }, ]; // Comprehensive dummy trip data sets (3 different trips) const dummyTripsData: TripFormData[] = [ { // Trip 1: Beach Adventure title: "7-Day Bali Beach Adventure", slug: "7-day-bali-beach-adventure", description: "Escape to paradise with our 7-day Bali beach adventure. Experience pristine beaches, explore ancient temples, enjoy world-class spa treatments, and immerse yourself in the rich Balinese culture. This carefully curated journey combines relaxation with adventure, offering the perfect balance for travelers seeking both tranquility and excitement.", short_description: "Escape to paradise with our 7-day Bali beach adventure featuring pristine beaches, ancient temples, and cultural immersion.", highlights: [ "Pristine white sand beaches", "Ancient temple visits", "Traditional spa treatments", "Cultural dance performances", "Sunset dinners by the ocean", ], trip_details: "This comprehensive 7-day journey takes you through the best of Bali. Start your adventure in Seminyak with its trendy beach clubs and world-class restaurants. Visit the iconic Tanah Lot Temple perched on a rock formation in the sea. Explore the cultural heart of Ubud, known for its rice terraces, monkey forest, and art galleries. Enjoy traditional Balinese spa treatments and witness captivating cultural performances. End your trip with a relaxing stay at a beachfront resort where you can unwind and reflect on your incredible journey.", what_makes_special: "This trip offers exclusive access to private beach areas, personalized cultural experiences, and a perfect blend of relaxation and adventure. Our local guides share insider knowledge and hidden gems that most tourists never discover.", trip_story: "Imagine waking up to the gentle sound of waves lapping against the shore. As the sun rises over the horizon, you step onto your private balcony to witness a breathtaking sunrise. Your day begins with a traditional Balinese breakfast before heading out to explore ancient temples that have stood for centuries. In the afternoon, you find yourself surrounded by emerald-green rice terraces, learning about traditional farming methods from local farmers. As evening approaches, you're treated to a mesmerizing cultural dance performance followed by a candlelit dinner on the beach. This is more than a vacation—it's a journey into the heart and soul of Bali.", video_url: "https://www.youtube.com/watch?v=example1", virtual_tour_url: "", testimonial_review_ids: [], // Will be populated from actual reviews destinations: [], // Will be populated based on available destinations starting_location: "Ngurah Rai International Airport (DPS)", ending_location: "Seminyak Beach Resort", countries: ["Indonesia"], regions: ["Bali"], starting_latitude: "-8.3405", starting_longitude: "115.0920", ending_latitude: "-8.5069", ending_longitude: "115.2625", landmarks: [ "Tanah Lot Temple", "Ubud Monkey Forest", "Tegallalang Rice Terrace", "Seminyak Beach", ], trip_type: "multi_day", duration_days: "7", duration_nights: "6", available_from: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) .toISOString() .split("T")[0], // 30 days from now available_to: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) .toISOString() .split("T")[0], // 1 year from now booking_window_days: "30", seasonal_availability: "Year-round", best_season: "April to October", peak_season: "July to August", off_season: "November to March", activity_types: [], // Will be populated based on available activities difficulty_level: "", trip_category: [], tags: ["family-friendly", "beach", "relaxation", "cultural", "spa"], featured_priority: "featured", accommodation_type: "Resort", meal_plan: "breakfast", accommodation_details: "4-star beachfront resort with private balconies, infinity pool, and spa facilities", transportation_included: true, pickup_location: "Ngurah Rai International Airport", dropoff_location: "Seminyak Beach Resort", transportation_details: "Private air-conditioned vehicle with professional driver", pricing_type: "traveler_based", original_price: "", discounted_price: "", price_types: [ { category_id: 1, original_price: "1250", discounted_price: "" }, { category_id: 2, original_price: "625", discounted_price: "" }, { category_id: 3, original_price: "0", discounted_price: "" }, ], deposit_amount: "300", deposit_percentage: "", payment_terms: "50% deposit required at booking, remaining 50% due 30 days before departure", max_travelers: "12", min_travelers: "2", booking_deadline_hours: "24", cancellation_policy: "Free cancellation up to 30 days before departure. 50% refund for cancellations 15-30 days before. No refund for cancellations less than 15 days before.", age_min: "8", age_max: "", physical_requirements: "Moderate fitness level required. Some walking involved but no strenuous activities.", visa_requirements: "Visa on arrival available for most nationalities. Valid passport required with at least 6 months validity.", vaccination_requirements: "No mandatory vaccinations. Recommended: Hepatitis A, Typhoid, and routine vaccinations.", disable_booking: false, has_default_time_slots: false, default_time_slots: [], departure_time: "", included_items: [ { title: "Accommodation", description: "6 nights at 4-star beachfront resort", }, { title: "Breakfast", description: "Daily breakfast included" }, { title: "Airport transfers", description: "Private transfers to and from airport", }, { title: "Temple visits", description: "Entrance fees to all temples included", }, { title: "Cultural performances", description: "Traditional dance show tickets", }, { title: "Professional guide", description: "English-speaking local guide", }, ], excluded_items: [ { title: "International flights", description: "Flights to and from Bali not included", }, { title: "Lunch and dinner", description: "Meals other than breakfast", }, { title: "Travel insurance", description: "Travel insurance recommended but not included", }, { title: "Personal expenses", description: "Souvenirs, tips, and personal items", }, ], itinerary_days: [], gallery_images: [], featured_image: null, downloadable_items: [], faqs: [ { question: "What is the best time to visit Bali?", answer: "The best time to visit Bali is during the dry season from April to October, when you can expect sunny days and minimal rainfall.", }, { question: "Do I need a visa?", answer: "Most nationalities can get a visa on arrival at the airport. A valid passport with at least 6 months validity is required.", }, { question: "What should I pack?", answer: "Pack light, breathable clothing, swimwear, sunscreen, insect repellent, and comfortable walking shoes. Modest clothing is required for temple visits.", }, ], frontend_tabs: [ { id: "overview", label: "Overview", enabled: true, order: 1, content_type: "general", icon: { type: "icon", value: "book-open" }, }, { id: "itinerary", label: "Itinerary", enabled: true, order: 2, content_type: "itinerary", icon: { type: "icon", value: "calendar" }, }, { id: "included", label: "Included", enabled: true, order: 3, content_type: "included_excluded", icon: { type: "icon", value: "check" }, }, { id: "location", label: "Location", enabled: true, order: 4, content_type: "gallery", icon: { type: "icon", value: "map-pin" }, }, { id: "important_info", label: "Important Info", enabled: true, order: 5, content_type: "general", icon: { type: "icon", value: "file-text" }, }, { id: "downloads", label: "Downloads", enabled: true, order: 6, content_type: "downloads", icon: { type: "icon", value: "download" }, }, { id: "faq", label: "FAQ", enabled: true, order: 7, content_type: "faqs", icon: { type: "icon", value: "help-circle" }, }, { id: "trip_story", label: "Story", enabled: true, order: 8, content_type: "custom", custom_content: "", icon: { type: "icon", value: "book" }, }, { id: "what_makes_special", label: "Special", enabled: true, order: 9, content_type: "custom", custom_content: "", icon: { type: "icon", value: "star" }, }, { id: "testimonials", label: "Testimonials", enabled: true, order: 10, content_type: "reviews", icon: { type: "icon", value: "message-circle" }, }, ], availability_dates: [], status: "draft", scheduled_publish_date: "", scheduled_unpublish_date: "", version: 1, seasonal_auto_enable: false, seasonal_enable_date: "", seasonal_disable_date: "", meta_title: "7-Day Bali Beach Adventure | Luxury Beach Resort Experience", meta_description: "Experience the best of Bali with our 7-day beach adventure. Pristine beaches, ancient temples, cultural immersion, and world-class spa treatments await.", meta_keywords: "Bali, beach vacation, cultural tour, spa retreat, Indonesia travel", attributes: {}, }, { // Trip 2: Mountain Trekking Adventure title: "Everest Base Camp Trek - 14 Days", slug: "everest-base-camp-trek-14-days", description: "Embark on the adventure of a lifetime with our 14-day Everest Base Camp trek. This challenging yet rewarding journey takes you through the heart of the Himalayas, passing through traditional Sherpa villages, ancient monasteries, and breathtaking mountain landscapes. Experience the rich culture of the Khumbu region while pushing your limits to reach the base of the world's highest mountain.", short_description: "Embark on the adventure of a lifetime with our 14-day Everest Base Camp trek through the heart of the Himalayas.", highlights: [ "Trek to Everest Base Camp (5,364m)", "Visit ancient Buddhist monasteries", "Experience Sherpa culture", "Breathtaking mountain views", "Professional mountain guides", ], trip_details: "This 14-day trekking adventure begins in Kathmandu, where you'll prepare for your journey and meet your experienced guides. Fly to Lukla, the gateway to the Khumbu region, and begin your trek through the stunning Himalayan landscape. Pass through traditional Sherpa villages like Namche Bazaar, Tengboche, and Dingboche, each offering unique cultural experiences and acclimatization opportunities. Visit ancient monasteries, learn about Sherpa traditions, and witness the daily life of mountain communities. The journey culminates at Everest Base Camp, where you'll stand in the shadow of the world's highest peak. Along the way, you'll be treated to spectacular views of peaks like Ama Dablam, Lhotse, and of course, Mount Everest itself.", what_makes_special: "Our trek includes experienced mountain guides, proper acclimatization schedules, high-altitude porters, and comprehensive safety equipment. We prioritize responsible tourism, supporting local communities and ensuring minimal environmental impact.", trip_story: "The crisp mountain air fills your lungs as you take your first steps on the trail. With each passing day, the mountains grow larger, the air thinner, and the sense of accomplishment greater. You wake before dawn to witness the sun painting the peaks in shades of gold and pink. You share meals with Sherpa families, learning about their way of life and the challenges they face in this harsh yet beautiful environment. As you approach Base Camp, the anticipation builds. When you finally arrive, standing at 5,364 meters with Everest towering above, you realize this is more than a trek—it's a transformation. The journey changes you, teaching resilience, appreciation for nature, and respect for the mountains and the people who call them home.", video_url: "https://www.youtube.com/watch?v=example2", virtual_tour_url: "", testimonial_review_ids: [], // Will be populated from actual reviews destinations: [], // Will be populated starting_location: "Kathmandu International Airport", ending_location: "Lukla Airport", countries: ["Nepal"], regions: ["Khumbu Region"], starting_latitude: "27.9881", starting_longitude: "86.9250", ending_latitude: "27.6837", ending_longitude: "86.7330", landmarks: [ "Mount Everest", "Namche Bazaar", "Tengboche Monastery", "Kala Patthar", ], trip_type: "multi_day", duration_days: "14", duration_nights: "13", available_from: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000) .toISOString() .split("T")[0], available_to: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) .toISOString() .split("T")[0], booking_window_days: "60", seasonal_availability: "March to May, September to November", best_season: "October to November", peak_season: "October to November", off_season: "December to February, June to August", activity_types: [], difficulty_level: "", trip_category: [], tags: ["trekking", "mountains", "adventure", "challenging", "everest"], featured_priority: "featured", accommodation_type: "Teahouse", meal_plan: "full_board", accommodation_details: "Traditional teahouses along the route with basic amenities. Rooms are shared, and facilities become more basic as altitude increases.", transportation_included: true, pickup_location: "Kathmandu International Airport", dropoff_location: "Lukla Airport", transportation_details: "Domestic flights Kathmandu-Lukla-Kathmandu included. Airport transfers included.", pricing_type: "regular", original_price: "1899", discounted_price: "1699", deposit_amount: "500", deposit_percentage: "", payment_terms: "50% deposit required at booking, remaining 50% due 60 days before departure", max_travelers: "12", min_travelers: "2", booking_deadline_hours: "24", cancellation_policy: "Free cancellation up to 60 days before departure. 50% refund for cancellations 30-60 days before. No refund for cancellations less than 30 days before.", age_min: "18", age_max: "65", physical_requirements: "Excellent physical fitness required. Previous trekking experience recommended. Must be able to walk 6-8 hours daily at high altitude.", visa_requirements: "Tourist visa required for Nepal. Can be obtained on arrival at airport or in advance. Valid passport required.", vaccination_requirements: "Recommended: Hepatitis A, Typhoid, Japanese Encephalitis, and routine vaccinations. Consult with travel health clinic.", disable_booking: false, has_default_time_slots: false, default_time_slots: [], departure_time: "", included_items: [ { title: "Accommodation", description: "13 nights in teahouses along the route", }, { title: "All meals", description: "Breakfast, lunch, and dinner included", }, { title: "Professional guides", description: "Experienced mountain guides and porters", }, { title: "Permits", description: "TIMS and Sagarmatha National Park permits", }, { title: "Domestic flights", description: "Kathmandu-Lukla-Kathmandu flights", }, { title: "Equipment", description: "Sleeping bag and down jacket rental", }, ], excluded_items: [ { title: "International flights", description: "Flights to and from Kathmandu", }, { title: "Travel insurance", description: "Comprehensive travel and medical insurance required", }, { title: "Personal equipment", description: "Trekking boots, clothing, and personal items", }, { title: "Tips", description: "Tips for guides and porters (recommended)", }, { title: "Personal expenses", description: "Drinks, snacks, and souvenirs", }, ], itinerary_days: [], gallery_images: [], featured_image: null, downloadable_items: [], faqs: [ { question: "How difficult is the trek?", answer: "This is a challenging trek requiring excellent physical fitness. You'll be walking 6-8 hours daily at high altitude. Previous trekking experience is recommended.", }, { question: "What is the altitude at Base Camp?", answer: "Everest Base Camp is located at 5,364 meters (17,598 feet) above sea level.", }, { question: "What happens if I get altitude sickness?", answer: "Our guides are trained to recognize and treat altitude sickness. We have proper acclimatization schedules and emergency descent plans in place.", }, ], frontend_tabs: [ { id: "overview", label: "Overview", enabled: true, order: 1, content_type: "general", icon: { type: "icon", value: "book-open" }, }, { id: "itinerary", label: "Itinerary", enabled: true, order: 2, content_type: "itinerary", icon: { type: "icon", value: "calendar" }, }, { id: "included", label: "Included", enabled: true, order: 3, content_type: "included_excluded", icon: { type: "icon", value: "check" }, }, { id: "location", label: "Location", enabled: true, order: 4, content_type: "gallery", icon: { type: "icon", value: "map-pin" }, }, { id: "important_info", label: "Important Info", enabled: true, order: 5, content_type: "general", icon: { type: "icon", value: "info" }, }, { id: "downloads", label: "Downloads", enabled: true, order: 6, content_type: "downloads", icon: { type: "icon", value: "download" }, }, { id: "faq", label: "FAQ", enabled: true, order: 7, content_type: "faqs", icon: { type: "icon", value: "help-circle" }, }, { id: "trip_story", label: "Story", enabled: true, order: 8, content_type: "custom", custom_content: "", icon: { type: "icon", value: "book" }, }, { id: "what_makes_special", label: "Special", enabled: true, order: 9, content_type: "custom", custom_content: "", icon: { type: "icon", value: "star" }, }, { id: "testimonials", label: "Testimonials", enabled: true, order: 10, content_type: "reviews", icon: { type: "icon", value: "message-circle" }, }, ], availability_dates: [], status: "draft", scheduled_publish_date: "", scheduled_unpublish_date: "", version: 1, seasonal_auto_enable: false, seasonal_enable_date: "", seasonal_disable_date: "", meta_title: "Everest Base Camp Trek - 14 Days | Ultimate Himalayan Adventure", meta_description: "Embark on the adventure of a lifetime with our 14-day Everest Base Camp trek. Experience Sherpa culture, ancient monasteries, and breathtaking mountain views.", meta_keywords: "Everest Base Camp, trekking, Nepal, Himalayas, adventure travel, mountain trek", attributes: {}, price_types: [], }, { // Trip 3: European City Tour title: "European Grand Tour - 10 Days", slug: "european-grand-tour-10-days", description: "Discover the best of Europe with our 10-day grand tour covering Paris, Rome, and Barcelona. Experience world-famous landmarks, indulge in exquisite cuisine, explore rich history and art, and immerse yourself in diverse European cultures. This carefully crafted journey takes you through three of Europe's most iconic cities, each offering unique experiences and unforgettable memories.", short_description: "Discover the best of Europe with our 10-day grand tour covering Paris, Rome, and Barcelona.", highlights: [ "Eiffel Tower and Louvre Museum", "Colosseum and Vatican City", "Sagrada Familia and Park Güell", "World-class cuisine", "Professional local guides", ], trip_details: "Begin your European adventure in the City of Light—Paris. Explore iconic landmarks like the Eiffel Tower, Louvre Museum, and Notre-Dame Cathedral. Stroll along the Champs-Élysées, enjoy a Seine River cruise, and indulge in French pastries at charming cafés. Next, travel to Rome, the Eternal City, where ancient history comes alive. Visit the Colosseum, Roman Forum, and Vatican City with its stunning Sistine Chapel. Enjoy authentic Italian cuisine and gelato while exploring cobblestone streets. Conclude your journey in Barcelona, Spain's vibrant cultural capital. Admire Gaudí's architectural masterpieces including the Sagrada Familia and Park Güell. Experience the lively atmosphere of Las Ramblas, enjoy tapas and sangria, and relax on beautiful Mediterranean beaches.", what_makes_special: "This tour includes skip-the-line tickets to major attractions, private guided tours in each city, and carefully selected accommodations in prime locations. Our small group size ensures personalized attention and authentic local experiences.", trip_story: "Your European adventure begins as you step off the plane in Paris, greeted by the elegant architecture and romantic atmosphere that has inspired artists for centuries. Each day brings new discoveries—from the artistic treasures of the Louvre to the bohemian charm of Montmartre. In Rome, you walk in the footsteps of emperors and gladiators, feeling the weight of history in every ancient stone. The Vatican's art and architecture leave you in awe, while a simple plate of pasta in a local trattoria reminds you that the best experiences are often the simplest. Barcelona welcomes you with its unique blend of Gothic and Modernist architecture, vibrant street life, and Mediterranean warmth. As you watch the sunset from Park Güell, you realize that this journey has not just shown you three cities—it has shown you three different ways of living, three different approaches to art and culture, and three different reasons to fall in love with Europe.", video_url: "https://www.youtube.com/watch?v=example3", virtual_tour_url: "", testimonial_review_ids: [], // Will be populated from actual reviews destinations: [], starting_location: "Charles de Gaulle Airport (CDG)", ending_location: "El Prat Airport (BCN)", countries: ["France", "Italy", "Spain"], regions: ["Île-de-France", "Lazio", "Catalonia"], starting_latitude: "48.8566", starting_longitude: "2.3522", ending_latitude: "41.3792", ending_longitude: "2.1281", landmarks: [ "Eiffel Tower", "Colosseum", "Sagrada Familia", "Louvre Museum", "Vatican City", ], trip_type: "multi_day", duration_days: "10", duration_nights: "9", available_from: new Date(Date.now() + 45 * 24 * 60 * 60 * 1000) .toISOString() .split("T")[0], available_to: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) .toISOString() .split("T")[0], booking_window_days: "45", seasonal_availability: "Year-round", best_season: "April to June, September to October", peak_season: "June to August", off_season: "November to March", activity_types: [], difficulty_level: "", trip_category: [], tags: ["cultural", "city-tour", "history", "art", "food"], featured_priority: "featured", accommodation_type: "Hotel", meal_plan: "breakfast", accommodation_details: "4-star hotels in city centers with easy access to major attractions", transportation_included: true, pickup_location: "Charles de Gaulle Airport", dropoff_location: "El Prat Airport", transportation_details: "High-speed train between cities, private transfers, and metro passes included", pricing_type: "regular", original_price: "2499", discounted_price: "", price_types: [], deposit_amount: "600", deposit_percentage: "", payment_terms: "40% deposit required at booking, remaining 60% due 45 days before departure", max_travelers: "20", min_travelers: "4", booking_deadline_hours: "24", cancellation_policy: "Free cancellation up to 45 days before departure. 75% refund for cancellations 30-45 days before. 50% refund for cancellations 15-30 days before. No refund for cancellations less than 15 days before.", age_min: "", age_max: "", physical_requirements: "Moderate walking required. Some sites involve stairs and uneven surfaces. Suitable for most fitness levels.", visa_requirements: "Schengen visa required for most non-EU nationals. Apply well in advance as processing can take several weeks.", vaccination_requirements: "No mandatory vaccinations. Routine vaccinations recommended.", disable_booking: false, has_default_time_slots: false, default_time_slots: [], departure_time: "", included_items: [ { title: "Accommodation", description: "9 nights in 4-star city center hotels", }, { title: "Breakfast", description: "Daily breakfast included" }, { title: "Transportation", description: "High-speed trains, airport transfers, and city metro passes", }, { title: "Guided tours", description: "Professional local guides in each city", }, { title: "Skip-the-line tickets", description: "Priority access to major attractions", }, { title: "Welcome dinner", description: "Traditional welcome dinner in Paris", }, ], excluded_items: [ { title: "International flights", description: "Flights to Paris and from Barcelona", }, { title: "Lunch and dinner", description: "Meals other than breakfast and welcome dinner", }, { title: "Travel insurance", description: "Travel insurance recommended", }, { title: "Personal expenses", description: "Souvenirs, tips, and personal items", }, ], itinerary_days: [], gallery_images: [], featured_image: null, downloadable_items: [], faqs: [ { question: "Do I need a visa?", answer: "Most non-EU nationals need a Schengen visa. Apply at the embassy of your first entry country (France) well in advance.", }, { question: "What languages are spoken?", answer: "English-speaking guides provided. Local languages are French, Italian, and Spanish, but English is widely spoken in tourist areas.", }, { question: "Is this suitable for families?", answer: "Yes, this tour is family-friendly. However, some museums and sites may have age restrictions for children.", }, ], frontend_tabs: [ { id: "overview", label: "Overview", enabled: true, order: 1, content_type: "general", icon: { type: "icon", value: "book-open" }, }, { id: "itinerary", label: "Itinerary", enabled: true, order: 2, content_type: "itinerary", icon: { type: "icon", value: "calendar" }, }, { id: "included", label: "Included", enabled: true, order: 3, content_type: "included_excluded", icon: { type: "icon", value: "check" }, }, { id: "location", label: "Location", enabled: true, order: 4, content_type: "gallery", icon: { type: "icon", value: "map-pin" }, }, { id: "important_info", label: "Important Info", enabled: true, order: 5, content_type: "general", icon: { type: "icon", value: "info" }, }, { id: "downloads", label: "Downloads", enabled: true, order: 6, content_type: "downloads", icon: { type: "icon", value: "download" }, }, { id: "faq", label: "FAQ", enabled: true, order: 7, content_type: "faqs", icon: { type: "icon", value: "help-circle" }, }, { id: "trip_story", label: "Story", enabled: true, order: 8, content_type: "custom", custom_content: "", icon: { type: "icon", value: "book" }, }, { id: "what_makes_special", label: "Special", enabled: true, order: 9, content_type: "custom", custom_content: "", icon: { type: "icon", value: "star" }, }, { id: "testimonials", label: "Testimonials", enabled: true, order: 10, content_type: "reviews", icon: { type: "icon", value: "message-circle" }, }, ], availability_dates: [], status: "draft", scheduled_publish_date: "", scheduled_unpublish_date: "", version: 1, seasonal_auto_enable: false, seasonal_enable_date: "", seasonal_disable_date: "", meta_title: "European Grand Tour - 10 Days | Paris, Rome & Barcelona", meta_description: "Discover the best of Europe with our 10-day grand tour covering Paris, Rome, and Barcelona. Experience iconic landmarks, world-class cuisine, and rich history.", meta_keywords: "Europe tour, Paris, Rome, Barcelona, European travel, cultural tour", attributes: {}, }, ]; const [formData, setFormData] = useState({ title: "", slug: "", description: "", highlights: [], trip_details: "", short_description: "", what_makes_special: "", trip_story: "", video_url: "", virtual_tour_url: "", testimonial_review_ids: [], destinations: [], // Array of destination IDs starting_location: "", ending_location: "", countries: [], regions: [], starting_latitude: "", starting_longitude: "", ending_latitude: "", ending_longitude: "", landmarks: [], trip_type: "multi_day", duration_days: "", duration_nights: "", available_from: "", available_to: "", booking_window_days: "", seasonal_availability: "", best_season: "", peak_season: "", off_season: "", activity_types: [], // Array of activity IDs difficulty_level: "", trip_category: [], tags: [], featured_priority: "none", accommodation_type: "", meal_plan: "", accommodation_details: "", transportation_included: false, pickup_location: "", dropoff_location: "", transportation_details: "", pricing_type: "regular", original_price: "", discounted_price: "", price_types: [], deposit_amount: "", deposit_percentage: "", payment_terms: "", max_travelers: "10", min_travelers: "2", booking_deadline_hours: "24", cancellation_policy: "Flexible", age_min: "18", age_max: "65", physical_requirements: "Moderate", visa_requirements: "Schengen visa", vaccination_requirements: "COVID-19 vaccination", disable_booking: false, has_default_time_slots: false, default_time_slots: [], departure_time: "09:00", included_items: [], excluded_items: [], attributes: {}, // attribute_id -> value mapping itinerary_days: [], gallery_images: [], featured_image: null, downloadable_items: [], faqs: [], frontend_tabs: [ { id: "overview", label: "Overview", enabled: true, order: 1, content_type: "general", icon: { type: "icon", value: "book-open" }, }, { id: "itinerary", label: "Itinerary", enabled: true, order: 2, content_type: "itinerary", icon: { type: "icon", value: "calendar" }, }, { id: "included", label: "Included", enabled: true, order: 3, content_type: "included_excluded", icon: { type: "icon", value: "check" }, }, { id: "location", label: "Location", enabled: true, order: 4, content_type: "gallery", icon: { type: "icon", value: "map-pin" }, }, { id: "important_info", label: "Important Info", enabled: true, order: 5, content_type: "general", icon: { type: "icon", value: "info" }, }, { id: "downloads", label: "Downloads", enabled: true, order: 6, content_type: "downloads", icon: { type: "icon", value: "download" }, }, { id: "faq", label: "FAQ", enabled: true, order: 7, content_type: "faqs", icon: { type: "icon", value: "help-circle" }, }, { id: "trip_story", label: "Story", enabled: true, order: 8, content_type: "custom", custom_content: "", icon: { type: "icon", value: "book" }, }, { id: "what_makes_special", label: "Special", enabled: true, order: 9, content_type: "custom", custom_content: "", icon: { type: "icon", value: "star" }, }, { id: "testimonials", label: "Testimonials", enabled: true, order: 10, content_type: "reviews", icon: { type: "icon", value: "message-circle" }, }, ], availability_dates: [], status: "draft", scheduled_publish_date: "", scheduled_unpublish_date: "", version: 1, seasonal_auto_enable: false, seasonal_enable_date: "", seasonal_disable_date: "", meta_title: "", meta_description: "", meta_keywords: "", }); const [errors, setErrors] = useState>({}); const [isSubmitting, setIsSubmitting] = useState(false); const [copiedErrorDetails, setCopiedErrorDetails] = useState(false); const copyErrorDetailsToClipboard = (text: string) => { if (!text) return; const fallbackCopy = () => { const textarea = document.createElement("textarea"); textarea.value = text; textarea.style.position = "fixed"; textarea.style.opacity = "0"; document.body.appendChild(textarea); textarea.select(); try { document.execCommand("copy"); } catch (e) { // ignore } document.body.removeChild(textarea); setCopiedErrorDetails(true); setTimeout(() => setCopiedErrorDetails(false), 1500); }; try { if (navigator?.clipboard?.writeText) { navigator.clipboard .writeText(text) .then(() => { setCopiedErrorDetails(true); setTimeout(() => setCopiedErrorDetails(false), 1500); }) .catch(() => fallbackCopy()); } else { fallbackCopy(); } } catch { fallbackCopy(); } }; // Get action and id from URL const action = useMemo(() => { const params = new URLSearchParams(window.location.search); return params.get("action") || "create"; }, []); const tripId = useMemo(() => { const params = new URLSearchParams(window.location.search); return params.get("id") ? parseInt(params.get("id") || "0") : null; }, []); const isEditMode = action === "edit" && tripId !== null; // Fetch traveler categories - LAZY LOAD: only when pricing section is visited const { data: travelerCategoriesData, isLoading: isLoadingCategories } = useQuery({ queryKey: ["traveler-categories"], queryFn: async () => { try { const response = await apiClient.get("/traveler-categories", { params: { per_page: 100, status: "publish", // Only get published categories }, }); const categories = response?.data?.data || response?.data || response || []; return Array.isArray(categories) ? categories : []; } catch (error: any) { showToast( error?.message || __("Failed to load traveler categories", "yatra"), "error", ); return []; } }, enabled: can("yatra_view_trips") && (currentSection === "pricing" || visitedSections.has("pricing")), staleTime: 5 * 60 * 1000, // Cache for 5 minutes }); // Get only active/published categories const activeCategories = useMemo(() => { const categories = travelerCategoriesData || []; return categories.filter( (cat: TravelerCategory) => cat.status === "active" || cat.status === "publish", ); }, [travelerCategoriesData]); // Fetch settings to get global currency const { data: settingsData } = useQuery({ queryKey: ["settings"], queryFn: async () => { try { const response = await fetchSettings(); return response; } catch (error: any) { // Return default currency if settings fetch fails return { currency: "USD" }; } }, enabled: can("yatra_view_trips"), }); // Get global currency from settings, default to USD const cur = settingsData?.currency; const defCur = settingsData?.default_currency; const globalCurrency = typeof cur === "string" && cur.length > 0 ? cur : typeof defCur === "string" && defCur.length > 0 ? defCur : "USD"; // Fetch activities from API - LAZY LOAD: only when categorization section is visited const { data: activitiesData } = useQuery({ queryKey: ["activities-published"], queryFn: async () => { try { const response = await apiClient.get("/activities", { params: { per_page: 100, status: "publish", // Only get published activities }, }); return response.data || []; } catch (error: any) { showToast( error?.message || __("Failed to load activities", "yatra"), "error", ); return []; } }, enabled: can("yatra_view_trips") && (currentSection === "categorization" || visitedSections.has("categorization")), staleTime: 5 * 60 * 1000, // Cache for 5 minutes }); // LAZY LOAD: only when categorization section is visited const { data: tripCategoriesResponse } = useQuery({ queryKey: ["trip-categories", "published"], queryFn: async () => { try { const response = await apiClient.get("/trip-categories", { params: { per_page: 100, status: "publish", hierarchical: true, orderby: "name", order: "ASC", }, }); return response; } catch (error: any) { showToast( error?.message || __("Failed to load trip categories", "yatra"), "error", ); return []; } }, enabled: can("yatra_view_trips"), staleTime: 5 * 60 * 1000, // Cache for 5 minutes }); // LAZY LOAD: only when categorization section is visited const { data: difficultyLevelsResponse, isLoading: isLoadingDifficultyLevels, } = useQuery({ queryKey: ["difficulty-levels", "published"], queryFn: async () => { try { const response = await apiClient.get("/difficulty-levels", { params: { per_page: 100, status: "publish", orderby: "sorting", order: "ASC", }, }); return response.data || []; } catch (error: any) { showToast( error?.message || __("Failed to load difficulty levels", "yatra"), "error", ); return []; } }, enabled: can("yatra_view_trips") && (currentSection === "categorization" || visitedSections.has("categorization")), staleTime: 5 * 60 * 1000, // Cache for 5 minutes }); const tripCategories: TripCategoryOption[] = useMemo(() => { const payload = extractArrayPayload(tripCategoriesResponse); return (payload as TripCategoryOption[]).filter((category) => { if (!category) return false; if (typeof category !== "object") return false; if ( "status" in category && category.status && category.status !== "publish" ) { return false; } return true; }); }, [tripCategoriesResponse]); const difficultyLevels: DifficultyLevelOption[] = useMemo(() => { const payload = extractArrayPayload(difficultyLevelsResponse); return (payload as DifficultyLevelOption[]).filter((level) => { if (!level) return false; if (typeof level !== "object") return false; if ("status" in level && level.status && level.status !== "publish") { return false; } return true; }); }, [difficultyLevelsResponse]); // Fetch destinations from API - LAZY LOAD: only when location section is visited const { data: destinationsData } = useQuery({ queryKey: ["destinations-published"], queryFn: async () => { try { const response = await apiClient.get("/destinations", { params: { per_page: 100, status: "publish", // Only get published destinations }, }); return response.data || []; } catch (error: any) { showToast( error?.message || __("Failed to load destinations", "yatra"), "error", ); return []; } }, enabled: can("yatra_view_trips") && (isEditMode || currentSection === "location" || visitedSections.has("location")), staleTime: 5 * 60 * 1000, // Cache for 5 minutes }); // Fetch trip data if editing const { data: tripData, isLoading: isLoadingTrip, error: tripError, } = useQuery({ queryKey: ["trip", tripId], queryFn: async () => { if (!tripId) return null; try { const response = await apiClient.get(`/trips/${tripId}`); // WordPress REST API returns data directly, but check if it's wrapped // Some endpoints return { data: {...} }, others return {...} directly const tripData = response?.data || response; return tripData; } catch (error: any) { console.error("Error loading trip:", error); showToast( error?.message || __("Failed to load trip data", "yatra"), "error", ); throw error; } }, enabled: !!tripId && isEditMode, staleTime: 0, // Always fetch fresh data to ensure downloadable items persist after save }); // Build error context for edit error state const tripErrorContext = useMemo( () => getErrorContext(tripError), [tripError], ); // Fetch trip attributes if editing (TripAttributesSection waits until resolved to avoid empty-first-render bug) const tripAttributesQueryEnabled = Boolean(tripId && isEditMode); const { data: tripAttributesData, isPending: isTripAttributesPending } = useQuery({ queryKey: ["trip-attributes", tripId], queryFn: async () => { if (!tripId) return {}; try { const response = await apiClient.get(`/trips/${tripId}/attributes`); // Try different ways to extract the data let payload = response?.data; if (!payload || !Array.isArray(payload)) { payload = response; } if (!payload || !Array.isArray(payload)) { payload = response?.data?.data; } const attributes = Array.isArray(payload) ? payload : Array.isArray(payload?.data) ? payload.data : []; // Convert to attributeId => value mapping for form const attributesMap: Record = {}; attributes.forEach((attr: any) => { const attributeId = Number(attr.attribute_id || attr.id); if (attributeId) { let value = ""; // Read from relationship_metadata (contains complete attribute data) if (attr.relationship_metadata) { try { const metadata = typeof attr.relationship_metadata === "string" ? JSON.parse(attr.relationship_metadata) : attr.relationship_metadata; value = metadata.value || ""; } catch (e) { console.warn( "Failed to parse relationship_metadata:", attr.relationship_metadata, e, ); value = ""; } } else { // Fallback to old format if metadata is not available value = attr.value || ""; } attributesMap[attributeId] = value; } }); return attributesMap; } catch (error) { console.error("Failed to fetch trip attributes:", error); return {}; } }, enabled: tripAttributesQueryEnabled, staleTime: 5 * 60 * 1000, // Cache for 5 minutes }); const tripAttributesReady = !tripAttributesQueryEnabled || !isTripAttributesPending; // Helper function to normalize array of items to IDs // Helper to normalize highlights (can be array of strings or objects) const normalizeHighlights = (highlights: any): string[] => { if (!highlights) return []; if (Array.isArray(highlights)) { return highlights .map((h: any) => { if (typeof h === "string") return h; if (h && typeof h === "object") { return h.highlight_text || h.text || h.title || String(h); } return String(h); }) .filter((h: string) => h.trim().length > 0); } if (typeof highlights === "string") { try { const parsed = JSON.parse(highlights); return normalizeHighlights(parsed); } catch { return [highlights]; } } return []; }; const normalizeDownloadableItems = (items: any): DownloadableItem[] => { if (!items || !Array.isArray(items)) return []; return items .filter((row: any) => row && typeof row === "object") .map((row: any, idx: number) => { const rawVisibility = (row.visibility ?? "booked_only") as any; const mappedVisibility = rawVisibility === "paid_only" ? "booked_only" : rawVisibility; const safeVisibility: DownloadableItem["visibility"] = [ "public", "logged_in", "booked_only", ].includes(mappedVisibility) ? mappedVisibility : "booked_only"; const title = ( row.title ?? row.download_title ?? row.downlaod_title ?? "" ).toString(); const description = ( row.description ?? row.download_description ?? row.downlaod_description ?? "" ).toString(); const attachmentIdRaw = row.attachment_id ?? row.download_file ?? row.downlaod_file; const attachmentUrl = ( row.attachment_url ?? row.content_url ?? "" ).toString(); const attachmentTitle = (row.attachment_title ?? "").toString(); const enabledRaw = row.enabled ?? row.is_downloadable ?? row.download_enabled ?? row.downlaod_enabled; return { id: row.id != null ? Number(row.id) : null, title, description, attachment_id: attachmentIdRaw != null ? Number(attachmentIdRaw) : null, attachment_url: attachmentUrl, attachment_title: attachmentTitle, visibility: safeVisibility, enabled: enabledRaw != null ? Boolean(enabledRaw) : true, sort_order: row.sort_order != null ? Number(row.sort_order) : idx + 1, }; }); }; // Helper to extract IDs from mixed arrays const extractIds = (items: any): number[] => { if (!items || !Array.isArray(items)) return []; return items .map((item: any) => { if (typeof item === "number") return item; if (typeof item === "string") return parseInt(item) || 0; if (item && typeof item === "object") { return ( item.id || item.destination_id || item.activity_id || item.category_id || 0 ); } return 0; }) .filter((id: number) => !isNaN(id) && id > 0); }; // Helper to normalize itinerary days const normalizeItineraryDays = (days: any): any[] => { if (!days) return []; if (Array.isArray(days)) return days; if (typeof days === "string") { try { const parsed = JSON.parse(days); return Array.isArray(parsed) ? parsed : []; } catch { return []; } } return []; }; // Helper to normalize availability dates const normalizeAvailabilityDates = (dates: any): any[] => { if (!dates) return []; if (Array.isArray(dates)) return dates; if (typeof dates === "string") { try { const parsed = JSON.parse(dates); return Array.isArray(parsed) ? parsed : []; } catch { return []; } } return []; }; // Utility function to normalize gallery images const normalizeGalleryImages = ( images: any, ): Array<{ id: number; url: string; thumbnail_url?: string; alt_text?: string; caption?: string; }> => { if (!images) return []; if (Array.isArray(images)) { return images .map((img: any) => { if (typeof img === "string") { return { id: 0, url: img }; } if (img && typeof img === "object") { return { id: img.id || img.image_id || 0, url: img.url || img.image_url || img.src || "", thumbnail_url: img.thumbnail_url || img.thumb_url || "", alt_text: img.alt_text || img.alt || "", caption: img.caption || img.title || "", }; } return { id: 0, url: "" }; }) .filter((item: any) => item.url); } if (typeof images === "string") { try { const parsed = JSON.parse(images); return normalizeGalleryImages(parsed); } catch { return [{ id: 0, url: images }]; } } return []; }; // Helper to normalize FAQs const normalizeFaqs = (faqs: any): FAQ[] => { if (!faqs || !Array.isArray(faqs)) return []; return faqs .map((faq: any) => { if (faq && typeof faq === "object") { return { question: faq.question || "", answer: faq.answer || "", }; } return { question: "", answer: "" }; }) .filter((faq: any) => faq.question && faq.answer); }; useEffect(() => { if (!tripData || !isEditMode) { return; } // Preserve current itinerary_days and gallery_images if they exist and have content // This prevents wiping out data when user updates trip in itinerary builder const currentItineraryDays = formData.itinerary_days || []; const currentGalleryImages = formData.gallery_images || []; // Only use database data if current data is empty or if this is initial load const shouldPreserveItinerary = currentItineraryDays.length > 0; const shouldPreserveGallery = currentGalleryImages.length > 0; setFormData({ title: tripData.title || "", slug: tripData.slug || "", description: tripData.description || "", highlights: normalizeHighlights(tripData.highlights), trip_details: tripData.trip_details || "", short_description: tripData.short_description || "", what_makes_special: tripData.what_makes_special || "", trip_story: tripData.trip_story || "", video_url: tripData.video_url || "", virtual_tour_url: tripData.virtual_tour_url || "", testimonial_review_ids: Array.isArray(tripData.testimonial_review_ids) ? tripData.testimonial_review_ids : [], destinations: extractIds(tripData.destinations || []), starting_location: tripData.starting_location || "", ending_location: tripData.ending_location || "", countries: Array.isArray(tripData.countries) ? tripData.countries : [], regions: Array.isArray(tripData.regions) ? tripData.regions : [], starting_latitude: tripData.starting_latitude?.toString() || "", starting_longitude: tripData.starting_longitude?.toString() || "", ending_latitude: tripData.ending_latitude?.toString() || "", ending_longitude: tripData.ending_longitude?.toString() || "", landmarks: Array.isArray(tripData.landmarks) ? tripData.landmarks : [], trip_type: (tripData.trip_type || (tripData.duration_days && parseInt(tripData.duration_days?.toString() || "0") === 1 ? "single_day" : "multi_day")) as "single_day" | "multi_day" | "flexible", duration_days: tripData.duration_days?.toString() || "", duration_nights: tripData.duration_nights?.toString() || "", available_from: tripData.available_from || "", available_to: tripData.available_to || "", booking_window_days: tripData.booking_window_days?.toString() || "", seasonal_availability: tripData.seasonal_availability || "", best_season: tripData.best_season || "", peak_season: tripData.peak_season || "", off_season: tripData.off_season || "", activity_types: extractIds(tripData.activity_types || []), difficulty_level: tripData.difficulty_level?.toString() || "", trip_category: extractIds(tripData.trip_category || []), tags: Array.isArray(tripData.tags) ? tripData.tags : [], featured_priority: tripData.featured_priority || "none", accommodation_type: tripData.accommodation_type || "", meal_plan: tripData.meal_plan || "", accommodation_details: tripData.accommodation_details || "", transportation_included: tripData.transportation_included || false, pickup_location: tripData.pickup_location || "", dropoff_location: tripData.dropoff_location || "", transportation_details: tripData.transportation_details || "", pricing_type: (tripData.pricing_type || (tripData.price_types && Array.isArray(tripData.price_types) && tripData.price_types.length > 0 ? "traveler_based" : "regular")) as "regular" | "traveler_based", original_price: tripData.original_price?.toString() || "", discounted_price: tripData.discounted_price?.toString() || "", price_types: Array.isArray(tripData.price_types) ? tripData.price_types.map((pt: any) => ({ category_id: Number(pt.category_id) || 0, original_price: pt.original_price?.toString() || "", discounted_price: pt.discounted_price?.toString() || "", is_default: Boolean(pt.is_default), })) : [], deposit_amount: tripData.deposit_amount?.toString() || "", deposit_percentage: tripData.deposit_percentage?.toString() || "", payment_terms: tripData.payment_terms || "", max_travelers: tripData.max_travelers?.toString() || "", min_travelers: tripData.min_travelers?.toString() || "", booking_deadline_hours: tripData.booking_deadline_hours || "", cancellation_policy: tripData.cancellation_policy || "", age_min: tripData.age_min?.toString() || "", age_max: tripData.age_max?.toString() || "", physical_requirements: tripData.physical_requirements || "", visa_requirements: tripData.visa_requirements || "", vaccination_requirements: tripData.vaccination_requirements || "", disable_booking: Boolean(tripData.custom_fields?.disable_booking), // tinyint(1) columns can serialize from PHP/wpdb as the string "0"/"1". // JS treats "0" as truthy, so a plain `value || false` would leave the // checkbox stuck on after the user un-checked + saved. Coerce explicitly. has_default_time_slots: tripData.has_default_time_slots === true || tripData.has_default_time_slots === 1 || tripData.has_default_time_slots === "1", default_time_slots: Array.isArray(tripData.default_time_slots) ? tripData.default_time_slots : tripData.default_time_slots ? JSON.parse(tripData.default_time_slots) : [], departure_time: tripData.departure_time || "09:00", included_items: normalizeAmenityItems(tripData.included_items), excluded_items: normalizeAmenityItems(tripData.excluded_items), // Preserve current itinerary data if it exists, otherwise use database data itinerary_days: shouldPreserveItinerary ? currentItineraryDays : normalizeItineraryDays(tripData.itinerary_days), // Preserve current gallery data if it exists, otherwise use database data gallery_images: shouldPreserveGallery ? currentGalleryImages : normalizeGalleryImages(tripData.gallery_images), featured_image: tripData.featured_image ? Number(tripData.featured_image) : null, downloadable_items: normalizeDownloadableItems( tripData.downloadable_items, ), faqs: normalizeFaqs(tripData.faqs), frontend_tabs: Array.isArray(tripData.frontend_tabs) ? tripData.frontend_tabs : [ // Core sections (always present) - in logical order { id: "overview", label: "Overview", enabled: true, order: 1, content_type: "overview", icon: { type: "icon", value: "book" }, }, { id: "itinerary", label: "Itinerary", enabled: true, order: 2, content_type: "itinerary", icon: { type: "icon", value: "calendar" }, }, { id: "included", label: "Included", enabled: true, order: 3, content_type: "included_excluded", icon: { type: "icon", value: "check" }, }, { id: "location", label: "Location", enabled: true, order: 4, content_type: "location", icon: { type: "icon", value: "map-pin" }, }, { id: "important_info", label: "Important Info", enabled: true, order: 5, content_type: "important_info", icon: { type: "icon", value: "info" }, }, // Conditional sections (enabled by default, shown conditionally on frontend) { id: "downloads", label: "Downloads", enabled: true, order: 6, content_type: "downloads", icon: { type: "icon", value: "download" }, }, { id: "faq", label: "FAQ", enabled: true, order: 7, content_type: "faq", icon: { type: "icon", value: "help-circle" }, }, { id: "trip_story", label: "Story", enabled: true, order: 8, content_type: "trip_story", custom_content: "", icon: { type: "icon", value: "book" }, }, { id: "what_makes_special", label: "Special", enabled: true, order: 9, content_type: "what_makes_special", custom_content: "", icon: { type: "icon", value: "star" }, }, { id: "testimonials", label: "Testimonials", enabled: true, order: 10, content_type: "testimonials", icon: { type: "icon", value: "message-circle" }, }, ], availability_dates: normalizeAvailabilityDates( tripData.availability_dates, ), status: (tripData.status || "draft") as | "draft" | "review" | "approved" | "publish" | "archived" | "suspended", scheduled_publish_date: tripData.scheduled_publish_date || "", scheduled_unpublish_date: tripData.scheduled_unpublish_date || "", version: tripData.version || 1, seasonal_auto_enable: tripData.seasonal_auto_enable || false, seasonal_enable_date: tripData.seasonal_enable_date || "", seasonal_disable_date: tripData.seasonal_disable_date || "", meta_title: tripData.meta_title || "", meta_description: tripData.meta_description || "", meta_keywords: tripData.meta_keywords || "", attributes: tripAttributesData || {}, }); if (tripData.featured_image_url && tripData.featured_image) { const numericId = Number(tripData.featured_image) || 0; setFeaturedImagePreview(tripData.featured_image_url); if (numericId > 0) { featuredImageCache.current[numericId] = tripData.featured_image_url; } } else if (!tripData.featured_image) { setFeaturedImagePreview(""); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [tripData, tripAttributesData, destinationsData, isEditMode, tripId]); useEffect(() => { let isMounted = true; const resolveFeaturedImage = async () => { const attachmentId = formData.featured_image; if (!attachmentId) { setFeaturedImagePreview(""); return; } const cachedUrl = featuredImageCache.current[attachmentId]; if (cachedUrl) { setFeaturedImagePreview(cachedUrl); return; } if (!mediaBaseUrl) { setFeaturedImagePreview(""); return; } setIsResolvingFeaturedImage(true); try { const data = await wpService.getMedia(attachmentId); const url = data?.source_url || ""; if (url && isMounted) { featuredImageCache.current[attachmentId] = url; setFeaturedImagePreview(url); } else if (isMounted) { setFeaturedImagePreview(""); } } catch (error) { console.error("Failed to resolve featured image URL:", error); if (isMounted) { setFeaturedImagePreview(""); } } finally { if (isMounted) { setIsResolvingFeaturedImage(false); } } }; resolveFeaturedImage(); return () => { isMounted = false; }; }, [formData.featured_image, mediaBaseUrl]); // Map errors to sections - also check for price_type errors const getSectionErrors = (sectionId: SectionId): string[] => { const errorMap: Record = { basic: [ "title", "slug", "description", "featured_image", "trip_type", "duration_days", "duration_nights", ], location: ["destinations", "starting_location", "ending_location"], duration: ["available_from", "available_to", "booking_window_days"], pricing: ["original_price", "discounted_price", "price_types"], booking: ["min_travelers", "max_travelers", "age_min", "age_max"], attributes: ["attributes"], itinerary: ["itinerary_days"], included: ["included_items", "excluded_items"], media: ["gallery_images", "video_url", "virtual_tour_url"], // Removed featured_image - it's in basic section downloads: ["downloadable_items"], categorization: ["trip_category", "activity_types"], faqs: ["faqs"], seo: ["meta_title", "meta_description"], advanced: [], }; const sectionFields = errorMap[sectionId] || []; const fieldErrors = sectionFields.filter((field) => errors[field]); // Also check for price_type errors (they have dynamic keys like price_type_0_original) if (sectionId === "pricing") { const priceTypeErrors = Object.keys(errors).filter((key) => key.startsWith("price_type_"), ); return [...fieldErrors, ...priceTypeErrors]; } return fieldErrors; }; // Define sections - Reorganized for beginner-friendly UX // PHASE 1: ESSENTIALS (Must complete for publishable trip) const essentialsSections: Section[] = [ // 1. Trip Basics - What you're offering { id: "basic", label: __("Trip Basics", "yatra"), icon: FileText, required: true, completed: !!(formData.title?.trim() && formData.slug?.trim()), hasErrors: getSectionErrors("basic").length > 0, }, // 2. Location & Route - Where it happens { id: "location", label: __("Location & Route", "yatra"), icon: MapPin, required: false, completed: !!(formData.destinations.length > 0), hasErrors: getSectionErrors("location").length > 0, }, // 3. Pricing - How much it costs { id: "pricing", label: __("Pricing", "yatra"), icon: DollarSign, required: true, completed: formData.pricing_type === "regular" ? !!( formData.original_price && parseFloat(formData.original_price) > 0 ) : formData.price_types.some( (pt) => pt.original_price && parseFloat(pt.original_price) > 0, ), hasErrors: getSectionErrors("pricing").length > 0, }, // 4. Availability & Booking - When available + booking rules (merged duration + booking) { id: "duration", label: __("Availability & Booking", "yatra"), icon: Calendar, required: false, completed: !!( formData.available_from || formData.available_to || (formData.min_travelers && formData.max_travelers) ), hasErrors: getSectionErrors("duration").length > 0 || getSectionErrors("booking").length > 0, }, ]; // PHASE 2: DETAILS (Enhance trip quality) const detailsSections: Section[] = [ // 5. Trip Details - Description + Itinerary + Included/Excluded (merged 3 sections) { id: "itinerary", label: __("Trip Details", "yatra"), icon: BookOpen, required: false, completed: formData.itinerary_days.length > 0 || formData.included_items.length > 0 || formData.excluded_items.length > 0, hasErrors: getSectionErrors("itinerary").length > 0 || getSectionErrors("included").length > 0, }, ]; // PHASE 3: OPTIMIZATION (Improve discoverability) const optimizationSections: Section[] = [ // 6. Media & Gallery - Photos, videos, testimonials { id: "media", label: __("Media & Gallery", "yatra"), icon: Image, required: false, completed: formData.gallery_images.length > 0 || !!formData.video_url, hasErrors: getSectionErrors("media").length > 0, }, ...(showDownloadsUI ? ([ { id: "downloads", label: __("Downloads", "yatra"), icon: Download, required: false, completed: (formData.downloadable_items || []).length > 0, hasErrors: getSectionErrors("downloads").length > 0, }, ] as Section[]) : []), // 7. Categories & Attributes - Classification + Custom Attributes (merged 2 sections) { id: "categorization", label: __("Categories & Attributes", "yatra"), icon: Tag, required: false, completed: !!( formData.trip_category || formData.activity_types.length > 0 || formData.tags.length > 0 || (formData.attributes && Object.keys(formData.attributes).length > 0) ), hasErrors: getSectionErrors("categorization").length > 0 || getSectionErrors("attributes").length > 0, }, // 8. SEO & Marketing - SEO + FAQs (merged, fixed duplicate) { id: "seo", label: __("SEO & Marketing", "yatra"), icon: Search, required: false, completed: !!( formData.meta_title || formData.meta_description || formData.faqs.length > 0 ), hasErrors: getSectionErrors("seo").length > 0 || getSectionErrors("faqs").length > 0, }, ]; // PHASE 4: ADVANCED (Power users only) const advancedSections: Section[] = [ // 9. Advanced Settings - Publishing, scheduling, technical { id: "advanced", label: __("Advanced Settings", "yatra"), icon: Settings, required: false, completed: false, hasErrors: false, }, ]; const allSections = [ ...essentialsSections, ...detailsSections, ...optimizationSections, ...advancedSections, ]; const currentStepIndex = allSections.findIndex( (s) => s.id === currentSection, ); // Navigation helpers const goToNextSection = () => { if (currentStepIndex < allSections.length - 1) { setCurrentSection(allSections[currentStepIndex + 1].id); } }; const goToPreviousSection = () => { if (currentStepIndex > 0) { setCurrentSection(allSections[currentStepIndex - 1].id); } }; // Smart defaults - Auto-calculate nights from days (only if nights is empty) useEffect(() => { if ( formData.duration_days && formData.trip_type === "multi_day" && (!formData.duration_nights || formData.duration_nights === "") ) { const days = parseInt(formData.duration_days); if (days > 0 && !isNaN(days)) { setFormData((prev) => ({ ...prev, duration_nights: String(Math.max(0, days - 1)), })); } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [formData.duration_days, formData.trip_type]); // Auto-calculate days from nights (only if days is empty) useEffect(() => { if ( formData.duration_nights && formData.trip_type === "multi_day" && (!formData.duration_days || formData.duration_days === "") ) { const nights = parseInt(formData.duration_nights); if (nights > 0 && !isNaN(nights)) { setFormData((prev) => ({ ...prev, duration_days: String(nights + 1) })); } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [formData.duration_nights, formData.trip_type]); // Auto-generate slug from title const generateSlug = (title: string): string => { return title .toLowerCase() .trim() .replace(/[^\w\s-]/g, "") .replace(/[\s_-]+/g, "-") .replace(/^-+|-+$/g, ""); }; const handleTitleChange = (value: string) => { setFormData((prev) => ({ ...prev, title: value, slug: prev.slug || generateSlug(value), })); if (errors.title) { setErrors((prev) => ({ ...prev, title: "" })); } }; const handleFieldChange = (field: keyof TripFormData, value: any) => { setFormData((prev) => ({ ...prev, [field]: value })); if (errors[field]) { setErrors((prev) => ({ ...prev, [field]: "" })); } if (field === "featured_image") { // Handle explicit null (when removing image) if (value === null || value === "" || value === undefined) { setFeaturedImagePreview(""); } else { const numericValue = typeof value === "number" ? value : Number(value); if (!numericValue) { setFeaturedImagePreview(""); } else if (featuredImageCache.current[numericValue]) { setFeaturedImagePreview(featuredImageCache.current[numericValue]); } } } }; const handleHighlightAdd = () => { setShowHighlightModal(true); setModalInput({ text: "", question: "", answer: "" }); }; const handleHighlightSave = () => { if (modalInput.text && modalInput.text.trim()) { setFormData((prev) => ({ ...prev, highlights: [...prev.highlights, modalInput.text.trim()], })); setShowHighlightModal(false); setModalInput({ text: "", question: "", answer: "" }); } }; const handleHighlightRemove = (index: number) => { setFormData((prev) => ({ ...prev, highlights: prev.highlights.filter((_, i) => i !== index), })); }; // Included/Excluded handlers - will be integrated into ItinerarySection component // Keeping for future integration when included/excluded are merged into itinerary section // const handleIncludedAdd = () => { ... }; // const handleIncludedRemove = (index: number) => { ... }; // const handleExcludedAdd = () => { ... }; // const handleExcludedRemove = (index: number) => { ... }; const handleFAQAdd = () => { setFormData((prev) => ({ ...prev, faqs: [...prev.faqs, { question: "", answer: "" }], })); }; const handleFAQRemove = (index: number) => { setFormData((prev) => ({ ...prev, faqs: prev.faqs.filter((_, i) => i !== index), })); }; const handleFAQChange = ( index: number, field: "question" | "answer", value: string, ) => { setFormData((prev) => ({ ...prev, faqs: prev.faqs.map((faq, i) => i === index ? { ...faq, [field]: value } : faq, ), })); }; const handleGalleryAdd = () => { // Use WordPress media library with multiple selection if (window.wp && window.wp.media) { const mediaUploader = window.wp.media({ title: __("Select Gallery Images", "yatra"), button: { text: __("Add to Gallery", "yatra") }, multiple: true, // Allow multiple image selection library: { type: "image" }, }); mediaUploader.on("select", () => { const selection = mediaUploader.state().get("selection"); const newImages: Array<{ id: number; url: string; thumbnail_url?: string; alt_text?: string; caption?: string; }> = []; selection.each((attachment: any) => { const image = attachment.toJSON(); if (image.url) { newImages.push({ id: image.id || 0, url: image.url, thumbnail_url: image.sizes?.thumbnail?.url || image.sizes?.medium?.url || image.url, alt_text: image.alt || "", caption: image.caption || "", }); } }); if (newImages.length > 0) { setFormData((prev) => ({ ...prev, gallery_images: [...prev.gallery_images, ...newImages], })); } }); prepareWordPressMediaFrameOpen(); mediaUploader.open(); } else { // Fallback for when wp.media is not available showToast( __( "Media library not available. Please ensure you are logged in as admin.", "yatra", ), "error", ); } }; const handleGalleryRemove = (index: number) => { setFormData((prev) => ({ ...prev, gallery_images: prev.gallery_images.filter((_, i) => i !== index), })); }; const handleGalleryReorder = (fromIndex: number, toIndex: number) => { setFormData((prev) => { const newImages = [...prev.gallery_images]; const [movedImage] = newImages.splice(fromIndex, 1); newImages.splice(toIndex, 0, movedImage); return { ...prev, gallery_images: newImages }; }); }; const handleDownloadableItemAdd = () => { setFormData((prev) => { const nextOrder = (prev.downloadable_items?.length || 0) + 1; return { ...prev, downloadable_items: [ ...(prev.downloadable_items || []), { id: null, title: "", description: "", attachment_id: null, attachment_url: "", attachment_title: "", visibility: "booked_only", enabled: true, sort_order: nextOrder, }, ], }; }); }; const handleDownloadableItemRemove = (index: number) => { setFormData((prev) => ({ ...prev, downloadable_items: (prev.downloadable_items || []).filter( (_, i) => i !== index, ), })); }; const handleDownloadableItemMove = (fromIndex: number, toIndex: number) => { setFormData((prev) => { const items = [...(prev.downloadable_items || [])]; const [moved] = items.splice(fromIndex, 1); items.splice(toIndex, 0, moved); const normalized = items.map((item, idx) => ({ ...item, sort_order: idx + 1, })); return { ...prev, downloadable_items: normalized }; }); }; const handleDownloadableItemChange = ( index: number, field: keyof DownloadableItem, value: any, ) => { setFormData((prev) => ({ ...prev, downloadable_items: (prev.downloadable_items || []).map((item, i) => i === index ? { ...item, [field]: value } : item, ), })); }; const handleDownloadableItemSelectFile = (index: number) => { if (window.wp && window.wp.media) { const mediaUploader = window.wp.media({ title: __("Select File", "yatra"), button: { text: __("Use this file", "yatra") }, multiple: false, }); mediaUploader.on("select", () => { const selection = mediaUploader.state().get("selection"); const attachment = selection.first(); if (!attachment) return; const file = attachment.toJSON(); handleDownloadableItemChange(index, "attachment_id", file.id || null); handleDownloadableItemChange(index, "attachment_url", file.url || ""); handleDownloadableItemChange( index, "attachment_title", file.title || file.filename || "", ); }); prepareWordPressMediaFrameOpen(); mediaUploader.open(); } else { // Fallback for when wp.media is not available showToast( __( "Media library not available. Please ensure you are logged in as admin.", "yatra", ), "error", ); } }; const handlePriceTypeAdd = (categoryId: number) => { // Check if category already exists (compare as numbers to handle string/number mismatch) if ( formData.price_types.some( (pt) => Number(pt.category_id) === Number(categoryId), ) ) { showToast( __("This category already has pricing set", "yatra"), "warning", ); return; } setFormData((prev) => ({ ...prev, price_types: [ ...prev.price_types, { category_id: categoryId, original_price: "", discounted_price: "", is_default: false, }, ], })); }; const handlePriceTypeRemove = (categoryId: number) => { setFormData((prev) => ({ ...prev, price_types: prev.price_types.filter( (pt) => Number(pt.category_id) !== Number(categoryId), ), })); }; const handlePriceTypeChange = ( categoryId: number, field: "original_price" | "discounted_price", value: string, ) => { setFormData((prev) => ({ ...prev, price_types: prev.price_types.map((pt) => Number(pt.category_id) === Number(categoryId) ? { ...pt, [field]: value } : pt, ), })); }; const handlePriceTypeDefaultChange = ( categoryId: number, isDefault: boolean, ) => { setFormData((prev) => ({ ...prev, price_types: prev.price_types.map((pt) => { if (Number(pt.category_id) === Number(categoryId)) { return { ...pt, is_default: isDefault }; } // Only allow one default at a time. return isDefault ? { ...pt, is_default: false } : pt; }), })); }; // Frontend Tabs Handlers const handleTabToggle = (tabId: string) => { setFormData((prev) => ({ ...prev, frontend_tabs: prev.frontend_tabs.map((tab) => tab.id === tabId ? { ...tab, enabled: !tab.enabled } : tab, ), })); }; // Handle dummy data fill const handleFillDummyData = () => { // Cycle through dummy data sets const nextIndex = (dummyDataIndex + 1) % dummyTripsData.length; setDummyDataIndex(nextIndex); const dummyData = dummyTripsData[nextIndex]; // Populate form with dummy data setFormData({ ...dummyData, // Ensure arrays are properly set destinations: dummyData.destinations || [], activity_types: dummyData.activity_types || [], price_types: dummyData.price_types || [], included_items: dummyData.included_items || [], excluded_items: dummyData.excluded_items || [], highlights: dummyData.highlights || [], faqs: dummyData.faqs || [], gallery_images: dummyData.gallery_images || [], itinerary_days: dummyData.itinerary_days || [], tags: dummyData.tags || [], testimonial_review_ids: dummyData.testimonial_review_ids || [], countries: dummyData.countries || [], regions: dummyData.regions || [], landmarks: dummyData.landmarks || [], availability_dates: dummyData.availability_dates || [], frontend_tabs: dummyData.frontend_tabs || [], has_default_time_slots: dummyData.has_default_time_slots || false, default_time_slots: dummyData.default_time_slots || [], departure_time: dummyData.departure_time || "09:00", downloadable_items: dummyData.downloadable_items || [], }); // Show toast notification showToast( __("Dummy data filled", "yatra") + ` (${nextIndex + 1}/${dummyTripsData.length})`, "success", ); }; const handleTabLabelChange = (tabId: string, label: string) => { setFormData((prev) => ({ ...prev, frontend_tabs: prev.frontend_tabs.map((tab) => tab.id === tabId ? { ...tab, label } : tab, ), })); }; const handleTabMove = (tabId: string, direction: "up" | "down") => { setFormData((prev) => { const tabs = [...prev.frontend_tabs]; const index = tabs.findIndex((t) => t.id === tabId); if (index === -1) return prev; const newIndex = direction === "up" ? index - 1 : index + 1; if (newIndex < 0 || newIndex >= tabs.length) return prev; [tabs[index], tabs[newIndex]] = [tabs[newIndex], tabs[index]]; // Update order tabs.forEach((tab, i) => { tab.order = i + 1; }); return { ...prev, frontend_tabs: tabs }; }); }; const handleTabRemove = (tabId: string) => { setFormData((prev) => ({ ...prev, frontend_tabs: prev.frontend_tabs .filter((tab) => tab.id !== tabId) .map((tab, i) => ({ ...tab, order: i + 1 })), })); }; const handleTabAdd = () => { const newTabId = `custom_${Date.now()}`; const maxOrder = Math.max(...formData.frontend_tabs.map((t) => t.order), 0); setFormData((prev) => ({ ...prev, frontend_tabs: [ ...prev.frontend_tabs, { id: newTabId, label: __("New Tab", "yatra"), enabled: true, order: maxOrder + 1, content_type: "custom", custom_content: "", }, ], })); }; const handleTabContentTypeChange = ( tabId: string, contentType: FrontendTab["content_type"], ) => { setFormData((prev) => ({ ...prev, frontend_tabs: prev.frontend_tabs.map((tab) => tab.id === tabId ? { ...tab, content_type: contentType } : tab, ), })); }; const handleTabCustomContentChange = (tabId: string, content: string) => { setFormData((prev) => ({ ...prev, frontend_tabs: prev.frontend_tabs.map((tab) => tab.id === tabId ? { ...tab, custom_content: content } : tab, ), })); }; const handleTabIconChange = (tabId: string, icon: IconPickerValue | null) => { setFormData((prev) => ({ ...prev, frontend_tabs: prev.frontend_tabs.map((tab) => tab.id === tabId ? { ...tab, icon } : tab, ), })); }; // Drag and drop handlers const [draggedTab, setDraggedTab] = useState(null); const [dragOverTab, setDragOverTab] = useState(null); const handleDragStart = (e: React.DragEvent, tabId: string) => { setDraggedTab(tabId); e.dataTransfer.effectAllowed = "move"; }; const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; }; const handleDragEnter = (tabId: string) => { setDragOverTab(tabId); }; const handleDragLeave = () => { setDragOverTab(null); }; const handleDrop = (e: React.DragEvent, targetTabId: string) => { e.preventDefault(); setDragOverTab(null); if (!draggedTab || draggedTab === targetTabId) { setDraggedTab(null); return; } setFormData((prev) => { const tabs = [...prev.frontend_tabs]; const draggedIndex = tabs.findIndex((t) => t.id === draggedTab); const targetIndex = tabs.findIndex((t) => t.id === targetTabId); if (draggedIndex === -1 || targetIndex === -1) { return prev; } // Remove dragged tab and insert at new position const [draggedTabObj] = tabs.splice(draggedIndex, 1); tabs.splice(targetIndex, 0, draggedTabObj); // Update order values tabs.forEach((tab, i) => { tab.order = i + 1; }); return { ...prev, frontend_tabs: tabs }; }); setDraggedTab(null); }; const handleDragEnd = () => { setDraggedTab(null); setDragOverTab(null); }; const buildEssentialFieldErrors = (): Record => { const newErrors: Record = {}; if (!formData.title.trim()) { newErrors.title = __("Title is required", "yatra"); } if (!formData.slug.trim()) { newErrors.slug = __("Slug is required", "yatra"); } else if (!/^[\p{L}\p{N}-]+$/u.test(formData.slug)) { newErrors.slug = __( "Slug can only contain letters, numbers, and hyphens", "yatra", ); } return newErrors; }; const validateForm = (): boolean => { const newErrors = buildEssentialFieldErrors(); setErrors(newErrors); return Object.keys(newErrors).length === 0; }; // Save mutation const saveMutation = useMutation({ mutationFn: async ( data: TripFormData & { status?: "draft" | "publish" }, ) => { const payload = { title: data.title.trim(), slug: data.slug.trim(), description: data.description.trim(), short_description: data.short_description.trim(), highlights: data.highlights, trip_details: data.trip_details.trim(), what_makes_special: data.what_makes_special.trim(), trip_story: data.trip_story.trim(), video_url: data.video_url.trim(), virtual_tour_url: data.virtual_tour_url.trim(), testimonial_review_ids: Array.isArray(data.testimonial_review_ids) ? data.testimonial_review_ids.filter( (id): id is number => id !== null && id !== undefined && id > 0, ) : [], destinations: data.destinations || [], // Array of destination IDs starting_location: data.starting_location.trim(), ending_location: data.ending_location.trim(), countries: data.countries || [], regions: data.regions || [], starting_latitude: data.starting_latitude ? parseFloat(data.starting_latitude) : null, starting_longitude: data.starting_longitude ? parseFloat(data.starting_longitude) : null, ending_latitude: data.ending_latitude ? parseFloat(data.ending_latitude) : null, ending_longitude: data.ending_longitude ? parseFloat(data.ending_longitude) : null, landmarks: data.landmarks || [], trip_type: data.trip_type, duration_days: data.duration_days ? parseInt(data.duration_days) : null, duration_nights: data.duration_nights ? parseInt(data.duration_nights) : null, available_from: data.available_from || null, available_to: data.available_to || null, booking_window_days: data.booking_window_days ? parseInt(data.booking_window_days) : null, seasonal_availability: data.seasonal_availability || "", best_season: data.best_season.trim(), peak_season: data.peak_season.trim(), off_season: data.off_season.trim(), activity_types: data.activity_types || [], // Array of activity IDs difficulty_level: parseInt(data.difficulty_level) || null, trip_category: (() => { const rawCategories = data.trip_category || []; // If no trip categories available yet, don't filter - send raw data if (tripCategories.length === 0) { return rawCategories; } // Filter valid categories - save exactly what user selects const validCategories = rawCategories.filter((_catId) => { // Always return true - save all selected categories regardless of availability return true; }); return validCategories; })(), tags: data.tags || [], accommodation_type: data.accommodation_type || "", meal_plan: data.meal_plan || "", accommodation_details: data.accommodation_details.trim(), transportation_included: data.transportation_included || false, pickup_location: data.pickup_location.trim(), dropoff_location: data.dropoff_location.trim(), transportation_details: data.transportation_details.trim(), pricing_type: data.pricing_type, original_price: data.pricing_type === "regular" ? data.original_price ? parseFloat(data.original_price) : null : null, discounted_price: data.pricing_type === "regular" ? data.discounted_price ? parseFloat(data.discounted_price) : null : null, price_types: data.pricing_type === "traveler_based" ? data.price_types.map((pt) => ({ category_id: pt.category_id, is_default: Boolean((pt as any).is_default), original_price: pt.original_price ? parseFloat(pt.original_price) : null, discounted_price: pt.discounted_price ? parseFloat(pt.discounted_price) : null, })) : [], deposit_amount: data.deposit_amount ? parseFloat(data.deposit_amount) : null, deposit_percentage: data.deposit_percentage ? parseFloat(data.deposit_percentage) : null, payment_terms: data.payment_terms.trim(), max_travelers: data.max_travelers ? parseInt(data.max_travelers) : null, min_travelers: data.min_travelers ? parseInt(data.min_travelers) : null, booking_deadline_hours: data.booking_deadline_hours || null, cancellation_policy: data.cancellation_policy || "", custom_fields: { disable_booking: data.disable_booking }, age_min: data.age_min ? parseInt(data.age_min) : null, age_max: data.age_max ? parseInt(data.age_max) : null, physical_requirements: data.physical_requirements.trim(), visa_requirements: data.visa_requirements.trim(), vaccination_requirements: data.vaccination_requirements.trim(), has_default_time_slots: data.has_default_time_slots || false, default_time_slots: JSON.stringify(data.default_time_slots || []), departure_time: data.departure_time || "09:00", included_items: (data.included_items || []) .map((item) => ({ title: item.title?.trim() || "", description: item.description?.trim() || "", })) .filter((item) => item.title), excluded_items: (data.excluded_items || []) .map((item) => ({ title: item.title?.trim() || "", description: item.description?.trim() || "", })) .filter((item) => item.title), itinerary_days: data.itinerary_days || [], gallery_images: data.gallery_images || [], featured_image: data.featured_image ?? null, faqs: data.faqs || [], frontend_tabs: data.frontend_tabs.map((tab) => ({ id: tab.id, label: tab.label, enabled: tab.enabled, order: tab.order, content_type: tab.content_type, custom_content: tab.custom_content || null, icon: tab.icon || null, })), availability_dates: data.availability_dates.map((avail) => ({ id: avail.id, departure_date: avail.departure_date || null, arrival_date: avail.arrival_date || null, seats_remaining: avail.seats_remaining || null, original_price: avail.original_price ? parseFloat(avail.original_price) : null, discounted_price: avail.discounted_price ? parseFloat(avail.discounted_price) : null, discount_percentage: avail.discount_percentage ? parseFloat(avail.discount_percentage) : null, status: avail.status || "available", from_location: avail.from_location || null, to_location: avail.to_location || null, from_latitude: avail.from_latitude || null, from_longitude: avail.from_longitude || null, to_latitude: avail.to_latitude || null, to_longitude: avail.to_longitude || null, })), status: data.status === "publish" ? "publish" : "draft", scheduled_publish_date: data.scheduled_publish_date || null, scheduled_unpublish_date: data.scheduled_unpublish_date || null, version: data.version || 1, seasonal_auto_enable: data.seasonal_auto_enable || false, seasonal_enable_date: data.seasonal_enable_date || null, seasonal_disable_date: data.seasonal_disable_date || null, meta_title: data.meta_title || "", meta_description: data.meta_description || "", meta_keywords: data.meta_keywords || "", attributes: data.attributes || {}, featured_priority: data.featured_priority, }; if (showDownloadsUI) { (payload as any).downloadable_items = (data.downloadable_items || []) .map((item, idx) => ({ id: item.id ?? null, title: (item.title || "").trim(), description: item.description || "", attachment_id: item.attachment_id ?? null, visibility: item.visibility || "booked_only", enabled: item.enabled !== false, sort_order: item.sort_order != null ? item.sort_order : idx + 1, attachment_url: item.attachment_url || "", attachment_title: item.attachment_title || "", // Prefixed keys (requested) download_title: (item.title || "").trim(), download_description: item.description || "", download_visibility: item.visibility || "booked_only", download_enabled: item.enabled !== false, download_file: item.attachment_id ?? null, })) .filter((item) => item.title); } if (isEditMode && tripId) { const response = await apiClient.put(`/trips/${tripId}`, payload); return response.data || response; } else { const response = await apiClient.post("/trips", payload); return response.data || response; } }, onSuccess: (data, variables) => { queryClient.invalidateQueries({ queryKey: ["trips"] }); queryClient.invalidateQueries({ queryKey: ["trip", tripId] }); // Force refetch the trip data immediately queryClient.refetchQueries({ queryKey: ["trip", tripId] }); setIsSubmitting(false); // Show success message - different for edit vs create mode let successMessage: string; if (isEditMode) { // Edit mode: updating existing trip successMessage = variables.status === "publish" ? __("Trip updated and published successfully", "yatra") : __("Trip updated successfully", "yatra"); } else { // Create mode: creating new trip successMessage = variables.status === "publish" ? __("Trip created and published successfully", "yatra") : __("Trip saved as draft successfully", "yatra"); } showToast(successMessage, "success"); // Only redirect when creating a NEW trip - stay on page when editing if (!isEditMode && data?.id) { // If creating new trip, redirect to edit mode so user can continue editing setTimeout(() => { window.location.href = `${window.yatraAdmin?.siteUrl || ""}/wp-admin/admin.php?page=yatra&subpage=trips&action=edit&id=${data.id}`; }, 1000); } // When editing (isEditMode), always stay on the same page - no redirect }, onError: (error: any) => { const errorMessage = error?.response?.data?.message || error?.message || __("An error occurred while saving", "yatra"); showToast(errorMessage, "error"); setErrors({ submit: errorMessage }); setIsSubmitting(false); }, }); // Light validation for draft saves (only essential fields) const validateDraft = (): boolean => { const newErrors = buildEssentialFieldErrors(); setErrors(newErrors); return Object.keys(newErrors).length === 0; }; const handleSaveDraft = async () => { if (!validateDraft()) { showToast( __("Please add a trip title and URL before saving.", "yatra"), "error", ); return; } setIsSubmitting(true); saveMutation.mutate({ ...formData, status: "draft" }); }; // eslint-disable-next-line react-hooks/exhaustive-deps const handlePublish = async () => { if (!validateForm()) { const firstError = Object.keys(errors)[0]; if (firstError) { showToast( __("Trip title and slug are required before publishing.", "yatra"), "error", ); const errorElement = document.querySelector( `[name="${firstError}"], #${firstError}`, ); if (errorElement) { errorElement.scrollIntoView({ behavior: "smooth", block: "center" }); } } return; } setIsSubmitting(true); saveMutation.mutate({ ...formData, status: "publish" }); }; // Enter should trigger publish/update (skip multiline and interactive controls) useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if (e.key !== "Enter" || e.shiftKey || e.ctrlKey || e.metaKey || e.altKey) return; const target = e.target as HTMLElement | null; if (!target) return; const tag = target.tagName.toLowerCase(); const isTextarea = tag === "textarea"; const isButton = tag === "button"; const isLink = tag === "a"; const isContentEditable = target.getAttribute("contenteditable") === "true"; // Ignore Enter in multiline or interactive elements if (isTextarea || isButton || isLink || isContentEditable) return; const inputType = (target as HTMLInputElement).type; if (["submit", "button", "file"].includes(inputType)) return; e.preventDefault(); if (!isSubmitting) { handlePublish(); } }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [isSubmitting, handlePublish]); // Using static revisions data for UI only const revisions = dummyRevisions; const isLoadingRevisions = false; const handlePreview = async () => { const slug = (formData.slug || "").trim(); if (!slug) { showToast( __( "Trip slug is missing. Please add a slug before previewing.", "yatra", ), "error", ); return; } const siteUrl = (window as any)?.yatraAdmin?.siteUrl || ""; const { plainUrl, prettyUrl } = buildYatraSinglePublicUrls({ entity: "trip", slug, siteUrl, bases: settingsData as Record | null, }); if (isWordPressPlainPermalink()) { window.open(plainUrl, "_blank", "noopener,noreferrer"); return; } let apiPermalink = (tripData as any)?.permalink || (tripData as any)?.url; if (!apiPermalink && tripId) { try { const detail = await apiClient.get(`/trips/${tripId}`); apiPermalink = (detail as any)?.permalink || (detail as any)?.url || apiPermalink; } catch (error) { // fall through to prettyUrl } } if (apiPermalink) { window.open(apiPermalink, "_blank", "noopener,noreferrer"); return; } window.open(prettyUrl, "_blank", "noopener,noreferrer"); }; const handleRevisionClick = (revisionId: number) => { setSelectedRevisionId(revisionId); setShowRevisionConfirm(true); }; const handleRevisionConfirm = () => { // UI only - no actual functionality if (selectedRevisionId) { showToast(__("Revision feature is coming soon", "yatra"), "info"); } setShowRevisionConfirm(false); setShowRevisionsDialog(false); setSelectedRevisionId(null); }; // Skeleton loader for edit mode if (isEditMode && isLoadingTrip) { return (
{/* Header Skeleton */}
{/* Navigation Bar Skeleton */}
{/* Main Content Skeleton */}
{/* Sidebar Skeleton */}
{/* Essentials Section Skeleton */}
{[...Array(8)].map((_, i) => (
))}
{/* Marketing Section Skeleton */}
{[...Array(9)].map((_, i) => (
))}
{/* Lifecycle Section Skeleton */}
{/* Main Form Area Skeleton */}
{/* Section Header Skeleton */}
{/* Form Fields Skeleton */} {/* Title Field */}
{/* Slug Field */}
{/* Description Field */}
{/* Featured Image Skeleton */}
{/* Grid Fields Skeleton */}
{[...Array(4)].map((_, i) => (
))}
); } if (isEditMode && tripError) { const requestInfo = tripErrorContext.requestInfo || (tripError as any)?.config || {}; const method = (requestInfo.method || "GET").toUpperCase?.() || "GET"; const url = requestInfo.url || (tripError as any)?.config?.url || ""; const payload = requestInfo.payload || (tripError as any)?.config?.data || ""; const composedErrorText = `Method: ${method}\nURL: ${url}\nPayload: ${payload || "N/A"}\n\n${tripErrorContext.details || ""}`; return (

{__("Error Loading Trips", "yatra")}

{__( "We couldn’t connect to the trips service. Please refresh or try again shortly.", "yatra", )}

{__("Technical details", "yatra")}

{__("Method:", "yatra")} {" "} {method}
{__("URL:", "yatra")}{" "} {url || __("N/A", "yatra")}
{payload && (
{__("Payload:", "yatra")}
                        {payload}
                      
)}
                  {tripErrorContext.details ||
                    JSON.stringify(
                      {
                        message:
                          tripError instanceof Error
                            ? tripError.message
                            : __("Failed to load trip data", "yatra"),
                        method,
                        url,
                        payload,
                      },
                      null,
                      2,
                    )}
                
); } const renderSectionContent = () => { switch (currentSection) { case "basic": return (

{__("Basic Information", "yatra")}

{__("Start Here", "yatra")}

{__("💡 Getting Started", "yatra")}

{__( "Only the Trip Title and Trip URL are required to create a draft. Everything else is optional for now, but filling it in is highly recommended for better discovery and conversions.", "yatra", )}

{/* Essential Fields - Single View (No Tabs) */}
{/* Tour Title */}
60 ? "text-red-600 dark:text-red-400" : formData.title.length >= 50 ? "text-green-600 dark:text-green-400" : "text-gray-500 dark:text-gray-400" }`} > {formData.title.length}/60
handleTitleChange(e.target.value)} placeholder={__("e.g., Bali Beach Retreat - 7 Days", "yatra")} maxLength={100} className={`${errors.title ? "border-red-500" : formData.title && formData.title.length <= 60 ? "border-green-500" : ""} transition-colors`} required /> {errors.title ? (

{errors.title}

) : ( formData.title && formData.title.length > 60 && (

{__( "Title is longer than recommended for SEO (60 characters)", "yatra", )}

) )}
{/* URL Slug */}
{showSlugPreview && formData.slug && ( )}
handleFieldChange("slug", e.target.value)} placeholder={__("bali-beach-retreat-7-days", "yatra")} className={`font-mono text-sm ${errors.slug ? "border-red-500" : ""}`} required /> {errors.slug && (

{errors.slug}

)} {showSlugPreview && formData.slug && !errors.slug && (
{__("Preview URL:", "yatra")}{" "} {(window as any).yatraAdmin?.siteUrl || "yoursite.com"}/ {settingsData?.trip_base || "trip"}/{formData.slug}
)} {!showSlugPreview && formData.slug && ( )}
{/* Short Description */}
200 ? "text-red-600 dark:text-red-400" : formData.short_description.length >= 100 && formData.short_description.length <= 150 ? "text-green-600 dark:text-green-400" : formData.short_description.length > 0 ? "text-yellow-600 dark:text-yellow-400" : "text-gray-500 dark:text-gray-400" }`} > {formData.short_description.length}/200 handleFieldChange("short_description", v) } buildContext={() => buildTripAiContext(formData)} />
handleFieldChange("short_description", value) } placeholder={__( "Escape to paradise with our 7-day Bali beach retreat...", "yatra", )} minHeight={120} /> {formData.short_description.length > 0 && formData.short_description.length < 100 && (

{__( "Consider adding more details (recommended: 100-150 characters)", "yatra", )}

)}
{/* Tour Description */}
handleFieldChange("description", v)} buildContext={() => buildTripAiContext(formData)} />
handleFieldChange("description", value)} placeholder={__( "Escape to paradise with our 7-day Bali beach retreat... Or describe your single day trip experience...", "yatra", )} minHeight={260} /> {errors.description && (

{errors.description}

)}
{/* Featured Image */}
{formData.featured_image ? (
{featuredImagePreview ? ( {__("Featured ) : (
{isResolvingFeaturedImage ? __("Loading image...", "yatra") : __("Preview unavailable", "yatra")}
)}
) : ( )} {errors.featured_image && (

{errors.featured_image}

)}
{/* Trip Highlights */}
{__("Trip Highlights", "yatra")} {__( "Add key highlights that make your trip special. These will be displayed prominently on your trip page.", "yatra", )}
{/* AI: generates 5-7 highlight strings. The affordance hands back a multi-line string; we split into the highlights array shape TripForm stores. */} { const list = raw .split(/\r?\n/) .map((l) => l.replace(/^[\s\-\*•·●]+/, "").trim()) .filter((l) => l !== ""); handleFieldChange("highlights", list as any); }} buildContext={() => buildTripAiContext(formData)} />
{formData.highlights.length > 0 ? (
{formData.highlights.map((highlight, index) => (
{highlight}
))}
) : (

{__("No highlights added yet", "yatra")}

{__( 'Add key selling points like "Private guide", "All meals included", or "Skip-the-line tickets"', "yatra", )}

)}
{/* Trip Type & Duration - Moved from Duration section */}

{__("Trip Type & Duration", "yatra")}

{/* Trip Type */}
{errors.trip_type && (

{errors.trip_type}

)}
{/* Duration Days & Nights */}
{ const days = e.target.value; handleFieldChange("duration_days", days); // Auto-set nights based on trip type if (formData.trip_type === "single_day") { setFormData((prev) => ({ ...prev, duration_nights: "0", })); } else if (days && parseInt(days) > 1) { // For multi-day, typically nights = days - 1 const nights = Math.max( 0, parseInt(days) - 1, ).toString(); setFormData((prev) => ({ ...prev, duration_nights: nights, })); } }} placeholder={ formData.trip_type === "single_day" ? "1" : __("e.g., 7", "yatra") } className={errors.duration_days ? "border-red-500" : ""} disabled={formData.trip_type === "single_day"} />

{formData.trip_type === "single_day" ? __("Single day trips are always 1 day", "yatra") : __( "Enter the number of days for your trip", "yatra", )}

{errors.duration_days && (

{errors.duration_days}

)}
handleFieldChange("duration_nights", e.target.value) } placeholder={ formData.trip_type === "single_day" ? "0" : __("e.g., 6", "yatra") } className={ errors.duration_nights ? "border-red-500" : "" } disabled={formData.trip_type === "single_day"} />

{formData.trip_type === "single_day" ? __( "Single day trips have 0 nights (no overnight stay)", "yatra", ) : __( "Enter the number of nights (typically days - 1)", "yatra", )}

{errors.duration_nights && (

{errors.duration_nights}

)}
); case "location": return (

{__("Location & Geography", "yatra")}

{__("Recommended", "yatra")}

{__( "Specify where your trip takes place, including destinations, coordinates, and key landmarks.", "yatra", )}

{__( "Optional but highly recommended to help travelers understand the experience.", "yatra", )}

{__( "Optimize how your trip appears in search engines and social shares", "yatra", )} {__( "Optional, but completing these fields improves SEO and click-through rates.", "yatra", )}

{/* Destinations - Multiple Selection */}
{destinationsData && destinationsData.length > 0 ? ( id.toString())} onChange={(values) => handleFieldChange( "destinations", values.map((v) => Number(v)), ) } options={destinationsData.map((destination: any) => ({ value: destination.id.toString(), label: destination.name || sprintf( // translators: %s: numeric destination ID, used as a fallback when the destination has no name. __("Destination #%s", "yatra"), String(destination.id), ), }))} placeholder={__("Select destinations...", "yatra")} searchPlaceholder={__("Search destinations...", "yatra")} error={!!errors.destinations} /> ) : (

{__( "No destinations available. Please create destinations first.", "yatra", )}

{__("To create destinations:", "yatra")}

  1. {__( "Go to Yatra → Destinations in your WordPress admin", "yatra", )}
  2. {__('Click "Add New Destination"', "yatra")}
  3. {__("Enter destination name and details", "yatra")}
  4. {__('Set status to "Published"', "yatra")}
{__("Create Destinations", "yatra")}
)} {errors.destinations && (

{errors.destinations}

)}
{/* Locations Section - Senior UI/UX Design */}
{/* Section Header */}

{__("Trip Locations", "yatra")}

{__( "Set precise starting and ending points with location names and GPS coordinates. Use manual entry or visual map selection for maximum accuracy.", "yatra", )}

{/* Locations Grid */}
{/* STARTING LOCATION */}
{/* Location Header */}

{__("Starting Point", "yatra")}

{__("Where the journey begins", "yatra")}

{formData.starting_location && (
)} {formData.starting_latitude && formData.starting_longitude && (
)}
{/* Integrated Location Input with Map */}
{formData.starting_location ? "✓" : __("Required", "yatra")} {formData.starting_latitude && formData.starting_longitude && (
{__("Coords Set", "yatra")}
)}
{/* LocationPicker - Primary Interface */}
{ handleFieldChange( "starting_location", locationData.name, ); handleFieldChange( "starting_latitude", locationData.latitude, ); handleFieldChange( "starting_longitude", locationData.longitude, ); }} label="" placeholder={__( "Search for starting location...", "yatra", )} helpText="" required={false} defaultMapCenter={ formData.starting_latitude && formData.starting_longitude ? [ parseFloat(formData.starting_latitude), parseFloat(formData.starting_longitude), ] : [20, 0] } defaultZoom={ formData.starting_latitude && formData.starting_longitude ? 13 : 2 } mapHeight="300px" showMapButton={false} searchLimit={8} __={__} className="" mapClassName="rounded-lg" />
{/* GPS Coordinates - Manual Override */}
handleFieldChange( "starting_latitude", e.target.value, ) } className="w-full text-sm" />
handleFieldChange( "starting_longitude", e.target.value, ) } placeholder={__("e.g., 115.0920", "yatra")} className="w-full text-sm" />

{__( "Manual coordinate entry. These will be auto-filled when you select a location from the map above.", "yatra", )}

{/* ENDING LOCATION */}
{/* Location Header */}

{__("Ending Point", "yatra")}

{__("Where the journey concludes", "yatra")}

{formData.ending_location && (
)}
{/* Integrated Location Input with Map */}
{formData.ending_location ? "✓" : __("Required", "yatra")} {formData.ending_latitude && formData.ending_longitude && (
{__("Coords Set", "yatra")}
)}
{/* LocationPicker - Primary Interface */}
{ handleFieldChange( "ending_location", locationData.name, ); handleFieldChange( "ending_latitude", locationData.latitude, ); handleFieldChange( "ending_longitude", locationData.longitude, ); }} label="" placeholder={__( "Search for ending location...", "yatra", )} helpText="" required={false} defaultMapCenter={ formData.ending_latitude && formData.ending_longitude ? [ parseFloat(formData.ending_latitude), parseFloat(formData.ending_longitude), ] : formData.starting_latitude && formData.starting_longitude ? [ parseFloat(formData.starting_latitude), parseFloat(formData.starting_longitude), ] : [20, 0] } defaultZoom={ formData.ending_latitude && formData.ending_longitude ? 13 : formData.starting_latitude && formData.starting_longitude ? 13 : 2 } mapHeight="300px" showMapButton={false} searchLimit={8} __={__} className="" mapClassName="rounded-lg" />
{/* GPS Coordinates - Manual Override */}
handleFieldChange( "ending_latitude", e.target.value, ) } placeholder={__("e.g., -8.5069", "yatra")} className="w-full text-sm" />
handleFieldChange( "ending_longitude", e.target.value, ) } placeholder={__("e.g., 115.2625", "yatra")} className="w-full text-sm" />

{__( "Optional: Manual coordinate entry. Auto-filled when you select a location from the map above.", "yatra", )}

{/* Landmarks */}
{formData.landmarks.length > 0 ? (
{formData.landmarks.map((landmark, index) => (
{landmark}
))}
) : (

{__("No landmarks added yet", "yatra")}

)}
); case "duration": return (

{__("Availability & Booking", "yatra")}

{__("Recommended", "yatra")}

{__("When is it available & who can book it?", "yatra")} {__( "Set your trip's availability period, capacity limits, and booking requirements. This helps automate bookings and prevent overbooking.", "yatra", )}

{/* SECTION 1: Availability Period */}

{__("Availability Period", "yatra")}

{/* Availability Dates */}
handleFieldChange("available_from", val) } placeholder={__("Select date", "yatra")} />
handleFieldChange("available_to", val) } placeholder={__("Select date", "yatra")} />
{/* Booking Window */}
handleFieldChange("booking_window_days", e.target.value) } placeholder={__("e.g., 30", "yatra")} />
{/* Seasonal Availability */}
handleFieldChange( "seasonal_availability", e.target.value, ) } placeholder={__( "e.g., Available year-round except monsoon season", "yatra", )} />
{/* SECTION 2: Capacity & Travelers */}

{__("Capacity & Travelers", "yatra")}

{/* Group Size */}
handleFieldChange("min_travelers", e.target.value) } className={errors.min_travelers ? "border-red-500" : ""} /> {errors.min_travelers && (

{errors.min_travelers}

)}
handleFieldChange("max_travelers", e.target.value) } className={errors.max_travelers ? "border-red-500" : ""} /> {errors.max_travelers && (

{errors.max_travelers}

)}
{/* Fallback Settings */}

{__("Fallback Settings", "yatra")}

{/* Info Banner */}

{__("When are these settings used?", "yatra")}

{__( "These settings apply ONLY when your trip has ZERO availability dates AND ZERO recurring rules. They provide defaults for flexible booking scenarios.", "yatra", )}

{/* Settings Content - Grouped by Trip Type */}
{/* Day Tour Settings */} {formData.trip_type === "single_day" && (
{__("Day Tour Time Settings", "yatra")}
{/* Enable Multiple Time Slots Toggle */}
handleFieldChange( "has_default_time_slots", e.target.checked, ) } className="mt-0.5 w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500" />

{__( "Allow customers to select from multiple departure times (e.g., Morning, Afternoon, Evening tours)", "yatra", )}

{/* Time Slots Configuration (if enabled) */} {formData.has_default_time_slots && (

{__( "Add multiple departure times for customers to choose from.", "yatra", )}

{formData.default_time_slots.length === 0 ? (

{__("No time slots added yet", "yatra")}

) : (
{formData.default_time_slots.map( (slot, index) => (
{index + 1}
{ const updated = [ ...formData.default_time_slots, ]; updated[index] = { ...slot, time: e.target.value, }; handleFieldChange( "default_time_slots", updated, ); }} className="w-full" />
{ const updated = [ ...formData.default_time_slots, ]; updated[index] = { ...slot, label: e.target.value, }; handleFieldChange( "default_time_slots", updated, ); }} placeholder={__( "e.g., Morning Tour, Afternoon Tour", "yatra", )} className="w-full" />
), )}
)}
)} {/* Single Departure Time (if multiple slots disabled) */} {!formData.has_default_time_slots && (
handleFieldChange( "departure_time", e.target.value, ) } className="max-w-xs" />

{__( "Single departure time for all bookings when multiple time slots are not enabled.", "yatra", )}

)}
)} {/* Multi-Day Trip Settings */} {formData.trip_type === "multi_day" && (
{__("Multi-Day Trip Departure Settings", "yatra")}
handleFieldChange( "departure_time", e.target.value, ) } className="max-w-xs" />

{__( "Default departure time for trips without specific availability dates.", "yatra", )}

)} {/* No Trip Type Selected */} {!formData.trip_type && (

{__( "Please select a trip type (Day Tour or Multi-Day) to configure fallback settings.", "yatra", )}

)}
{/* SECTION 3: Booking Policies */}

{__("Booking Policies", "yatra")}

{/* Age Restrictions */}

{__("Age Restrictions", "yatra")}

handleFieldChange("age_min", e.target.value) } placeholder={__("e.g., 18", "yatra")} className={errors.age_min ? "border-red-500" : ""} /> {errors.age_min && (

{errors.age_min}

)}
handleFieldChange("age_max", e.target.value) } placeholder={__("e.g., 65", "yatra")} className={errors.age_max ? "border-red-500" : ""} /> {errors.age_max && (

{errors.age_max}

)}
{/* Requirements */}

{__("Trip Requirements", "yatra")}