// Hotel booking flow backed by the BedBank Partner API through the Duyuf Worker. const hotelApi = async (endpoint, payload) => { const response = await fetch( `${window.DUYUF_API_URL}/v1/hotels/${endpoint}`, { method: payload === undefined ? "GET" : "POST", headers: { "content-type": "application/json" }, body: payload === undefined ? undefined : JSON.stringify(payload), }, ); const body = await response.json().catch(() => ({})); if (!response.ok || body.ok === false || body.success === false) throw new Error(body.message || body.error || "hotel_api_error"); return body; }; const hotelDate = (offset) => { const value = new Date(); value.setDate(value.getDate() + offset); return value.toISOString().slice(0, 10); }; const hotelArabicTerms = { Makkah: "مكة المكرمة", Mecca: "مكة المكرمة", Madinah: "المدينة المنورة", Medina: "المدينة المنورة", Jeddah: "جدة", Riyadh: "الرياض", Abha: "أبها", Dammam: "الدمام", Khobar: "الخبر", "Saudi Arabia": "المملكة العربية السعودية", "Room Only": "إقامة فقط", "ROOM ONLY": "إقامة فقط", "Bed and Breakfast": "إقامة مع الإفطار", Breakfast: "إفطار", "Half Board": "نصف إقامة", "Full Board": "إقامة كاملة", "All Inclusive": "شامل جميع الوجبات", Superior: "سوبيريور", Deluxe: "ديلوكس", Standard: "قياسية", Executive: "تنفيذية", "King Room": "غرفة بسرير كبير", "Twin Room": "غرفة بسريرين", "Double Room": "غرفة مزدوجة", "Single Room": "غرفة مفردة", Suite: "جناح", "Free WiFi": "واي فاي مجاني", WiFi: "واي فاي", Parking: "مواقف سيارات", Restaurant: "مطعم", "Breakfast included": "الإفطار مشمول", "Airport shuttle": "نقل من وإلى المطار", "Swimming pool": "مسبح", "Family rooms": "غرف عائلية", "Room service": "خدمة الغرف", "Fitness center": "مركز لياقة بدنية", }; function hotelLocalized(value, rtl) { if (!value || !rtl) return value || ""; let text = String(value); Object.entries(hotelArabicTerms) .sort((a, b) => b[0].length - a[0].length) .forEach(([from, to]) => { text = text.replace( new RegExp(from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"), to, ); }); return text; } function hotelCurrency(code, rtl) { return rtl ? String(code || "SAR").toUpperCase() === "SAR" ? "ر.س" : code : code || "SAR"; } function hotelBoard(value, rtl) { return hotelLocalized(value, rtl); } function hotelImageUrl(image) { if (!image) return ""; if (typeof image === "string") return image; return image.url || image.imageUrl || image.largeUrl || image.thumbnailUrl || ""; } function hotelImages(value) { if (!value) return []; const media = [ value.imageUrl, value.image, value.thumbnailUrl, ...(Array.isArray(value.images) ? value.images : []), ...(Array.isArray(value.photos) ? value.photos : []), ...(Array.isArray(value.imageUrls) ? value.imageUrls : []), ...(Array.isArray(value.media) ? value.media : []), ]; return [...new Set(media.map(hotelImageUrl).filter(Boolean))]; } function hotelImage(value) { return hotelImages(value)[0] || ""; } function hotelFormatDate(value, rtl) { if (!value) return ""; const date = new Date(value); if (Number.isNaN(date.getTime())) return ""; return new Intl.DateTimeFormat(rtl ? "ar-SA-u-ca-gregory" : "en-GB", { day: "numeric", month: "short", year: "numeric", timeZone: "Asia/Riyadh", }).format(date); } function hotelName(value, rtl) { return hotelLocalized(value?.name || value?.hotelName || value?.propertyName || "", rtl); } function hotelNightCount(query) { const start = new Date(`${query?.checkInDate || ""}T12:00:00`); const end = new Date(`${query?.checkOutDate || ""}T12:00:00`); const nights = Math.round((end - start) / 86400000); return Number.isFinite(nights) && nights > 0 ? nights : 1; } function hotelGuestCount(query) { return (query?.rooms || []).reduce( (total, room) => total + Number(room.adults || 0) + (room.childrenAges || []).length, 0, ); } function hotelBestRoom(hotel) { return (hotel?.rooms || []) .filter((room) => Number.isFinite(Number(room.totalPrice ?? room.price)), ) .sort( (a, b) => Number(a.totalPrice ?? a.price) - Number(b.totalPrice ?? b.price), )[0]; } function hotelAmenityIcon(value) { const text = String(value || "").toLowerCase(); if (/wifi|internet|lan/.test(text)) return "⌁"; if (/breakfast|restaurant|food|dining|coffee|kitchen/.test(text)) return "♨"; if (/pool|swim/.test(text)) return "≈"; if (/parking|car park/.test(text)) return "P"; if (/fitness|gym|massage|spa/.test(text)) return "✦"; if (/airport|shuttle|taxi|transport/.test(text)) return "↗"; if (/family|child/.test(text)) return "♙"; if (/accessible|wheelchair|disabled/.test(text)) return "♿"; if (/air conditioning/.test(text)) return "❄"; if (/laundry|cleaning/.test(text)) return "◇"; return "✓"; } const HOTEL_SPOKEN_LANGUAGES = { arabic: { ar: "العربية", en: "Arabic" }, english: { ar: "الإنجليزية", en: "English" }, french: { ar: "الفرنسية", en: "French" }, german: { ar: "الألمانية", en: "German" }, hindi: { ar: "الهندية", en: "Hindi" }, urdu: { ar: "الأردية", en: "Urdu" }, turkish: { ar: "التركية", en: "Turkish" }, spanish: { ar: "الإسبانية", en: "Spanish" }, italian: { ar: "الإيطالية", en: "Italian" }, russian: { ar: "الروسية", en: "Russian" }, }; function hotelLanguageKey(value) { const text = String(value || "").trim().toLowerCase(); const aliases = { "العربية": "arabic", "اللغة العربية": "arabic", "الإنجليزية": "english", "اللغة الإنجليزية": "english", "الفرنسية": "french", "اللغة الفرنسية": "french", "الألمانية": "german", "اللغة الألمانية": "german", "الهندية": "hindi", "اللغة الهندية": "hindi", "الأردية": "urdu", "اللغة الأردية": "urdu", "التركية": "turkish", "اللغة التركية": "turkish", "الإسبانية": "spanish", "الإيطالية": "italian", "الروسية": "russian", }; if (aliases[text]) return aliases[text]; return Object.keys(HOTEL_SPOKEN_LANGUAGES).find( (key) => text === key || text === `${key} language` || text === `language: ${key}`, ) || null; } function hotelFacilityItems(items) { return (Array.isArray(items) ? items : []).filter((item) => !hotelLanguageKey(item)); } function hotelSpokenLanguages(items, rtl) { return [...new Set((Array.isArray(items) ? items : []).map(hotelLanguageKey).filter(Boolean))] .map((key) => HOTEL_SPOKEN_LANGUAGES[key][rtl ? "ar" : "en"]); } function HotelStepBar({ theme, lang, active }) { const rtl = isRTL(lang); const labels = rtl ? ["البحث", "اختيار الفندق", "اختيار الغرفة", "بيانات الضيوف"] : ["Search", "Hotel", "Room", "Guests"]; return (
{labels.map((label, i) => (
{i + 1}. {label}
))}
); } function HotelSearchForm({ theme, lang, compact = false, initial, onSearch }) { const T = theme, rtl = isRTL(lang); const seed = initial ? { ...initial, adults: (initial.adults ?? (initial.rooms || []).reduce( (n, r) => n + Number(r.adults || 0), 0, )) || 2, childrenAges: initial.childrenAges ?? (initial.rooms || []).flatMap((r) => r.childrenAges || []), roomCount: initial.roomCount ?? Math.max(1, (initial.rooms || []).length), pets: Boolean(initial.pets), } : { destination: "Makkah", countryCode: "SA", checkInDate: hotelDate(14), checkOutDate: hotelDate(16), nationality: "SA", residency: "SA", adults: 2, childrenAges: [], roomCount: 1, pets: false, }; const [cities, setCities] = React.useState([]), [hotels, setHotels] = React.useState([]), [busy, setBusy] = React.useState(false), [error, setError] = React.useState(""); const [form, setForm] = React.useState(seed), [destinationText, setDestinationText] = React.useState( initial ? seed.hotelName || hotelLocalized(seed.destination || "Makkah", rtl) : "", ), [destinationOpen, setDestinationOpen] = React.useState(false), [datesOpen, setDatesOpen] = React.useState(false), [guestsOpen, setGuestsOpen] = React.useState(false); const destinationBox = React.useRef(null), destinationOverlayInput = React.useRef(null), datesBox = React.useRef(null), guestsBox = React.useRef(null); React.useEffect(() => { if (!destinationOpen || typeof window === "undefined") return; const mobile = window.matchMedia("(max-width: 600px)").matches; if (!mobile) return; const previousOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; const focusTimer = window.setTimeout( () => destinationOverlayInput.current?.focus(), 80, ); return () => { window.clearTimeout(focusTimer); document.body.style.overflow = previousOverflow; }; }, [destinationOpen]); React.useEffect(() => { let active = true; hotelApi("cities", { countryCode: form.countryCode }) .then(async (b) => { const list = b.cities || []; if (!active) return; setCities(list); const chosen = list.find( (c) => String(c.cityName).toLowerCase() === String(form.destination || "Makkah").toLowerCase(), ) || list.find((c) => /makkah|mecca/i.test(c.cityName)); if (chosen) { const result = await hotelApi("hotels", { countryCode: form.countryCode, cityId: chosen.cityId, }).catch(() => ({ hotels: [] })); if (active) setHotels((result.hotels || []).slice(0, 300)); } }) .catch(() => setCities([ { cityId: 78591, cityName: "Makkah" }, { cityId: 23028, cityName: "Madinah" }, { cityId: 9777, cityName: "Jeddah" }, ]), ); return () => { active = false; }; }, [form.countryCode]); React.useEffect(() => { const close = (e) => { if (destinationBox.current && !destinationBox.current.contains(e.target)) setDestinationOpen(false); if (guestsBox.current && !guestsBox.current.contains(e.target)) setGuestsOpen(false); if (datesBox.current && !datesBox.current.contains(e.target)) setDatesOpen(false); }; document.addEventListener("pointerdown", close); return () => document.removeEventListener("pointerdown", close); }, []); const set = (key, value) => setForm((v) => ({ ...v, [key]: value })); const selectCity = async (city) => { setDestinationText(hotelLocalized(city.cityName, rtl)); setForm((v) => ({ ...v, destination: city.cityName, cityId: city.cityId, hotelId: null, hotelName: null, })); setDestinationOpen(false); const result = await hotelApi("hotels", { countryCode: form.countryCode, cityId: city.cityId, }).catch(() => ({ hotels: [] })); setHotels((result.hotels || []).slice(0, 300)); }; const selectHotel = (hotel) => { setDestinationText(hotel.name); setForm((v) => ({ ...v, destination: hotel.city || v.destination || "Makkah", hotelId: Number(hotel.id), hotelName: hotel.name, })); setDestinationOpen(false); }; const adjust = (key, delta, min, max) => setForm((v) => ({ ...v, [key]: Math.max(min, Math.min(max, Number(v[key] || 0) + delta)), })); const setChildren = (count) => setForm((v) => { const ages = [...(v.childrenAges || [])]; while (ages.length < count) ages.push(7); return { ...v, childrenAges: ages.slice(0, count) }; }); const setChildAge = (index, age) => setForm((v) => ({ ...v, childrenAges: (v.childrenAges || []).map((x, i) => i === index ? Number(age) : x, ), })); const makeRooms = () => { const count = Math.max(1, Number(form.roomCount)), adultTotal = Math.max(count, Number(form.adults)), rooms = Array.from({ length: count }, () => ({ adults: 0, childrenAges: [], })); for (let i = 0; i < adultTotal; i++) rooms[i % count].adults++; (form.childrenAges || []).forEach((age, i) => rooms[i % count].childrenAges.push(age), ); return rooms; }; const submit = async (e) => { e.preventDefault(); setError(""); if (form.checkOutDate <= form.checkInDate) return setError( rtl ? "تاريخ المغادرة يجب أن يكون بعد الوصول." : "Check-out must be after check-in.", ); setBusy(true); try { const query = { ...form, destination: form.destination || "Makkah", rooms: makeRooms(), }; await onSearch(query, form.hotelId ? [Number(form.hotelId)] : undefined); } catch (err) { setError( rtl ? "تعذر جلب الفنادق الآن. جرّب تاريخًا آخر أو وجهة أخرى." : "Hotels could not be loaded. Try different dates or another destination.", ); } finally { setBusy(false); } }; const fieldStyle = { width: "100%", minHeight: 50, padding: "11px 12px", border: `1px solid ${T.hairlineStrong}`, borderRadius: 11, background: T.bg, color: T.text, font: "600 13px Alexandria", }; const q = destinationText.trim().toLowerCase(), apiQ = q .replace(/مكة المكرمة|مكة/g, "makkah") .replace(/المدينة المنورة|المدينة/g, "madinah") .replace(/جدة/g, "jeddah"), popularCityNames = [ "Makkah", "Madinah", "Jeddah", "Riyadh", "Dammam", "Al Khobar", ], popularCities = popularCityNames .map((name) => cities.find((c) => String(c.cityName || "") .toLowerCase() .includes(name.toLowerCase()), ), ) .filter(Boolean), cityMatches = cities .filter((c) => !apiQ || c.cityName.toLowerCase().includes(apiQ)) .slice(0, apiQ ? 6 : 0), hotelMatches = hotels .filter( (h) => apiQ && `${h.name} ${h.address || ""}`.toLowerCase().includes(apiQ), ) .slice(0, 8), visibleCities = apiQ ? cityMatches : popularCities.length ? popularCities : cities.slice(0, 6); const destinationInput = (overlay = false) => (
setDestinationOpen(true)} onChange={(e) => { setDestinationText(e.target.value); setDestinationOpen(true); setForm((v) => ({ ...v, hotelId: null, hotelName: null })); }} placeholder={rtl ? "ابحث عن مدينة أو فندق أو مكان إقامة" : "Search for a city, hotel or property"} autoComplete="off" aria-label={rtl ? "ابحث عن وجهة أو فندق" : "Search destination or hotel"} style={{ ...fieldStyle, paddingInlineStart: 39 }} />
); const counter = (label, key, min, max) => (
{label}
{form[key]}
); return (
{destinationOpen && (
{rtl ? "اختر وجهتك" : "Choose your destination"}
{destinationInput(true)}
{!apiQ && (
{rtl ? "المدن الأكثر بحثًا" : "Popular destinations"} {rtl ? "وجهات يفضلها المسافرون" : "Traveller favourites"}
)} {!!apiQ && !!hotelMatches.length && (
{rtl ? "الفنادق وأماكن الإقامة" : "Hotels and properties"}
)} {hotelMatches.map((h) => ( ))} {!!apiQ && !!visibleCities.length && (
{rtl ? "المدن والوجهات" : "Cities and destinations"}
)} {visibleCities.map((c) => ( ))} {!hotelMatches.length && !visibleCities.length && (
{rtl ? "لم نجد نتيجة مطابقة." : "No matching destination found."}
)}
)}
{rtl ? "فترة الإقامة" : "Stay dates"} {datesOpen &&
setForm(v=>({...v,checkInDate,checkOutDate}))} onDone={()=>setDatesOpen(false)}/>
}
{guestsOpen && (
{counter(rtl ? "البالغون" : "Adults", "adults", 1, 30)}
{rtl ? "الأطفال" : "Children"} {rtl ? "من 0 إلى 17 سنة" : "Ages 0–17"}
{(form.childrenAges || []).length}
{(form.childrenAges || []).length > 0 && (
{form.childrenAges.map((age, i) => ( ))}
)} {counter(rtl ? "الغرف" : "Rooms", "roomCount", 1, 10)}
)}
{rtl ? "الأسعار والتوفر يتم تأكيدهما مباشرة قبل الحجز." : "Prices and availability are rechecked immediately before booking."}
{error && (

{error}

)}
); } function WebHotelsSearchPage({ theme, lang }) { const T = theme, rtl = isRTL(lang), ctx = useWeb(); const [catalog, setCatalog] = React.useState([]), [catalogBusy, setCatalogBusy] = React.useState(true), [catalogError, setCatalogError] = React.useState(""), [catalogQuery, setCatalogQuery] = React.useState(""), [visible, setVisible] = React.useState(6), [destinations, setDestinations] = React.useState([]); const [catalogImages, setCatalogImages] = React.useState({}); const catalogSwipeStart = React.useRef({}); const defaultQuery = ctx.hotel?.query || { destination: "Makkah", countryCode: "SA", checkInDate: hotelDate(14), checkOutDate: hotelDate(16), nationality: "SA", residency: "SA", rooms: [{ adults: 2, childrenAges: [] }], }; const search = async (query, hotelIds) => { const body = await hotelApi("search", { destination: query.destination, countryCode: query.countryCode, checkInDate: query.checkInDate, checkOutDate: query.checkOutDate, nationality: query.nationality, residency: query.residency, rooms: query.rooms, hotelIds, }); const hotels = body.hotels || []; if (hotelIds?.length && !hotels.length) throw new Error("hotel_not_available"); ctx.setHotel({ ...ctx.hotel, query, results: hotels, currency: body.currency || "SAR", sessionId: body.sessionId, }); ctx.setPage("hotel-results"); }; React.useEffect(() => { let active = true; (async () => { try { const cityBody = await hotelApi("cities", { countryCode: "SA" }); const allCities = cityBody.cities || []; const preferred = [ /makkah|mecca/i, /madinah|medina/i, /jeddah/i, /riyadh/i, /abha/i, /dammam|khobar/i, ] .map((pattern) => allCities.find((city) => pattern.test(city.cityName))) .filter(Boolean); const discovered = await Promise.all( preferred.map(async (city) => { try { const body = await hotelApi("search", { ...defaultQuery, destination: city.cityName, }); const hotels = (body.hotels || []).filter((hotel) => hotelImage(hotel)); return hotels.length ? { ...city, hotels, image: hotelImage(hotels[0]) } : null; } catch (_) { return null; } }), ); const ready = discovered.filter(Boolean); const hotels = ready .flatMap((city) => city.hotels.map((hotel) => ({ ...hotel, landingCity: city.cityName }))) .filter((hotel, index, list) => list.findIndex((candidate) => String(candidate.hotelCode || candidate.id) === String(hotel.hotelCode || hotel.id), ) === index, ); if (!active) return; setDestinations(ready); setCatalog(hotels); if (!hotels.length) setCatalogError( rtl ? "لا توجد حاليًا فنادق بصور وأسعار متاحة لهذه الفترة. غيّر التواريخ للبحث المباشر." : "No hotels with imagery and live rates are available for these dates. Try different dates.", ); } catch (_) { if (active) setCatalogError( rtl ? "تعذر تحميل كتالوج الفنادق." : "Could not load the hotel catalog.", ); } finally { if (active) setCatalogBusy(false); } })(); return () => { active = false; }; }, []); const chooseDestination = async (city) => { setCatalogError(""); try { await search({ ...defaultQuery, destination: city.cityName }); } catch (_) { setCatalogError( rtl ? "تعذر جلب فنادق هذه الوجهة الآن." : "Hotels for this destination could not be loaded.", ); } }; const defaultDestination = String(defaultQuery.destination || "Makkah").toLowerCase(); const filtered = catalog.filter((h) => { const matchesText = `${h.name || h.hotelName || ""} ${h.address || ""}` .toLowerCase() .includes(catalogQuery.toLowerCase()); const matchesDestination = String(h.landingCity || "").toLowerCase() === defaultDestination; return catalogQuery ? matchesText : matchesDestination; }); const check = async (h) => { setCatalogError(""); if ((h.rooms || []).length) { ctx.setHotel({ ...ctx.hotel, query: { ...defaultQuery, destination: h.landingCity || defaultQuery.destination }, results: [h], currency: h.currency || "SAR", }); ctx.setPage("hotel-results"); return; } try { await search(defaultQuery, [Number(h.id || h.hotelCode)]); } catch (_) { setCatalogError( rtl ? "لا يوجد سعر متاح لهذا الفندق في التواريخ الحالية. غيّر التواريخ من نموذج البحث." : "No rate is available for this hotel on the current dates. Change the dates above.", ); } }; return (
{(rtl ? [ "أسعار مباشرة من المزوّد", "سياسة إلغاء واضحة", "تأكيد التوفر قبل الدفع", ] : [ "Live rates", "Clear cancellation terms", "Availability rechecked", ] ).map((x) => (
{x}
))}
{destinations.length > 0 && ( <>

{rtl ? "وجهات رائجة" : "Trending destinations"}

{rtl ? "وجهات يكثر البحث عنها داخل المملكة" : "Popular places travellers are searching for"}

{destinations.slice(0, 4).map((city) => ( ))}

{rtl ? "اكتشف السعودية" : "Explore Saudi Arabia"}

{rtl ? "وجهات سعودية متاحة للحجز" : "Saudi destinations available to book"}

{destinations.map((city) => ( ))}
)}
{rtl ? "إقامات مقترحة" : "RECOMMENDED STAYS"}

{rtl ? `فنادق مقترحة في ${hotelLocalized(defaultQuery.destination || "Makkah", rtl)}` : "Hotels with imagery and live rates"}

{ setCatalogQuery(e.target.value); setVisible(6); }} placeholder={ rtl ? "ابحث باسم الفندق أو العنوان…" : "Search name or address…" } style={{ width: "min(100%,360px)", height: 48, boxSizing: "border-box", padding: "0 14px", border: `1px solid ${T.hairlineStrong}`, borderRadius: 12, background: T.surface, color: T.text, font: "500 13px Alexandria", }} />
{catalogError && (

{catalogError}

)} {catalogBusy ? (
{rtl ? "جارٍ تحميل كتالوج الفنادق…" : "Loading hotel catalog…"}
) : (
{filtered.filter((h) => hotelImage(h)).slice(0, visible).map((h) => { const gallery = hotelImages(h); const imageKey = String(h.id || h.hotelCode); const imageIndex = Math.min(catalogImages[imageKey] || 0, Math.max(0, gallery.length - 1)); const moveImage = (delta) => setCatalogImages((current) => ({ ...current, [imageKey]: (imageIndex + delta + gallery.length) % gallery.length, })); return (
{ catalogSwipeStart.current[imageKey] = event.touches[0].clientX; }} onTouchEnd={(event) => { const delta = event.changedTouches[0].clientX - (catalogSwipeStart.current[imageKey] || 0); if (gallery.length > 1 && Math.abs(delta) > 40) moveImage(delta < 0 ? 1 : -1); }} style={{ height: 190, position: "relative", background: T.surfaceAlt, }} > {hotelName(h, { event.currentTarget.closest("article").style.display = "none"; }} style={{ width: "100%", height: "100%", objectFit: "cover" }} /> {gallery.length > 1 && <> } {hotelLocalized(h.landingCity || h.city || "Saudi Arabia", rtl)}
{rtl ? "سعر مباشر" : "Live rate"}

{hotelName(h, rtl)}

{hotelLocalized(h.address || h.city, rtl)}

{rtl ? "السعر والتوفر مباشر" : "Live price and availability"}
); })}
)} {visible < filtered.filter((h) => hotelImage(h)).length && ( )}
); } function WebHotelResultsPage({ theme, lang }) { const T = theme, rtl = isRTL(lang), ctx = useWeb(), data = ctx.hotel || {}, [sort, setSort] = React.useState("recommended"), [refundable, setRefundable] = React.useState(false), [stars, setStars] = React.useState(0), [mealPlan, setMealPlan] = React.useState("all"), [priceCap, setPriceCap] = React.useState(0), [sheet, setSheet] = React.useState(null), [displayCurrency, setDisplayCurrency] = React.useState(data.currency || "SAR"), [activeImages, setActiveImages] = React.useState({}), [loading, setLoading] = React.useState(false); const swipeStart = React.useRef({}); const nightCount = hotelNightCount(data.query), guestCount = hotelGuestCount(data.query), roomCount = Math.max(1, (data.query?.rooms || []).length); const currencyRate = displayCurrency === "USD" ? 1 / 3.75 : displayCurrency === "AED" ? 1 / 1.021 : 1; const money = (value) => (Number(value || 0) * currencyRate).toLocaleString(undefined, { maximumFractionDigits: 2 }); const currencyLabel = displayCurrency === "SAR" ? (rtl ? "ر.س" : "SAR") : displayCurrency; React.useEffect(() => { document.body.style.overflow = sheet ? "hidden" : ""; return () => { document.body.style.overflow = ""; }; }, [sheet]); const search = async (query, hotelIds) => { setLoading(true); try { const body = await hotelApi("search", { destination: query.destination, countryCode: query.countryCode, checkInDate: query.checkInDate, checkOutDate: query.checkOutDate, nationality: query.nationality, residency: query.residency, rooms: query.rooms, hotelIds, }); ctx.setHotel({ ...data, query, results: body.hotels || [], currency: body.currency || "SAR", }); } finally { setLoading(false); } }; let hotels = (data.results || []).filter((h) => { const best = hotelBestRoom(h), price = Number(best?.totalPrice ?? best?.price ?? 0), boards = (h.rooms || []).map((room) => String(room.boardType || "").toLowerCase()); const mealMatch = mealPlan === "all" || (mealPlan === "breakfast" && boards.some((board) => /breakfast|half board|full board|all inclusive/.test(board))) || (mealPlan === "room" && boards.some((board) => /room only/.test(board))); return ( hotelImages(h).length > 0 && (!stars || Number(h.starRating) >= stars) && (!refundable || (h.rooms || []).some((r) => r.isRefundable)) && (!priceCap || (price > 0 && price <= priceCap)) && mealMatch ); }); hotels = [...hotels].sort((a, b) => sort === "price" ? Number(hotelBestRoom(a)?.totalPrice ?? hotelBestRoom(a)?.price ?? Infinity) - Number(hotelBestRoom(b)?.totalPrice ?? hotelBestRoom(b)?.price ?? Infinity) : sort === "price-high" ? Number(hotelBestRoom(b)?.totalPrice ?? hotelBestRoom(b)?.price ?? 0) - Number(hotelBestRoom(a)?.totalPrice ?? hotelBestRoom(a)?.price ?? 0) : sort === "stars" ? Number(b.starRating) - Number(a.starRating) : sort === "smart" ? (Number(b.reviewScore || b.rating || b.starRating || 0) * 100 - Number(hotelBestRoom(b)?.totalPrice || 0) / 20) - (Number(a.reviewScore || a.rating || a.starRating || 0) * 100 - Number(hotelBestRoom(a)?.totalPrice || 0) / 20) : 0, ); const open = async (h) => { setLoading(true); try { const q = data.query, body = await hotelApi("details", { token: h.token, hotelCode: h.hotelCode, countryCode: q.countryCode, destination: q.destination, checkInDate: q.checkInDate, checkOutDate: q.checkOutDate, nationality: q.nationality, residency: q.residency, rooms: q.rooms, }); ctx.setHotel({ ...data, selectedHotel: { ...h, ...body }, currency: body.currency || data.currency, }); ctx.setPage("hotel-details"); } catch (_) { alert( rtl ? "تعذر تحميل تفاصيل الفندق." : "Could not load hotel details.", ); } finally { setLoading(false); } }; return (

{hotelLocalized(data.query?.destination, rtl)} —{" "} {hotels.length} {rtl ? "فندق متاح" : "properties available"}

{rtl ? `${hotelFormatDate(data.query?.checkInDate, true)} — ${hotelFormatDate(data.query?.checkOutDate, true)} · ${nightCount} ${nightCount === 1 ? "ليلة" : "ليالٍ"} · ${guestCount} ضيف · ${roomCount} غرفة` : `${hotelFormatDate(data.query?.checkInDate, false)} — ${hotelFormatDate(data.query?.checkOutDate, false)} · ${nightCount} nights · ${guestCount} guests · ${roomCount} rooms`}
{loading && (

{rtl ? "جارٍ التحديث…" : "Updating…"}

)}
{hotels.map((h, i) => { const best = hotelBestRoom(h), total = Number(best?.totalPrice ?? best?.price ?? 0), perNight = total / nightCount, gallery = hotelImages(h), imageKey = String(h.hotelCode || i), imageIndex = Math.min(activeImages[imageKey] || 0, Math.max(0, gallery.length - 1)), reviewScore = Number(h.reviewScore || h.rating || 0), reviewCount = Number(h.reviewCount || h.reviewsCount || 0); return (
{ swipeStart.current[imageKey] = e.touches[0].clientX; }} onTouchEnd={(e) => { const delta = e.changedTouches[0].clientX - (swipeStart.current[imageKey] || 0); if (gallery.length > 1 && Math.abs(delta) > 40) { const next = (imageIndex + (delta < 0 ? 1 : -1) + gallery.length) % gallery.length; setActiveImages((v) => ({ ...v, [imageKey]: next })); } }} > {gallery[imageIndex] ? ( {hotelName(h, ) : (
{rtl ? "صورة الفندق" : "Hotel image"}
)} {gallery.length > 1 && <> {gallery.map((_, n) => )} }
{"★".repeat( Math.max(0, Math.min(5, Number(h.starRating) || 0)), )}

{hotelName(h, rtl)}

{rtl ? "فندق" : "Hotel"} · {"★".repeat(Math.max(0, Math.min(5, Number(h.starRating) || 0)))} {reviewScore > 0 ? <>{reviewScore.toFixed(1)}{rtl ? `${reviewCount ? `${reviewCount} تقييم` : "تقييم عملاء"}` : `${reviewCount || "Guest"} reviews`} : {rtl ? "جديد على ضيوف" : "New on Duyuf"}}

{hotelLocalized(h.address || h.city, rtl)}

{hotelFacilityItems(h.amenities).slice(0, 4).map((a) => ( {hotelLocalized(a, rtl)} ))}
{best && (

{best.isRefundable ? rtl ? "✓ خيار قابل للاسترداد" : "✓ Refundable option" : rtl ? "غير قابل للاسترداد" : "Non-refundable"}{" "} · {hotelBoard(best.boardType, rtl)}

)}
{rtl ? "السعر الإجمالي من" : "Total from"} {money(total)}{" "} {currencyLabel} {best && ( {rtl ? `${money(perNight)} ${currencyLabel} لليلة · ${nightCount} ${nightCount === 1 ? "ليلة" : "ليالٍ"}` : `${currencyLabel} ${money(perNight)} per night · ${nightCount} nights`} )} {rtl ? `الإجمالي لـ ${nightCount} ${nightCount === 1 ? "ليلة" : "ليالٍ"} · شامل الضرائب والرسوم المعروضة` : `Total for ${nightCount} nights · displayed taxes and fees included`}
); })} {!hotels.length && !loading && (
{rtl ? "لا توجد نتائج مطابقة. غيّر التاريخ أو الفلاتر." : "No matching stays. Change dates or filters."}
)}
{sheet &&
setSheet(null)}>
e.stopPropagation()}>
{sheet === "search" && <>

{rtl ? "تعديل البحث" : "Edit search"}

{ await search(query, hotelIds); setSheet(null); }} /> } {sheet === "currency" && <>

{rtl ? "اختر العملة" : "Choose currency"}

{["SAR", "USD", "AED"].map((code) => )}
} {sheet === "sort" && <>

{rtl ? "ترتيب النتائج" : "Sort results"}

{[["recommended", rtl ? "الأكثر شعبية" : "Most popular"], ["smart", rtl ? "الأنسب لرحلتي" : "Best match"], ["price", rtl ? "السعر: الأقل أولاً" : "Lowest price"], ["price-high", rtl ? "السعر: الأعلى أولاً" : "Highest price"], ["stars", rtl ? "التصنيف الأعلى" : "Highest rated"]].map(([key,label]) => )}
} {sheet === "filters" && <>

{rtl ? "تصفية البحث" : "Filter results"}

{rtl ? "تصنيف الفندق" : "Hotel stars"}

{[0,3,4,5].map((n) => )}
}
}
); } function cancellationText(room, rtl, currency = "SAR") { if (!room.isRefundable) return rtl ? "غير قابل للاسترداد" : "Non-refundable"; const rule = room.cancellationPolicy?.rules?.[0]; if (!rule) return rtl ? "قابل للاسترداد حسب شروط الفندق" : "Refundable subject to hotel terms"; const when = hotelFormatDate(rule.fromDate, rtl), amount = Number(rule.chargeAmount || 0), amountText = amount > 0 ? `${amount.toLocaleString(rtl ? "ar-SA" : "en-US", { maximumFractionDigits: 2 })} ${hotelCurrency(currency, rtl)}` : ""; if (when && amountText) return rtl ? `إلغاء مجاني حتى ${when}، وبعدها قد تُطبّق رسوم بقيمة ${amountText}.` : `Free cancellation until ${when}; after that, a charge of ${amountText} may apply.`; if (when) return rtl ? `إلغاء مجاني حتى ${when}، وتُطبّق شروط الفندق بعد ذلك.` : `Free cancellation until ${when}; hotel terms apply after that.`; return rtl ? "قابل للاسترداد حسب شروط الفندق" : "Refundable subject to hotel terms"; } function WebHotelDetailsPage({ theme, lang }) { const T = theme, rtl = isRTL(lang), ctx = useWeb(), data = ctx.hotel || {}, h = data.selectedHotel, [busy, setBusy] = React.useState(false), [error, setError] = React.useState(""), [activeImage, setActiveImage] = React.useState(0), [galleryOpen, setGalleryOpen] = React.useState(false), [showAmenities, setShowAmenities] = React.useState(false), detailSwipeStart = React.useRef(0); if (!h) return ; const choose = async (room) => { setBusy(true); setError(""); try { const q = data.query, body = await hotelApi("check-availability", { token: room.token, checkInDate: q.checkInDate, checkOutDate: q.checkOutDate, nationality: q.nationality, residency: q.residency, rooms: q.rooms, }); const option = body.roomOptions?.[0]; if (!option) throw new Error("no_room"); ctx.setHotel({ ...data, selectedRoom: room, availability: option, currency: body.currency || data.currency, }); ctx.setPage("hotel-checkout"); } catch (_) { setError( rtl ? "هذا السعر لم يعد متاحاً. ارجع للنتائج واختر خياراً آخر." : "This rate is no longer available. Please choose another option.", ); } finally { setBusy(false); } }; const images = hotelImages(h), nightCount = hotelNightCount(data.query), guestCount = hotelGuestCount(data.query), roomCount = Math.max(1, (data.query?.rooms || []).length); const facilityItems = hotelFacilityItems(h.amenities); const spokenLanguages = hotelSpokenLanguages(h.amenities, rtl); return (
{detailSwipeStart.current=e.touches[0].clientX}} onTouchEnd={(e)=>{const delta=e.changedTouches[0].clientX-detailSwipeStart.current;if(images.length>1&&Math.abs(delta)>40)setActiveImage(i=>(i+(delta<0?1:-1)+images.length)%images.length)}}> {images.length>1&&<> {images.slice(0,8).map((_,i)=>)} }
{galleryOpen && images.length>0 &&
setGalleryOpen(false)}> e.stopPropagation()} src={images[activeImage]} alt={`${hotelName(h,rtl)} ${activeImage+1}`} />
}
{"★".repeat(Number(h.starRating) || 0)}

