// 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}
adjust(key, -1, min, max)}
style={{
width: 34,
height: 34,
border: `1px solid ${T.primary}`,
borderRadius: 8,
background: T.surface,
color: T.primary,
fontSize: 22,
}}
>
−
{form[key]}
adjust(key, 1, min, max)}
style={{
width: 34,
height: 34,
border: `1px solid ${T.primary}`,
borderRadius: 8,
background: T.surface,
color: T.primary,
fontSize: 20,
}}
>
+
);
return (
);
}
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) => (
chooseDestination(city)} style={{ position: "relative", flex: "0 0 min(360px,82vw)", height: 220, padding: 0, overflow: "hidden", border: 0, borderRadius: 17, cursor: "pointer", scrollSnapAlign: "start", background: T.surface }}>
{ event.currentTarget.closest("button").style.display = "none"; }} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
{hotelLocalized(city.cityName, rtl)}
))}
{rtl ? "اكتشف السعودية" : "Explore Saudi Arabia"}
{rtl ? "وجهات سعودية متاحة للحجز" : "Saudi destinations available to book"}
{destinations.map((city) => (
chooseDestination(city)} style={{ padding: 0, overflow: "hidden", textAlign: "start", border: `1px solid ${T.hairline}`, borderRadius: 15, background: T.surface, cursor: "pointer" }}>
{ event.currentTarget.closest("button").style.display = "none"; }} style={{ display: "block", width: "100%", height: 150, objectFit: "cover" }} />
{hotelLocalized(city.cityName, rtl)}
{rtl ? `${city.hotels.length} خيارات متاحة الآن` : `${city.hotels.length} options available now`}
))}
>
)}
{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,
}}
>
{ event.currentTarget.closest("article").style.display = "none"; }}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
{gallery.length > 1 && <>
moveImage(-1)}>‹
moveImage(1)}>›
{gallery.slice(0, 8).map((_, index) => )}
>}
{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"}
check(h)}
style={{
minHeight: 42,
padding: "0 14px",
border: 0,
borderRadius: 10,
background: T.primary,
color: "#fff",
font: "700 11px Alexandria",
}}
>
{rtl ? "عرض الغرف" : "See rooms"}
);
})}
)}
{visible < filtered.filter((h) => hotelImage(h)).length && (
setVisible((v) => Math.min(v + 24, 300))}
style={{
display: "block",
margin: "24px auto 0",
minHeight: 48,
padding: "0 28px",
border: `1px solid ${T.primary}`,
borderRadius: 11,
background: T.surface,
color: T.primary,
font: "700 13px Alexandria",
}}
>
{rtl ? "عرض المزيد من الفنادق" : "Show more hotels"}
)}
);
}
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 (
ctx.setPage("hotels")} aria-label={rtl ? "رجوع" : "Back"}>{rtl ? "→" : "←"}
setSheet("search") }>
{hotelLocalized(data.query?.destination, rtl)}
{hotelFormatDate(data.query?.checkInDate, rtl)} — {hotelFormatDate(data.query?.checkOutDate, rtl)} · {guestCount} {rtl ? "ضيف" : "guests"}
setSheet("currency")}>{displayCurrency}
setSheet("filters")}>{rtl ? "تصفية البحث" : "Filters"} ⚙
setSheet("sort")}>{rtl ? "الأكثر شعبية" : "Popular"} ↕
{rtl ? "تصفية النتائج" : "Filter results"}
setRefundable(e.target.checked)}
/>
{rtl ? "إلغاء مجاني/قابل للاسترداد" : "Refundable rates"}
{rtl ? "تصنيف الفندق" : "Star rating"}
{[5, 4, 3, 0].map((n) => (
setStars(n)}
style={{
display: "block",
width: "100%",
textAlign: "start",
padding: "9px 10px",
marginTop: 6,
border: `1px solid ${stars === n ? T.primary : T.hairline}`,
borderRadius: 9,
background: stars === n ? T.camelMist : "transparent",
color: T.text,
font: "600 12px Alexandria",
}}
>
{n
? `${"★".repeat(n)} ${rtl ? "فأعلى" : "& up"}`
: rtl
? "الكل"
: "All"}
))}
{rtl ? "الوجبات" : "Meals"}
setMealPlan(e.target.value)}
style={{ width: "100%", marginTop: 7, padding: "10px", border: `1px solid ${T.hairline}`, borderRadius: 9, background: T.surface, color: T.text, font: "600 11px Alexandria" }}
>
{rtl ? "كل الخيارات" : "All options"}
{rtl ? "يشمل الإفطار" : "Breakfast included"}
{rtl ? "إقامة فقط" : "Room only"}
{rtl ? "الحد الأعلى للسعر الإجمالي" : "Maximum total price"}
setPriceCap(Number(e.target.value))}
style={{ width: "100%", marginTop: 7, padding: "10px", border: `1px solid ${T.hairline}`, borderRadius: 9, background: T.surface, color: T.text, font: "600 11px Alexandria" }}
>
{rtl ? "بدون حد" : "No limit"}
{[500, 1000, 2000, 5000].map((price) => (
{rtl ? `حتى ${price.toLocaleString()} ر.س` : `Up to SAR ${price.toLocaleString()}`}
))}
{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`}
setSort(e.target.value)}
style={{
padding: "10px 12px",
border: `1px solid ${T.hairline}`,
borderRadius: 10,
background: T.surface,
color: T.text,
font: "600 12px Alexandria",
}}
>
{rtl ? "موصى به" : "Recommended"}
{rtl ? "الأقل سعراً" : "Lowest price"}
{rtl ? "الأعلى سعراً" : "Highest price"}
{rtl ? "الأعلى تصنيفاً" : "Highest stars"}
{rtl ? "الأنسب لرحلتي" : "Best match"}
{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] ? (
) : (
{rtl ? "صورة الفندق" : "Hotel image"}
)}
{gallery.length > 1 && <>
{ e.stopPropagation(); setActiveImages((v) => ({ ...v, [imageKey]: (imageIndex - 1 + gallery.length) % gallery.length })); }}>‹
{ e.stopPropagation(); setActiveImages((v) => ({ ...v, [imageKey]: (imageIndex + 1) % gallery.length })); }}>›
{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`}
open(h)}
style={{
marginTop: 12,
minHeight: 44,
border: 0,
borderRadius: 10,
background: T.primary,
color: "#fff",
font: "700 12px Alexandria",
}}
>
{rtl ? "عرض الغرف" : "See rooms"}
);
})}
{!hotels.length && !loading && (
{rtl
? "لا توجد نتائج مطابقة. غيّر التاريخ أو الفلاتر."
: "No matching stays. Change dates or filters."}
)}
{sheet && setSheet(null)}>
e.stopPropagation()}>
setSheet(null)}>×
{sheet === "search" && <>
{rtl ? "تعديل البحث" : "Edit search"}
{ await search(query, hotelIds); setSheet(null); }} />
>}
{sheet === "currency" && <>
{rtl ? "اختر العملة" : "Choose currency"}
{["SAR", "USD", "AED"].map((code) => { setDisplayCurrency(code); setSheet(null); }}>{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]) => { setSort(key); setSheet(null); }}>{label} )}
>}
{sheet === "filters" && <>
{rtl ? "تصفية البحث" : "Filter results"}
setRefundable(e.target.checked)} /> {rtl ? "إلغاء مجاني أو قابل للاسترداد" : "Refundable rates"}
{rtl ? "تصنيف الفندق" : "Hotel stars"}
{[0,3,4,5].map((n) => setStars(n)}>{n ? `${n}★+` : (rtl ? "الكل" : "All")} )}
setSheet(null)}>{rtl ? `عرض ${hotels.length} فندق` : `Show ${hotels.length} stays`}
>}
}
);
}
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 (
ctx.setPage("hotel-results")}
style={{
border: 0,
background: "transparent",
color: T.primary,
font: "700 12px Alexandria",
}}
>
{rtl ? "→" : "←"} {rtl ? "العودة للنتائج" : "Back to results"}
{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&&setGalleryOpen(true)}>
{images[activeImage]
?
: {rtl?"لا توجد صور متاحة للفندق":"No hotel photos available"} }
{images.length>0&&{activeImage+1} / {images.length} }
{images.length>1&&<>
setActiveImage(i=>(i-1+images.length)%images.length)}>‹
setActiveImage(i=>(i+1)%images.length)}>›
{images.slice(0,8).map((_,i)=>)}
>}
{galleryOpen && images.length>0 &&
setGalleryOpen(false)}>
setGalleryOpen(false)}>×
{e.stopPropagation();setActiveImage(i=>(i-1+images.length)%images.length)}}>‹
e.stopPropagation()} src={images[activeImage]} alt={`${hotelName(h,rtl)} ${activeImage+1}`} />
{e.stopPropagation();setActiveImage(i=>(i+1)%images.length)}}>›
}
{"★".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
{hotelAmenityIcon(a)} {hotelLocalized(a,rtl)}
;
})}
{facilityItems.length > 12 && (
setShowAmenities((value) => !value)} style={{ marginTop: 14, minHeight: 42, padding: "0 18px", borderRadius: 10, border: `1px solid ${T.primary}`, background: T.surface, color: T.primary, font: "700 11px Alexandria" }}>
{showAmenities ? (rtl ? "عرض المرافق الأساسية" : "Show key facilities") : (rtl ? "عرض جميع المرافق" : "Show all facilities")}
)}
{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`}
choose(r)}
style={{
minHeight: 43,
padding: "0 20px",
marginTop: 12,
border: 0,
borderRadius: 10,
background: T.primary,
color: "#fff",
font: "700 12px Alexandria",
}}
>
{rtl ? "اختيار" : "Select"}
);})}
);
}
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"}
)}
ctx.setPage("home")}
style={{
marginTop: 18,
padding: "12px 22px",
border: 0,
borderRadius: 10,
background: T.primary,
color: "#fff",
font: "700 13px Alexandria",
}}
>
{rtl ? "العودة للرئيسية" : "Back home"}
);
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}
}
{ setOtpStage("details"); setOtpCode(""); setError(""); }} style={{ marginTop: 12, padding: "10px 18px", border: `1px solid ${T.hairline}`, borderRadius: 10, background: T.surface, color: T.text, font: "700 12px Alexandria" }}>{rtl ? "تعديل البيانات" : "Edit details"}
);
return (
);
}
Object.assign(window, {
hotelApi,
WebHotelsSearchPage,
WebHotelResultsPage,
WebHotelDetailsPage,
WebHotelCheckoutPage,
});