{hotelName(h, rtl)}

{hotelLocalized(h.address, rtl)} · {hotelLocalized(h.city, rtl)}

{[ [rtl ? "فترة الإقامة" : "Stay dates", `${hotelFormatDate(data.query?.checkInDate, rtl)} — ${hotelFormatDate(data.query?.checkOutDate, rtl)}`], [rtl ? "المدة" : "Duration", rtl ? `${nightCount} ${nightCount === 1 ? "ليلة" : "ليالٍ"}` : `${nightCount} nights`], [rtl ? "الضيوف والغرف" : "Guests and rooms", rtl ? `${guestCount} ضيف · ${roomCount} غرفة` : `${guestCount} guests · ${roomCount} rooms`], ].map(([label, value]) => (
{label} {value}
))}
{rtl ? ( <>

نبذة عن الفندق

يوفر {h.hotelName} خيارات إقامة في{" "} {hotelLocalized(h.city || data.query?.destination, true)}. اختر الغرفة المناسبة وراجع الوجبات وسياسة الإلغاء والسعر النهائي قبل إتمام الحجز.

) : ( <>

About this hotel

{h.hotelName} offers accommodation in {hotelLocalized(h.city || data.query?.destination, false)}. Choose a suitable room and review the meal plan, cancellation policy and final price before booking.

)}

{rtl ? "أبرز مرافق الفندق" : "Popular facilities"}

{facilityItems.slice(0, showAmenities ? 60 : 12).map((a) => { return
{hotelLocalized(a,rtl)}
; })}
{facilityItems.length > 12 && ( )} {spokenLanguages.length > 0 && <>

{rtl ? "اللغات التي يتحدث بها الموظفون" : "Languages spoken by staff"}

{spokenLanguages.map((language) => {language})}
}

{rtl ? "اختر الغرفة والسعر" : "Choose a room and rate"}

{error && (

{error}

)}
{(h.rooms || []).map((r, i) => { const total = Number(r.totalPrice ?? r.price ?? 0), perNight = total / nightCount; return (

{hotelLocalized(r.roomName, rtl)}

{hotelBoard(r.boardType, rtl)}

{cancellationText(r, rtl, data.currency)}

{total.toLocaleString(undefined, { maximumFractionDigits: 2 })}{" "} {hotelCurrency(data.currency, rtl)} {rtl ? `${perNight.toLocaleString(undefined, { maximumFractionDigits: 2 })} ${hotelCurrency(data.currency, rtl)} لليلة · الإجمالي لـ ${nightCount} ${nightCount === 1 ? "ليلة" : "ليالٍ"}` : `${hotelCurrency(data.currency, rtl)} ${perNight.toLocaleString(undefined, { maximumFractionDigits: 2 })} per night · total for ${nightCount} nights`}
);})}
); } function WebHotelCheckoutPage({ theme, lang }) { const T = theme, rtl = isRTL(lang), ctx = useWeb(), data = ctx.hotel || {}, h = data.selectedHotel, a = data.availability, [busy, setBusy] = React.useState(false), [result, setResult] = React.useState(null), [error, setError] = React.useState(""), [otpStage, setOtpStage] = React.useState("details"), [otpCode, setOtpCode] = React.useState(""), [otpHint, setOtpHint] = React.useState(""), [pendingBooking, setPendingBooking] = React.useState(null); const count = (data.query?.rooms || []).reduce( (n, r) => n + r.adults + (r.childrenAges?.length || 0), 0, ) || 1; const [guests, setGuests] = React.useState( Array.from({ length: count }, (_, i) => ({ title: i ? "Mrs" : "Mr", firstName: "", lastName: "", email: "", phone: "", nationality: data.query?.nationality || "SA", residency: data.query?.residency || "SA", isLeadGuest: i === 0, })), ); if (!h || !a) return ; const change = (i, k, v) => setGuests((gs) => gs.map((g, x) => (x === i ? { ...g, [k]: v } : g))); const normalizePhone = (value) => { let digits = String(value || "").replace(/\D/g, ""); if (digits.startsWith("00966")) digits = digits.slice(2); if (digits.startsWith("966")) return `+${digits}`; if (digits.startsWith("0")) digits = digits.slice(1); return `+966${digits}`; }; const finalizeBooking = async (code) => { if (!pendingBooking || busy) return; setBusy(true); setError(""); try { const credential = await window.duyufHotelConfirmation.confirm(code); const firebaseIdToken = await credential.user.getIdToken(); const body = await hotelApi("book", { ...pendingBooking, firebaseIdToken, confirmation: { email: guests[0].email.trim(), phone: normalizePhone(guests[0].phone), hotelName: h.hotelName, roomName: hotelLocalized(a.roomName, false), checkInDate: data.query.checkInDate, checkOutDate: data.query.checkOutDate, totalPrice: Number(a.price), currency: data.currency || "SAR", leadGuest: `${guests[0].firstName} ${guests[0].lastName}`.trim(), }, }); try { const saved = JSON.parse(localStorage.getItem("duyuf_hotel_bookings") || "[]"); saved.unshift({ reference: body.bookingReference || body.clientReference || pendingBooking.clientReference, hotelName: h.hotelName, checkInDate: data.query.checkInDate, checkOutDate: data.query.checkOutDate, voucherUrl: body.confirmation?.voucherUrl || "", status: "confirmed", createdAt: new Date().toISOString(), }); localStorage.setItem("duyuf_hotel_bookings", JSON.stringify(saved.slice(0, 20))); } catch (_) {} setResult(body); } catch (err) { setError( rtl ? "رمز التحقق غير صحيح أو انتهت صلاحيته. اطلب رمزاً جديداً وحاول مرة أخرى." : "The verification code is invalid or expired. Request a new code and try again.", ); } finally { setBusy(false); } }; React.useEffect(() => { if (otpStage === "otp" && /^\d{6}$/.test(otpCode) && !busy) finalizeBooking(otpCode); }, [otpCode, otpStage]); const submit = async (e) => { e.preventDefault(); setError(""); if (guests.some((g) => !g.firstName.trim() || !g.lastName.trim())) return setError( rtl ? "أدخل الاسم الأول والأخير لكل ضيف." : "Enter first and last name for every guest.", ); const leadGuest = guests.find((guest) => guest.isLeadGuest) || guests[0]; const leadEmail = String(leadGuest?.email || "").trim(); const leadPhone = String(leadGuest?.phone || "").replace(/\D/g, ""); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(leadEmail)) return setError( rtl ? "أدخل بريداً إلكترونياً صحيحاً للضيف الرئيسي." : "Enter a valid email for the lead guest.", ); if (leadPhone.length < 8) return setError( rtl ? "أدخل رقم جوال صحيحاً للضيف الرئيسي." : "Enter a valid phone number for the lead guest.", ); setBusy(true); try { const ref = `DUY-HOTEL-${Date.now().toString(36).toUpperCase()}-${crypto.randomUUID().slice(0, 8).toUpperCase()}`; const payload = { checkInDate: data.query.checkInDate, checkOutDate: data.query.checkOutDate, clientReference: ref, rooms: [ { token: a.token, totalPrice: Number(a.price), adults: data.query.rooms[0].adults, childrenAges: data.query.rooms[0].childrenAges || [], guests, }, ], }; if (!window.firebase?.auth) throw new Error("Firebase Auth is unavailable"); if (window.duyufHotelRecaptcha) { try { window.duyufHotelRecaptcha.clear(); } catch (_) {} } window.duyufHotelRecaptcha = new firebase.auth.RecaptchaVerifier( "duyuf-hotel-recaptcha", { size: "invisible" }, ); window.duyufHotelConfirmation = await firebase .auth() .signInWithPhoneNumber(normalizePhone(leadGuest.phone), window.duyufHotelRecaptcha); setPendingBooking(payload); setOtpStage("otp"); setOtpHint( rtl ? `أرسلنا رمزاً من 6 أرقام إلى ${normalizePhone(leadGuest.phone)}` : `We sent a 6-digit code to ${normalizePhone(leadGuest.phone)}`, ); } catch (err) { setError( rtl ? "تعذر إرسال رمز التحقق. تأكد من رقم الجوال وإعدادات Firebase ثم حاول مرة أخرى." : "Could not send the verification code. Check the phone number and try again.", ); } finally { setBusy(false); } }; if (result) return (

{rtl ? "تم تأكيد الحجز" : "Booking confirmed"}

{h.hotelName}
{hotelFormatDate(data.query.checkInDate, rtl)} — {hotelFormatDate(data.query.checkOutDate, rtl)}

{rtl ? "رقم الحجز" : "Booking reference"}: {result.bookingReference || result.clientReference}

{result.confirmation?.emailSent ? rtl ? "أرسلنا تأكيد الحجز وملف PDF إلى بريدك الإلكتروني." : "The booking confirmation and PDF voucher were emailed to you." : rtl ? "تم الحجز، ويمكنك تنزيل تأكيد الحجز من الزر أدناه." : "The booking is confirmed. Download the voucher below."}

{result.confirmation?.voucherUrl && ( {rtl ? "تنزيل تأكيد الحجز PDF" : "Download PDF voucher"} )}
); const inputStyle = { width: "100%", minHeight: 47, padding: "10px 11px", border: `1px solid ${T.hairlineStrong}`, borderRadius: 10, background: T.bg, color: T.text, font: "500 13px Alexandria", }; if (otpStage === "otp") return (
🔐

{rtl ? "تحقق من رقم الجوال" : "Verify your phone"}

{otpHint}

setOtpCode(e.target.value.replace(/\D/g, "").slice(0, 6))} placeholder="••••••" style={{ ...inputStyle, direction: "ltr", textAlign: "center", letterSpacing: 10, fontSize: 24 }} />

{rtl ? "سيتم التحقق وتأكيد الحجز تلقائياً عند إدخال الرقم السادس." : "Verification and booking will start automatically after the sixth digit."}

{busy &&

{rtl ? "جارٍ تأكيد الحجز التجريبي…" : "Confirming your test booking…"}

} {error &&

{error}

}
); return (

{rtl ? "بيانات الضيوف" : "Guest details"}

{rtl ? "أدخل الأسماء كما تظهر في الهوية أو جواز السفر." : "Use names exactly as shown on the ID or passport."}

{guests.map((g, i) => (

{rtl ? "الضيف" : "Guest"} {i + 1} {i === 0 ? ` · ${rtl ? "الرئيسي" : "Lead"}` : ""}

change(i, "firstName", e.target.value)} style={inputStyle} /> change(i, "lastName", e.target.value)} style={inputStyle} />
{i === 0 && (
change(i, "email", e.target.value)} style={inputStyle} /> change(i, "phone", e.target.value)} style={inputStyle} />
)}
))} {error && (

{error}

)}
); } Object.assign(window, { hotelApi, WebHotelsSearchPage, WebHotelResultsPage, WebHotelDetailsPage, WebHotelCheckoutPage, });