// Standalone web pages — browse, detail, become-provider, contact, sign-in
// All consume the WebContext (set by WebApp).
const WebContext = React.createContext(null);
const useWeb = () => React.useContext(WebContext);
// ─── Page header (used on subpages) ────────────────────────────────────
function WebPageHeader({ theme, lang, eyebrow, title, sub, tone = "camel" }) {
const T = theme;
const rtl = isRTL(lang);
const tones = {
camel: `linear-gradient(135deg, ${T.camel} 0%, ${T.camelDeep} 100%)`,
ink: `linear-gradient(135deg, ${T.ink} 0%, ${T.inkSoft} 100%)`,
brown: `linear-gradient(135deg, ${T.pastelBrown} 0%, ${T.camelDeep} 100%)`,
};
return (
{eyebrow && (
{eyebrow}
)}
{title}
{sub && (
{sub}
)}
);
}
// ─── Browse / search results page ──────────────────────────────────────
function WebBrowsePage({ theme, lang }) {
const T = theme;
const l = L[lang];
const rtl = isRTL(lang);
const { setPage, setPkg, umrahSearch } = useWeb();
const [filtersOpen, setFiltersOpen] = React.useState(false);
const basePackages = [
{
id: "fairmont",
hotel: rtl ? "فيرمونت ساعة مكة" : "Fairmont Makkah Clock Tower",
dist: rtl ? "50م من الحرم" : "50m from Haram",
stars: 5,
nights: 7,
price: 4899,
was: 5499,
seats: 4,
board: rtl ? "إفطار+عشاء" : "B&D",
tone: "dark",
provider: rtl ? "الإيلاف الفاخرة" : "Elaf Premium",
dates: rtl ? "16–25 رمضان" : "16–25 Ramadan",
},
{
id: "swiss",
hotel: rtl ? "سويس أوتيل برج الزمزم" : "Swissôtel Al Maqam",
dist: rtl ? "120م من الحرم" : "120m from Haram",
stars: 5,
nights: 7,
price: 3899,
seats: 9,
board: rtl ? "إفطار" : "Breakfast",
tone: "camel",
provider: rtl ? "الرحمة للحج" : "Al-Rahma",
dates: rtl ? "1–8 شوال" : "1–8 Shawwal",
},
{
id: "hilton",
hotel: rtl ? "هيلتون مكة المكرمة" : "Hilton Suites Makkah",
dist: rtl ? "200م من الحرم" : "200m from Haram",
stars: 4,
nights: 7,
price: 2799,
seats: 12,
board: rtl ? "بدون" : "Room only",
tone: "brown",
provider: rtl ? "إيلاف ضيوف" : "Elaf Guests",
dates: rtl ? "20 رمضان" : "20 Ramadan",
},
{
id: "safwah",
hotel: rtl ? "الصفوة رويال أوركيد" : "Al Safwah Royal Orchid",
dist: rtl ? "320م" : "320m",
stars: 4,
nights: 6,
price: 1199,
seats: 9,
board: rtl ? "إفطار" : "Breakfast",
tone: "cream",
provider: rtl ? "حجاج الجزيرة" : "Hujjaj Aljazeera",
dates: rtl ? "8 ذو القعدة" : "8 Dhul Qadah",
},
{
id: "fairmont2",
hotel: rtl ? "فيرمونت — شوال" : "Fairmont — Shawwal",
dist: "50m",
stars: 5,
nights: 10,
price: 5499,
seats: 2,
board: rtl ? "إفطار+عشاء" : "B&D",
tone: "dark",
provider: rtl ? "الإيلاف الفاخرة" : "Elaf Premium",
dates: rtl ? "12 شوال" : "12 Shawwal",
},
{
id: "movenpick",
hotel: rtl ? "موڤنبيك حجاج" : "Mövenpick Hajar",
dist: "180m",
stars: 5,
nights: 10,
price: 5290,
seats: 7,
board: rtl ? "إفطار" : "Breakfast",
tone: "brown",
provider: rtl ? "بوابة الكعبة" : "Kaaba Gate",
dates: rtl ? "20 شوال" : "20 Shawwal",
},
];
const [approved, setApproved] = React.useState([]);
React.useEffect(() => {
fetch(`${window.DUYUF_API_URL}/v1/public/packages`)
.then((r) => r.json())
.then((body) => {
if (!body.ok) return;
setApproved(
(body.packages || []).map((p, i) => ({
id: p.id,
hotel: p.hotel_name,
dist: rtl ? "من مزوّد موثوق" : "Verified provider",
stars: 5,
nights: p.duration_days,
price: Number(p.price_per_person),
seats: p.seats,
board: rtl ? "حسب الباقة" : "As listed",
tone: ["camel", "brown", "cream", "dark"][i % 4],
provider: p.business_name,
providerLogoUrl: p.provider_logo_key
? `${window.DUYUF_API_URL}/v1/public/package-images/${encodeURIComponent(p.provider_logo_key)}`
: "",
dates: `${p.available_from} — ${p.available_to}`,
title: p.title,
imageUrl: p.cover_key
? `${window.DUYUF_API_URL}/v1/public/package-images/${encodeURIComponent(p.cover_key)}`
: JSON.parse(p.hotel_images_json || "[]")[0] || "",
inclusions: JSON.parse(p.inclusions || "[]"),
exclusions: JSON.parse(p.exclusions || "[]"),
originCity: p.origin_city,
destinationCity: p.destination_city,
notes: p.notes || "",
occupancyPrices: JSON.parse(p.occupancy_prices_json || "{}"),
roomOptions: JSON.parse(p.room_options_json || "[]"),
cancellationPolicy: p.cancellation_policy || "",
supportsTabby: Boolean(p.supports_tabby),
supportsTamara: Boolean(p.supports_tamara),
paymentConfirmationMode: p.payment_confirmation_mode || "pay_on_arrival",
availableSeats: Math.max(0,Number(p.seats||0)-Number(p.booked_travelers||0)),
departureAt:p.departure_at, returnAt:p.return_at, travelMode:p.travel_mode,
airline:p.airline, flightNumber:p.flight_number, flightArrivalCity:p.flight_arrival_city,
returnAirline:p.return_airline, returnFlightNumber:p.return_flight_number,
returnOriginCity:p.return_origin_city, returnDestinationCity:p.return_destination_city,
roomType:p.room_type, mealPlan:p.meal_plan,
isDynamic: true,
})),
);
})
.catch(() => {});
}, [lang]);
const normalizeCity = (value) =>
String(value || "")
.replace("مكة المكرمة", "مكة")
.replace("المدينة المنورة", "المدينة")
.trim();
const matchesSearch = (p) => {
if (!p.isDynamic)
return (
normalizeCity(umrahSearch?.origin) === "جدة" &&
normalizeCity(umrahSearch?.destination) === "مكة"
);
const originMatches =
!umrahSearch?.origin ||
normalizeCity(p.originCity) === normalizeCity(umrahSearch.origin);
const destinationMatches =
!umrahSearch?.destination ||
normalizeCity(p.destinationCity) ===
normalizeCity(umrahSearch.destination);
const seatsMatch =
Number(p.seats || 0) >= Number(umrahSearch?.travelers || 1);
return originMatches && destinationMatches && seatsMatch;
};
const packages = approved.filter(matchesSearch);
const routeLabel = `${rtl ? umrahSearch?.origin : { جدة: "Jeddah", الرياض: "Riyadh", "المدينة المنورة": "Madinah", الدمام: "Dammam", الطائف: "Taif" }[umrahSearch?.origin] || umrahSearch?.origin} → ${rtl ? umrahSearch?.destination : { "مكة المكرمة": "Makkah", "المدينة المنورة": "Madinah" }[umrahSearch?.destination] || umrahSearch?.destination}`;
return (
<>
{/* Compact search re-bar */}
{routeLabel}
{umrahSearch?.date || (rtl ? "أي موعد" : "Any date")}
{umrahSearch?.travelers || 1} {rtl ? "مسافرين" : "travelers"}
setPage("home")}
style={{
marginInlineStart: "auto",
padding: "10px 20px",
borderRadius: 12,
background: T.primary,
color: "#fff",
border: 0,
fontFamily: "Alexandria",
fontWeight: 600,
fontSize: 13,
cursor: "pointer",
}}
>
{rtl ? "تعديل البحث" : "Edit search"}
{filtersOpen && (
setFiltersOpen(false)}
/>
)}
{/* Filter sidebar */}
{rtl ? "الفلاتر" : "Filters"}
setFiltersOpen(false)}
>
×
{/* Price range */}
{rtl ? "السعر" : "Price (SAR)"}
1,200
5,500
{/* Stars */}
{rtl ? "تقييم الفندق" : "Hotel rating"}
{[5, 4, 3].map((n, i) => (
{i === 0 && (
)}
{[24, 18, 8][i]}
))}
{/* Distance from Haram */}
{rtl ? "المسافة من الحرم" : "Distance from Haram"}
{[
rtl ? "أقل من 100م" : "Within 100m",
rtl ? "100–300م" : "100–300m",
rtl ? "300–500م" : "300–500m",
rtl ? "أكثر من 500م" : "Over 500m",
].map((d, i) => (
{i === 1 && (
)}
{d}
))}
{/* Board */}
{rtl ? "الإقامة" : "Board"}
{[
rtl ? "بدون" : "Room only",
rtl ? "إفطار" : "Breakfast",
rtl ? "نصف إقامة" : "Half board",
rtl ? "كاملة" : "Full board",
].map((b, i) => (
{b}
))}
{/* Results */}
setFiltersOpen(true)}
style={{
background: T.surface,
color: T.text,
border: `1px solid ${T.hairline}`,
}}
>
{rtl ? "الفلاتر" : "Filters"}
{packages.length} {rtl ? "باقة متاحة" : "packages available"}
{routeLabel} · {umrahSearch?.travelers || 1}{" "}
{rtl ? "مسافرين" : "travelers"}
{rtl ? "ترتيب:" : "Sort:"}
{rtl ? "الأنسب" : "Recommended"}
{rtl ? "الأرخص" : "Cheapest"}
{rtl ? "الأقرب للحرم" : "Distance from Haram"}
{rtl ? "التقييم" : "Rating"}
{packages.map((p) => (
{
setPkg(p);
setPage("detail");
}}
style={{
display: "grid",
gridTemplateColumns: "280px 1fr 200px",
background: T.surface,
borderRadius: 18,
border: `1px solid ${T.hairline}`,
boxShadow: T.cardShadow,
overflow: "hidden",
cursor: "pointer",
}}
>
{p.imageUrl ? (
) : (
)}
{p.was && (
-{Math.round((1 - p.price / p.was) * 100)}%
)}
{p.hotel}
4.7 · 312 {rtl ? "تقييم" : "reviews"}
{p.dist}
{p.dates}
{p.nights} {rtl ? "ليالٍ" : "nights"}
{p.board}
{rtl ? "إلغاء مجاني" : "Free cancellation"}
{p.seats} {rtl ? "مقاعد متبقية" : "seats left"}
{p.was && (
{p.was.toLocaleString()} SAR
)}
{p.price.toLocaleString()}
SAR / {rtl ? "للفرد" : "person"}
{rtl ? "عرض التفاصيل" : "View details"}
))}
>
);
}
// ─── Package detail page ───────────────────────────────────────────────
function WebDetailPage({ theme, lang }) {
const T = theme;
const l = L[lang];
const rtl = isRTL(lang);
const { pkg, setPage } = useWeb();
const p = pkg || {
hotel: rtl ? "فيرمونت ساعة مكة" : "Fairmont Makkah Clock Royal",
provider: rtl ? "الإيلاف الفاخرة" : "Elaf Premium",
price: 4899,
was: 5499,
dates: rtl ? "16–25 رمضان" : "16–25 Ramadan",
nights: 7,
dist: rtl ? "50م من الحرم" : "50m from Haram",
stars: 5,
tone: "dark",
board: rtl ? "إفطار+عشاء" : "B&D",
seats: 4,
};
return (
{/* Breadcrumb */}
{/* Gallery */}
{p.imageUrl ? (
) : (
)}
{rtl ? "+8 صور" : "+8 photos"}
{/* Main column */}
{p.hotel}
4.8 · 312 {rtl ? "تقييم" : "reviews"}
·
{p.dist}
{/* Stats strip */}
{[
{ i: "calendar", t: p.dates, l: rtl ? "الموعد" : "Window" },
{
i: "bed",
t: `${p.nights} ${rtl ? "ليالٍ" : "nights"}`,
l: rtl ? "الإقامة" : "Stay",
},
{
i: p.isDynamic ? "user" : "plane",
t: p.isDynamic
? `${p.seats} ${rtl ? "شخص" : "people"}`
: rtl
? "مع طيران"
: "Flight included",
l: p.isDynamic
? rtl
? "السعة"
: "Capacity"
: rtl
? "السفر"
: "Travel",
},
{
i: "coffee",
t: p.isDynamic ? `${p.inclusions?.length || 0}` : p.board,
l: p.isDynamic
? rtl
? "مشتملات محددة"
: "Selected items"
: rtl
? "الوجبات"
: "Meals",
},
].map((m, i) => (
{m.t}
{m.l}
))}
{/* Description */}
{rtl ? "عن الباقة" : "About this package"}
{p.isDynamic
? p.notes ||
(rtl
? "باقة مقدمة من مزود معتمد. راجع المشتملات والتواريخ قبل الحجز."
: "A package from a verified provider. Review inclusions and dates before booking.")
: rtl
? "باقة عمرة كاملة تشمل الطيران والإقامة في فيرمونت ساعة مكة على بُعد 50 متراً فقط من الحرم. الإقامة شاملة الإفطار والعشاء يومياً، ونقل من المطار، ومرشد ميداني طوال فترة الإقامة. الباقة مجدولة مسبقاً من المزوّد ولا تتطلب اختيار مقعد."
: "A complete Umrah package including flight and stay at Fairmont Makkah Clock Royal — just 50m from the Haram. Includes daily breakfast & dinner, airport transfers, and an on-site guide throughout your stay. Pre-arranged by the provider; no seat selection required."}
{/* What's included */}
{rtl ? "ما يشمله" : "What's included"}
{(p.isDynamic
? (p.inclusions || []).map((t) => ({ i: "check", t }))
: [
{
i: "plane",
t: rtl
? "تذكرة طيران ذهاب وعودة"
: "Round-trip flight ticket",
},
{
i: "bed",
t: rtl ? "6 ليالٍ في فيرمونت" : "6 nights at Fairmont",
},
{
i: "coffee",
t: rtl
? "إفطار وعشاء يومياً"
: "Daily breakfast & dinner",
},
{
i: "car",
t: rtl ? "نقل من المطار" : "Airport transfers",
},
{ i: "compass", t: rtl ? "مرشد ميداني" : "On-site guide" },
{ i: "shield", t: rtl ? "تأمين سفر" : "Travel insurance" },
]
).map((it, i) => (
))}
{p.isDynamic && !p.inclusions?.length && (
{rtl
? "لم يحدد المزود مشتملات الباقة بعد."
: "The provider has not selected inclusions yet."}
)}
{p.exclusions?.length > 0 && (
<>
{rtl ? "ما لا تشمله الباقة" : "Not included"}
{p.exclusions.map((text, i) => (
× {text}
))}
>
)}
{/* Provider card */}
{rtl ? "عن المزوّد" : "About the provider"}
{" "}
{rtl
? "مزوّد موثّق · منذ 2019"
: "Verified provider · since 2019"}
142 {rtl ? "باقة نشطة · 4.7★" : "active packages · 4.7★"}
setPage("browse")}
style={{
padding: "9px 16px",
borderRadius: 10,
background: "transparent",
border: `1px solid ${T.hairline}`,
color: T.text,
fontFamily: "Alexandria",
fontWeight: 600,
fontSize: 12.5,
cursor: "pointer",
}}
>
{rtl ? "كل باقاته" : "All packages"}
{/* Sticky booking panel */}
);
}
function WebCustomerBookingsPage({theme,lang}){
const T=theme,rtl=isRTL(lang),{setPage}=useWeb();
const [user,setUser]=React.useState(()=>firebase.auth().currentUser),[phone,setPhone]=React.useState(""),[otp,setOtp]=React.useState(""),[step,setStep]=React.useState(firebase.auth().currentUser?"bookings":"phone"),[bookings,setBookings]=React.useState([]),[tab,setTab]=React.useState("upcoming"),[busy,setBusy]=React.useState(false),[message,setMessage]=React.useState("");
const verifying=React.useRef(false);
const load=async(current=user||firebase.auth().currentUser)=>{if(!current)return;setBusy(true);try{const token=await current.getIdToken(true),r=await fetch(`${window.DUYUF_API_URL}/v1/customer/bookings`,{headers:{authorization:`Bearer ${token}`}}),b=await r.json();if(!r.ok)throw new Error(b.error);setBookings(b.bookings||[]);localStorage.setItem("duyuf_dashboard_mode","customer");localStorage.setItem("duyuf_customer",JSON.stringify({phone:b.phone}));setStep("bookings");}catch(_){setMessage(rtl?"تعذر تحميل الحجوزات.":"Could not load bookings.");}finally{setBusy(false);}};
React.useEffect(()=>{if(user)load(user);},[]);
const send=async()=>{const digits=phone.replace(/\D/g,"").replace(/^966/,"").replace(/^0/,"");if(!/^5\d{8}$/.test(digits)){setMessage(rtl?"أدخل رقم جوال صحيحًا.":"Enter a valid mobile number.");return;}setBusy(true);setMessage("");try{if(window.duyufCustomerRecaptcha)window.duyufCustomerRecaptcha.clear();window.duyufCustomerRecaptcha=new firebase.auth.RecaptchaVerifier("duyuf-customer-recaptcha",{size:"invisible"});window.duyufCustomerConfirmation=await firebase.auth().signInWithPhoneNumber(`+966${digits}`,window.duyufCustomerRecaptcha);setPhone(digits);setStep("otp");}catch(_){setMessage(rtl?"تعذر إرسال رمز التحقق.":"Could not send verification code.");}finally{setBusy(false);}};
const verify=async(value=otp)=>{if(verifying.current||!/^\d{6}$/.test(value))return;verifying.current=true;setBusy(true);try{const credential=await window.duyufCustomerConfirmation.confirm(value);setUser(credential.user);await load(credential.user);}catch(_){setMessage(rtl?"الرمز غير صحيح أو انتهت صلاحيته.":"Invalid or expired code.");}finally{verifying.current=false;setBusy(false);}};
const groups={upcoming:bookings.filter(b=>["confirmed","checked_in"].includes(b.status)),past:bookings.filter(b=>["checked_out","completed"].includes(b.status)),cancelled:bookings.filter(b=>["cancelled","no_show"].includes(b.status))},shown=groups[tab]||[];
const statusLabel=s=>s==="confirmed"?(rtl?"مؤكد":"Confirmed"):s==="checked_in"?(rtl?"أثناء الرحلة":"In progress"):s==="checked_out"?(rtl?"مكتمل":"Completed"):s==="cancelled"?(rtl?"ملغي من المزود":"Cancelled by provider"):s==="no_show"?(rtl?"لم يحضر":"No-show"):s;
const download=async b=>{const r=await fetch(`${window.DUYUF_API_URL}/v1/public/bookings/${encodeURIComponent(b.booking_reference)}/pdf?token=${encodeURIComponent(b.public_token)}`),blob=await r.blob(),u=URL.createObjectURL(blob),a=document.createElement("a");a.href=u;a.download=`Duyuf-${b.booking_reference}.pdf`;a.click();setTimeout(()=>URL.revokeObjectURL(u),30000);};
if(step!=="bookings")return {rtl?"الدخول إلى حجوزاتي":"Sign in to my bookings"} {rtl?"استخدم رقم الجوال المرتبط بالحجز.":"Use the mobile number linked to your booking."}
{step==="phone"?:{rtl?<>أرسلنا رمزًا إلى +966 {phone} >:<>Code sent to +966 {phone}>}
{const n=e.target.value.replace(/\D/g,"").slice(0,6);setOtp(n);if(n.length===6)setTimeout(()=>verify(n),0);}} style={{minHeight:54,textAlign:"center",fontSize:24,letterSpacing:8,direction:"ltr"}}/>
verify()} style={{minHeight:54,border:0,borderRadius:13,background:T.primary,color:"#fff",fontWeight:700}}>{rtl?"تحقق ومتابعة":"Verify and continue"} }{message&&{message}
} ;
return {rtl?"إدارة حجوزاتي":"Manage my bookings"} {rtl?"تابع رحلاتك، السداد ومستندات التأكيد من مكان واحد.":"Track trips, payments and confirmation documents."}
setPage("home")} style={{padding:"11px 16px",border:`1px solid ${T.hairlineStrong}`,borderRadius:11,background:T.surface}}>{rtl?"استكشاف الباقات":"Explore packages"} {[["upcoming",rtl?"القادمة":"Upcoming"],["past",rtl?"السابقة":"Past"],["cancelled",rtl?"الملغاة":"Cancelled"]].map(([id,label])=>setTab(id)} style={{padding:"10px 18px",borderRadius:99,border:`1px solid ${tab===id?T.primary:T.hairlineStrong}`,background:tab===id?T.primary:T.surface,color:tab===id?"#fff":T.text,whiteSpace:"nowrap"}}>{label} ({groups[id].length}) )}
{busy?{rtl?"جارٍ تحميل الحجوزات…":"Loading bookings…"}
:shown.length===0?{rtl?"لا توجد حجوزات في هذا القسم":"No bookings here"} {rtl?"أي حجز مرتبط بهذا الرقم سيظهر هنا تلقائيًا.":"Bookings linked to this phone appear automatically."}
:{shown.map(b=>{const p=b.package||{};return
{statusLabel(b.status)} {p.title||b.title} {p.hotel||b.hotel_name} · {p.provider||b.business_name}
{rtl?"رقم الحجز":"Booking reference"} {b.booking_reference}
{rtl?"التاريخ":"Dates"} {String(p.departureAt||b.available_from||"—").slice(0,10)} - {String(p.returnAt||b.available_to||"—").slice(0,10)}
{rtl?"المسافرون":"Travelers"} {b.travelers}
{rtl?"الغرف":"Rooms"} {(b.rooms||[]).reduce((s,r)=>s+Number(r.count||0),0)}
{rtl?"الإجمالي / المتبقي":"Total / due"} {Number(b.total_amount).toLocaleString()} {b.currency}
{rtl?"السداد":"Payment"} {rtl?"عند الوصول":"Pay on arrival"}
download(b)} style={{padding:"11px 16px",border:0,borderRadius:11,background:T.primary,color:"#fff",fontWeight:700}}>{rtl?"تحميل تأكيد الحجز":"Download confirmation"} {localStorage.setItem("duyuf_last_booking",JSON.stringify({bookingReference:b.booking_reference,publicToken:b.public_token,customerName:b.customer_name,customerPhone:b.customer_phone,travelers:b.travelers,adults:b.adults,children:b.children,infants:b.infants,rooms:b.rooms,totalAmount:b.total_amount,balanceAmount:b.total_amount,currency:b.currency,confirmedAt:b.confirmed_at,package:p}));location.assign("/?page=booking-confirmation");}} style={{padding:"11px 16px",border:`1px solid ${T.hairlineStrong}`,borderRadius:11,background:T.surface,color:T.text}}>{rtl?"عرض التفاصيل":"View details"}
})}
} ;
}
function WebCheckoutPage({ theme, lang }) {
const T = theme, rtl = isRTL(lang), { pkg, setPage, setBookingConfirmation } = useWeb();
const [step, setStep] = React.useState("details");
const [form, setForm] = React.useState({ fullName: "", email: "", phone: "", adults: 1, children: 0, infants: 0, rooms:{ single:0,double:1,triple:0,quad:0 }, roomPreference:"" });
const [firebaseIdToken, setFirebaseIdToken] = React.useState("");
const [providerSessionToken,setProviderSessionToken]=React.useState("");
const [adminSessionToken,setAdminSessionToken]=React.useState("");
const [otp, setOtp] = React.useState("");
const otpVerifyingRef = React.useRef(false);
const [busy, setBusy] = React.useState(false), [message, setMessage] = React.useState("");
const totalTravelers = form.adults + form.children + form.infants;
const roomMeta = { single:{capacity:1,ar:"مفردة",en:"Single"}, double:{capacity:2,ar:"مزدوجة",en:"Double"}, triple:{capacity:3,ar:"ثلاثية",en:"Triple"}, quad:{capacity:4,ar:"رباعية",en:"Quad"} };
const availableRooms = Object.entries(roomMeta).map(([key,meta])=>({key,...meta,price:Number(pkg?.occupancyPrices?.[key]||0)})).filter(x=>x.price>0);
const selectedRooms = availableRooms.map(x=>({...x,count:Number(form.rooms?.[x.key]||0)})).filter(x=>x.count>0);
const roomCapacity = selectedRooms.reduce((sum,x)=>sum+x.capacity*x.count,0);
const roomCount = selectedRooms.reduce((sum,x)=>sum+x.count,0);
const estimatedTotal = selectedRooms.reduce((sum,x)=>sum+x.price*x.count,0);
const fieldStyle = { width: "100%", minHeight: 52, boxSizing: "border-box", border: `1px solid ${T.hairlineStrong}`, borderRadius: 13, padding: "0 14px", background: T.surfaceAlt, color: T.text, font: "500 14px Alexandria", outline: "none" };
const requestCode = async () => {
if (form.fullName.trim().length < 3 || (form.email.trim() && !/^\S+@\S+\.\S+$/.test(form.email)) || !/^5\d{8}$/.test(form.phone)) {
setMessage(rtl ? "أدخل الاسم الكامل ورقم جوال صحيحًا. البريد الإلكتروني اختياري." : "Enter your full name and a valid mobile number. Email is optional."); return;
}
if (!roomCount || roomCapacity < totalTravelers) { setMessage(rtl?`اختر غرفًا تستوعب ${totalTravelers} أشخاص على الأقل.`:`Select rooms for at least ${totalTravelers} guests.`); return; }
if (Number(pkg?.availableSeats ?? pkg?.seats ?? 0) < totalTravelers) { setMessage(rtl?`لا توجد سعة كافية. المتاح حاليًا ${Number(pkg?.availableSeats||0)} فقط. اختر عددًا أقل أو باقة أخرى.`:`Not enough capacity. Only ${Number(pkg?.availableSeats||0)} places remain.`); return; }
setBusy(true); setMessage("");
try {
const currentUser=firebase.auth().currentUser,currentPhone=String(currentUser?.phoneNumber||"").replace(/\D/g,"").replace(/^966/,"");
if(currentUser&¤tPhone===form.phone){
const idToken=await currentUser.getIdToken(true);
localStorage.setItem("duyuf_customer",JSON.stringify({phone:currentUser.phoneNumber}));
setFirebaseIdToken(idToken);setStep("review");return;
}
if(currentUser&¤tPhone!==form.phone){
throw new Error("different_signed_in_phone");
}
const storedSession=localStorage.getItem("duyuf_provider_session")||"";
if(!currentUser&&storedSession){
const sessionResponse=await fetch(`${window.DUYUF_API_URL}/v1/provider/packages`,{headers:{authorization:`Bearer ${storedSession}`}});
const sessionBody=await sessionResponse.json().catch(()=>({}));
const sessionPhone=String(sessionBody?.provider?.phone||"").replace(/\D/g,"").replace(/^966/,"");
if(sessionResponse.ok&&sessionPhone===form.phone){setProviderSessionToken(storedSession);setStep("review");return;}
}
const storedAdminSession=localStorage.getItem("duyuf_admin_session")||"";
const storedAdmin=(()=>{try{return JSON.parse(localStorage.getItem("duyuf_admin")||"null");}catch(_){return null;}})();
const storedAdminPhone=String(storedAdmin?.phone||"").replace(/\D/g,"").replace(/^966/,"");
if(!currentUser&&storedAdminSession&&storedAdminPhone===form.phone){setAdminSessionToken(storedAdminSession);setStep("review");return;}
if (window.duyufCheckoutRecaptcha) window.duyufCheckoutRecaptcha.clear();
const recaptchaHost=document.getElementById("duyuf-checkout-recaptcha");
if(recaptchaHost)recaptchaHost.replaceChildren();
firebase.auth().languageCode=rtl?"ar":"en";
window.duyufCheckoutRecaptcha = new firebase.auth.RecaptchaVerifier("duyuf-checkout-recaptcha",{size:"invisible"});
await window.duyufCheckoutRecaptcha.render();
window.duyufCheckoutConfirmation = await firebase.auth().signInWithPhoneNumber(`+966${form.phone}`,window.duyufCheckoutRecaptcha);
setStep("otp");
} catch (error) { console.error("Checkout OTP failed",error); const errorCode=String(error?.code||error?.message||"unknown").replace(/^auth\//,""); setMessage(error.message==="different_signed_in_phone"?(rtl?"أنت مسجل الدخول برقم جوال مختلف. استخدم الرقم المسجل حاليًا أو سجل الخروج أولًا.":"You are signed in with a different mobile number. Use it or sign out first."):(rtl ? `تعذر إرسال رمز التحقق (${errorCode}).` : `Could not send the verification code (${errorCode}).`)); }
finally { setBusy(false); }
};
const verifyCode = async (codeValue=otp) => {
if (otpVerifyingRef.current) return;
if (!/^\d{6}$/.test(codeValue)) { setMessage(rtl ? "أدخل رمز التحقق المكوّن من 6 أرقام." : "Enter the 6-digit code."); return; }
otpVerifyingRef.current=true;
setBusy(true); setMessage("");
try {
if (!window.duyufCheckoutConfirmation) throw new Error("missing_confirmation");
const credential = await window.duyufCheckoutConfirmation.confirm(codeValue);
const idToken = await credential.user.getIdToken(true);
localStorage.setItem("duyuf_customer",JSON.stringify({phone:credential.user.phoneNumber}));
setFirebaseIdToken(idToken); setStep("review");
} catch (_) { setMessage(rtl ? "رمز التحقق غير صحيح أو انتهت صلاحيته." : "The verification code is invalid or expired."); }
finally { setBusy(false); otpVerifyingRef.current=false; }
};
const confirmBooking = async () => {
if (pkg?.paymentConfirmationMode === "prepay_required") {
setMessage(rtl ? "يتطلب هذا العرض الدفع مسبقًا لتأكيد الحجز. سيتم إتاحة المتابعة عند تفعيل بوابة الدفع." : "This offer requires prepayment. Checkout will be available when online payment is enabled.");
return;
}
setBusy(true); setMessage("");
try {
const r = await fetch(`${window.DUYUF_API_URL}/v1/public/bookings`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ packageId: pkg?.id, ...form, phone: `+966${form.phone}`, firebaseIdToken,providerSessionToken,adminSessionToken }) });
const b = await r.json(); if (!r.ok || !b.booking) throw new Error(b.error);
localStorage.setItem("duyuf_last_booking", JSON.stringify(b.booking));
setBookingConfirmation(b.booking); setPage("booking-confirmation");
} catch (error) { setMessage(error.message === "insufficient_capacity" ? (rtl ? "لا توجد سعة كافية لهذا العدد." : "Not enough capacity.") : (rtl ? "تعذر إتمام الحجز. حاول مرة أخرى." : "Could not complete the booking.")); }
finally { setBusy(false); }
};
const counter = (key, label, min = 0) => {label} setForm(v => ({ ...v, [key]: Math.max(min, v[key] - 1) }))} style={{ width: 34, height: 34, borderRadius: 9, border: `1px solid ${T.hairlineStrong}`, background: T.surface, color: T.text }}>− {form[key]} setForm(v => ({ ...v, [key]: Math.min(20, v[key] + 1) }))} style={{ width: 34, height: 34, borderRadius: 9, border: 0, background: T.primary, color: "#fff" }}>+
;
const steps = [["details", rtl ? "البيانات" : "Details"], ["otp", rtl ? "التحقق" : "Verify"], ["review", rtl ? "المراجعة" : "Review"]];
return (
setPage("detail")} style={{ border: 0, background: "transparent", color: T.textMuted, cursor: "pointer" }}>→ {rtl ? "العودة إلى الباقة" : "Back to package"}
{steps.map(([id,label],i) => { const current = steps.findIndex(x => x[0] === step); return
; })}
{step === "details" ? (rtl ? "بيانات الحجز" : "Booking details") : step === "otp" ? (rtl ? "تحقق من رقم الجوال" : "Verify mobile") : (rtl ? "راجع طلبك" : "Review booking")}
{pkg?.title} · {pkg?.hotel}
{step === "details" && setForm(v=>({...v,fullName:e.target.value}))} style={fieldStyle}/> setForm(v=>({...v,email:e.target.value}))} style={fieldStyle}/>+966 setForm(v=>({...v,phone:e.target.value.replace(/\D/g,"").slice(0,9)}))} style={{...fieldStyle,border:0,borderRadius:0,paddingInlineStart:0}}/>
{counter("adults",rtl?"البالغون":"Adults",1)}{counter("children",rtl?"الأطفال":"Children")}{counter("infants",rtl?"الرضّع":"Infants")}
{rtl?"الغرف المطلوبة":"Required rooms"} {availableRooms.map(room=>
{rtl?`غرفة ${room.ar}`:room.en} · {room.price.toLocaleString()} SAR setForm(v=>({...v,rooms:{...v.rooms,[room.key]:Math.max(0,(v.rooms?.[room.key]||0)-1)}}))} style={{width:34,height:34,borderRadius:9,border:`1px solid ${T.hairlineStrong}`,background:T.surface}}>− {form.rooms?.[room.key]||0} setForm(v=>({...v,rooms:{...v.rooms,[room.key]:Math.min(20,(v.rooms?.[room.key]||0)+1)}}))} style={{width:34,height:34,borderRadius:9,border:0,background:T.primary,color:"#fff"}}>+
)}{pkg?.roomOptions?.length>0&&
setForm(v=>({...v,roomPreference:e.target.value}))} style={fieldStyle}>{rtl?"تفضيل الغرفة أو الإطلالة (اختياري)":"Room or view preference (optional)"} {pkg.roomOptions.map((option,i)=>{option.name}{option.boardType?` · ${option.boardType}`:""} )} }
{rtl?`${roomCount} غرفة · سعة ${roomCapacity} أشخاص`:`${roomCount} rooms · capacity ${roomCapacity}`} {estimatedTotal.toLocaleString()} SAR
{busy?(rtl?"جارٍ الإرسال…":"Sending…"):(rtl?"إرسال رمز التحقق":"Send verification code")} }
{step === "otp" && {rtl?<>أرسلنا رمزًا إلى +966 {form.phone} >:<>We sent a code to +966 {form.phone} >}
{const next=e.target.value.replace(/\D/g,"").slice(0,6);setOtp(next);if(next.length===6)setTimeout(()=>verifyCode(next),0);}} placeholder="• • • • • •" style={{...fieldStyle,textAlign:"center",fontSize:24,letterSpacing:8,direction:"ltr"}}/>
verifyCode()} style={{ minHeight:54,border:0,borderRadius:13,background:T.primary,color:"#fff",font:"700 15px Alexandria" }}>{busy?(rtl?"جارٍ التحقق…":"Verifying…"):(rtl?"تحقق ومتابعة":"Verify and continue")} {rtl?"إعادة إرسال الرمز":"Resend code"} }
{step === "review" && {form.fullName} +966 {form.phone}{form.email?` · ${form.email}`:""} {rtl?"المسافرون":"Travelers"}: {form.adults} {rtl?"بالغ":"adult"} · {form.children} {rtl?"طفل":"child"} · {form.infants} {rtl?"رضيع":"infant"} {rtl?"الغرف":"Rooms"}: {selectedRooms.map(x=>`${x.count}× ${rtl?x.ar:x.en}`).join(" · ")} {form.roomPreference&&{rtl?"تفضيل الغرفة":"Room preference"}: {form.roomPreference} }{rtl?"الفندق":"Hotel"}: {pkg?.hotel||"—"} {rtl?"الإقامة":"Stay"}: {pkg?.nights||pkg?.durationDays||"—"} {rtl?"ليالٍ":"nights"} · {pkg?.mealPlan||"—"} {rtl?"مسار الرحلة":"Route"}: {pkg?.originCity||"—"} ← {pkg?.destinationCity||pkg?.flightArrivalCity||"—"} {rtl?"موعد الانطلاق":"Departure"}: {pkg?.departureAt?new Date(pkg.departureAt).toLocaleString(rtl?"ar-SA":"en-US"):"—"} {rtl?"موعد العودة":"Return"}: {pkg?.returnAt?new Date(pkg.returnAt).toLocaleString(rtl?"ar-SA":"en-US"):"—"} {pkg?.flightNumber&&{rtl?"رحلة الذهاب":"Outbound flight"}: {pkg.airline||"—"} · {pkg.flightNumber} }{pkg?.returnFlightNumber&&{rtl?"رحلة العودة":"Return flight"}: {pkg.returnAirline||"—"} · {pkg.returnFlightNumber} }{rtl?"إجمالي الحجز":"Booking total"}: {estimatedTotal.toLocaleString()} SAR {rtl?"طريقة السداد":"Payment method"}: {pkg?.paymentConfirmationMode==="prepay_required"?(rtl?"الدفع مسبقًا لتأكيد الحجز":"Prepayment required"):(rtl?"الدفع عند الوصول":"Pay on arrival")} {pkg?.cancellationPolicy&&{rtl?"سياسة الإلغاء":"Cancellation policy"}: {pkg.cancellationPolicy} }
{rtl?"بعد إتمام الحجز سيصدر رقم التأكيد ويمكنك تنزيل نموذج PDF الرسمي.":"After completion, a confirmation number and official PDF will be available."}
{busy?(rtl?"جارٍ إتمام الحجز…":"Completing…"):(rtl?"إتمام الحجز":"Complete booking")} {pkg?.paymentConfirmationMode==="prepay_required"&&
{rtl?"يتطلب هذا العرض الدفع قبل إصدار تأكيد الحجز.":"Payment is required before this booking can be confirmed."}
}
}
{message && {message}
}
);
}
function WebBookingConfirmationPage({ theme, lang }) {
const T = theme, rtl = isRTL(lang), { bookingConfirmation, setPage } = useWeb();
const booking = bookingConfirmation || (() => { try { return JSON.parse(localStorage.getItem("duyuf_last_booking") || "null"); } catch (_) { return null; } })();
const [busy, setBusy] = React.useState(false);
const voucherRef = React.useRef(null);
if (!booking) return setPage("home")}>{rtl?"العودة للرئيسية":"Back home"}
;
const p = booking.package || {};
const formatDate = value => value ? new Date(value).toLocaleString(rtl ? "ar-SA" : "en-US", { dateStyle:"medium", timeStyle:value.includes?.("T")?"short":undefined }) : "—";
const downloadPdf = async () => {
setBusy(true);
try {
const url=`${window.DUYUF_API_URL}/v1/public/bookings/${encodeURIComponent(booking.bookingReference)}/pdf?token=${encodeURIComponent(booking.publicToken)}`;
const response=await fetch(url);
if(!response.ok) throw new Error("pdf_failed");
const blob=await response.blob(),objectUrl=URL.createObjectURL(blob),link=document.createElement("a");
link.href=objectUrl;link.download=`Duyuf-${booking.bookingReference}.pdf`;document.body.appendChild(link);link.click();link.remove();setTimeout(()=>URL.revokeObjectURL(objectUrl),30000);
} finally { setBusy(false); }
};
const row = (label,value) => {label} {value || "—"}
;
const milestones = p.travelMode === "flight" ? [
{ title:rtl?"رحلة الذهاب":"Outbound flight", detail:`${rtl?"من":"From"} ${p.originCity||"—"} ${rtl?"إلى":"to"} ${p.flightArrivalCity||"—"} · ${p.airline||"—"} · ${p.flightNumber||"—"}`, date:formatDate(p.departureAt) },
{ title:rtl?"الوصول والانتقال إلى الفندق":"Arrival and hotel transfer", detail:p.airportTransferType||"—", date:p.flightArrivalCity||"—" },
{ title:rtl?"الإقامة":"Hotel stay", detail:`${p.hotel||"—"} · ${p.roomType||"—"}`, date:`${p.durationDays||"—"} ${rtl?"أيام":"days"}` },
{ title:rtl?"المغادرة من الفندق":"Hotel checkout", detail:p.hotel||"—", date:formatDate(p.returnAt) },
{ title:rtl?"رحلة العودة":"Return flight", detail:`${rtl?"من":"From"} ${p.returnOriginCity||"—"} ${rtl?"إلى":"to"} ${p.returnDestinationCity||"—"} · ${p.returnAirline||"—"} · ${p.returnFlightNumber||"—"}`, date:formatDate(p.returnDepartureAt) },
] : [
{ title:rtl?"الانطلاق بالنقل البري":"Ground transport departure", detail:`${rtl?"من":"From"} ${p.originCity||"—"} ${rtl?"إلى":"to"} ${p.destinationCity||"—"} · ${p.busType||"—"}`, date:formatDate(p.departureAt) },
{ title:rtl?"الوصول والإقامة":"Arrival and stay", detail:`${p.hotel||"—"} · ${p.roomType||"—"}`, date:`${p.durationDays||"—"} ${rtl?"أيام":"days"}` },
{ title:rtl?"العودة":"Return", detail:p.destinationCity||"—", date:formatDate(p.returnAt) },
];
return
setPage("home")} style={{padding:"11px 16px",border:`1px solid ${T.hairlineStrong}`,borderRadius:11,background:T.surface,color:T.text}}>{rtl?"العودة للرئيسية":"Back home"} {busy?(rtl?"جارٍ تجهيز الملف…":"Preparing…"):(rtl?"تحميل PDF":"Download PDF")}
✓ {rtl?"تم تأكيد حجزك":"Booking confirmed"} {p.title} {p.provider} · {p.hotel}
{booking.bookingReference}
{rtl?"ملخص الحجز":"Booking summary"} {rtl?"الاسم الكامل":"Full name"} {booking.customerName}
{rtl?"رقم الجوال":"Mobile"} {booking.customerPhone}
{rtl?"المسافرون":"Travelers"} {booking.adults} {rtl?"بالغ":"adult"} · {booking.children} {rtl?"طفل":"child"}
{rtl?"الغرف":"Rooms"} {booking.rooms?.map(x=>`${x.count}× ${rtl?({single:"مفردة",double:"مزدوجة",triple:"ثلاثية",quad:"رباعية"}[x.type]||x.type):x.type}`).join(" · ")}
{rtl?"طريقة السداد":"Payment"} {rtl?"الدفع عند الوصول":"Pay on arrival"}
{rtl?"تاريخ التأكيد":"Confirmed"} {new Date(booking.confirmedAt).toLocaleDateString(rtl?"ar-SA":"en-US")}
{rtl?"برنامج الرحلة":"Trip itinerary"} {milestones.map((m,i)=>
{m.title} {m.detail} {m.date}
)}
{rtl?"إجمالي الحجز":"Total"} {Number(booking.totalAmount).toLocaleString()} SAR
{rtl?"المبلغ المتبقي":"Amount due"} {Number(booking.balanceAmount??booking.totalAmount).toLocaleString()} SAR
{rtl?"تأكيد حجز باقة عمرة":"Umrah Package Booking Confirmation"} {p.title} · {p.provider}
{row(rtl?"الاسم الكامل":"Full name",booking.customerName)}{row(rtl?"رقم الجوال":"Mobile",booking.customerPhone)}{booking.customerEmail?row(rtl?"البريد الإلكتروني":"Email",booking.customerEmail):null}{row(rtl?"المسافرون":"Travelers",`${booking.travelers} (${booking.adults}/${booking.children}/${booking.infants})`)}{booking.rooms?.length?row(rtl?"الغرف":"Rooms",booking.rooms.map(x=>`${x.count}× ${rtl?({single:"مفردة",double:"مزدوجة",triple:"ثلاثية",quad:"رباعية"}[x.type]||x.type):x.type}`).join(" · ")):null}{row(rtl?"طريقة السداد":"Payment method",rtl?"الدفع عند الوصول":"Pay on arrival")}{row(rtl?"تاريخ التأكيد":"Confirmed",new Date(booking.confirmedAt).toLocaleString(rtl?"ar-SA":"en-US"))}
{rtl?"تفاصيل الباقة":"Package details"} {row(rtl?"الفندق":"Hotel",p.hotel)}{row(rtl?"نوع الغرفة":"Room",p.roomType)}{row(rtl?"الإعاشة":"Meals",p.mealPlan)}{row(rtl?"المدة":"Duration",`${p.durationDays||"—"} ${rtl?"أيام":"days"}`)}{row(rtl?"الانطلاق":"Departure",formatDate(p.departureAt))}{row(rtl?"العودة":"Return",formatDate(p.returnAt))}
{rtl?"برنامج الرحلة":"Trip itinerary"}
{milestones.map((m,i)=>
{m.title} {m.detail}
{m.date} )}
{rtl?"إجمالي الحجز":"Booking total"} {Number(booking.totalAmount).toLocaleString()} SAR
{rtl?"المبلغ المتبقي":"Amount due"} {Number(booking.balanceAmount ?? booking.totalAmount).toLocaleString()} SAR
{rtl?"يرجى إبراز هذا التأكيد عند الطلب. تطبق سياسة المزود وشروط الباقة.":"Present this confirmation when requested. Provider terms apply."} duyufsa.com · +966 55 614 7561
;
}
function WebPaymentResultPage({ theme, lang }) {
const T = theme,
rtl = isRTL(lang),
{ setPage } = useWeb();
const orderId =
new URLSearchParams(window.location.search).get("order") || "";
const [status, setStatus] = React.useState(null);
React.useEffect(() => {
if (orderId)
fetch(
`${window.DUYUF_API_URL}/v1/payments/status?order=${encodeURIComponent(orderId)}`,
)
.then((r) => r.json())
.then((b) => setStatus(b.order?.status || "pending"))
.catch(() => setStatus("pending"));
}, [orderId]);
return (
⌛
{rtl ? "جارٍ التحقق من عملية السداد" : "Verifying payment"}
{rtl
? "لا نعتبر العملية ناجحة حتى يصل تأكيد آمن من بوابة الدفع. يمكنك العودة للموقع الآن."
: "The payment is not considered successful until a secure gateway confirmation arrives."}
{orderId && (
{orderId} · {status || "..."}
)}
setPage("home")}
style={{
marginTop: 14,
padding: "12px 22px",
border: 0,
borderRadius: 12,
background: T.primary,
color: "#fff",
font: "700 14px Alexandria",
}}
>
{rtl ? "العودة للرئيسية" : "Back home"}
);
}
// ─── Become provider page ──────────────────────────────────────────────
function WebProviderSignupPage({ theme, lang }) {
const T = theme;
const l = L[lang];
const rtl = isRTL(lang);
const { setPage } = useWeb();
const [businessName, setBusinessName] = React.useState("");
const [phone, setPhone] = React.useState("");
const [otp, setOtp] = React.useState("");
const [step, setStep] = React.useState("details");
const [busy, setBusy] = React.useState(false);
const [message, setMessage] = React.useState("");
const verifyingRef = React.useRef(false);
const normalizeSaudiPhone = (value) => {
const digits = String(value || "").replace(/\D/g, "");
if (/^05\d{8}$/.test(digits)) return `+966${digits.slice(1)}`;
if (/^9665\d{8}$/.test(digits)) return `+${digits}`;
if (/^5\d{8}$/.test(digits)) return `+966${digits}`;
return "";
};
const sendOtp = async () => {
const formattedPhone = normalizeSaudiPhone(phone);
if (!businessName.trim() || !formattedPhone) {
setMessage(
rtl
? "أدخل الاسم التجاري ورقم جوال سعودي صحيح."
: "Enter a business name and valid Saudi mobile.",
);
return;
}
setBusy(true);
setMessage("");
try {
if (window.duyufRecaptcha) window.duyufRecaptcha.clear();
window.duyufRecaptcha = new firebase.auth.RecaptchaVerifier(
"duyuf-recaptcha",
{ size: "invisible" },
);
window.duyufConfirmation = await firebase
.auth()
.signInWithPhoneNumber(formattedPhone, window.duyufRecaptcha);
setStep("otp");
setMessage(
rtl
? "أرسلنا رمز التحقق إلى جوالك."
: "We sent a verification code to your phone.",
);
} catch (error) {
console.error(error);
setMessage(
rtl
? "تعذّر إرسال الرمز. تأكد من تفعيل Phone وإضافة الدومين في Firebase."
: "Could not send code. Check Phone Auth and Firebase authorized domains.",
);
} finally {
setBusy(false);
}
};
const verifyOtp = async (codeValue = otp) => {
if (
!/^\d{6}$/.test(codeValue) ||
!window.duyufConfirmation ||
verifyingRef.current
)
return;
verifyingRef.current = true;
setBusy(true);
setMessage("");
let credential;
try {
credential = await window.duyufConfirmation.confirm(codeValue);
} catch (error) {
console.error("Firebase OTP confirmation failed", error);
setMessage(
rtl
? "الرمز غير صحيح أو انتهت صلاحيته. اطلب رمزاً جديداً."
: "The code is invalid or expired. Request a new one.",
);
setBusy(false);
verifyingRef.current = false;
return;
}
try {
const idToken = await credential.user.getIdToken(true);
const response = await fetch(
`${window.DUYUF_API_URL}/v1/auth/firebase-provider`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ idToken, businessName: businessName.trim() }),
},
);
const result = await response.json();
if (!response.ok || !result.ok)
throw new Error(result.error || "provider_login_failed");
localStorage.setItem("duyuf_provider_session", result.sessionToken);
localStorage.setItem("duyuf_provider", JSON.stringify(result.provider));
setPage("provider-dashboard");
} catch (error) {
console.error("Provider account creation failed", error);
setMessage(
rtl
? "تم توثيق الجوال، لكن تعذّر إنشاء حساب المزود. حاول مرة أخرى دون طلب رمز جديد."
: "Phone verified, but provider account creation failed. Try again without requesting a new code.",
);
} finally {
setBusy(false);
verifyingRef.current = false;
}
};
return (
<>
{/* Form */}
{rtl ? "بيانات الشركة" : "Company information"}
{step === "details"
? rtl
? "ابدأ بالاسم التجاري والجوال فقط"
: "Start with business name and mobile only"
: step === "otp"
? rtl
? "تحقق من رقم الجوال"
: "Verify mobile"
: rtl
? "تم التسجيل بنجاح"
: "Registration complete"}
{step === "details" && (
<>
{rtl ? "الاسم التجاري" : "Business name"}
setBusinessName(e.target.value)}
placeholder={
rtl ? "مثال: رحلات النور" : "Example: Al Noor Travel"
}
style={{
height: 46,
padding: "0 14px",
borderRadius: 12,
background: T.bg,
border: `1px solid ${T.hairlineStrong}`,
color: T.text,
fontFamily: "Alexandria",
fontSize: 14,
}}
/>
{rtl ? "رقم الجوال" : "Mobile number"}
🇸🇦
+966
setPhone(e.target.value.replace(/\D/g, "").slice(0, 10))
}
inputMode="numeric"
autoComplete="tel-national"
placeholder="5xxxxxxxx"
aria-label={rtl ? "رقم الجوال" : "Mobile number"}
style={{
minWidth: 0,
flex: 1,
height: "100%",
padding: "0 12px",
border: 0,
outline: 0,
background: "transparent",
color: T.text,
fontFamily: "Alexandria",
fontSize: 16,
textAlign: "left",
}}
/>
>
)}
{step === "otp" && (
<>
{rtl
? "رمز التحقق المكوّن من 6 أرقام"
: "6-digit verification code"}
{
const value = e.target.value
.replace(/\D/g, "")
.slice(0, 6);
setOtp(value);
if (value.length === 6)
setTimeout(() => verifyOtp(value), 0);
}}
inputMode="numeric"
autoComplete="one-time-code"
placeholder="••••••"
dir="ltr"
style={{
height: 54,
padding: "0 14px",
borderRadius: 12,
textAlign: "center",
letterSpacing: 8,
background: T.bg,
border: `1px solid ${T.hairlineStrong}`,
color: T.text,
fontSize: 22,
}}
/>
>
)}
{step === "done" && (
{rtl
? "حالة الملف: غير مكتمل. ستجد داخل لوحة المزود السجل التجاري والترخيص والبريد والوثائق المطلوبة."
: "Profile status: incomplete. Complete registration, license, email and documents from your dashboard."}
)}
{message && (
{message}
)}
setPage("home")}
style={{
padding: "12px 24px",
borderRadius: 12,
background: "transparent",
border: `1px solid ${T.hairline}`,
color: T.text,
fontFamily: "Alexandria",
fontWeight: 600,
fontSize: 13,
cursor: "pointer",
}}
>
{rtl ? "إلغاء" : "Cancel"}
verifyOtp()
: () => setPage("provider-dashboard")
}
style={{
flex: 1,
padding: "12px 24px",
borderRadius: 12,
background: T.primary,
border: 0,
color: "#fff",
fontFamily: "Alexandria",
fontWeight: 700,
fontSize: 13,
cursor: "pointer",
opacity: busy ? 0.6 : 1,
}}
>
{busy
? rtl
? "جارٍ التنفيذ…"
: "Please wait…"
: step === "details"
? rtl
? "إرسال رمز التحقق"
: "Send verification code"
: step === "otp"
? rtl
? "تحقق وادخل"
: "Verify and continue"
: rtl
? "الدخول إلى الموقع"
: "Continue"}
{/* Side info */}
{rtl ? "ماذا تحصل عليه" : "What you'll get"}
{[
{
i: "trend-up",
t: rtl ? "لوحة إحصائيات لحظية" : "Live analytics dashboard",
d: rtl
? "تابع الإيرادات والحجوزات بالوقت الفعلي"
: "Track revenue and bookings in real time",
},
{
i: "kaaba",
t: rtl ? "معالج تكوين الباقات" : "Package wizard",
d: rtl
? "انشر باقات جديدة خلال دقائق"
: "Publish new packages in minutes",
},
{
i: "scan",
t: rtl ? "تطبيق التشغيل" : "Operations app",
d: rtl
? "تحقّق من العملاء وتنبيهات أثناء الرحلة"
: "Check in travelers and send in-trip alerts",
},
{
i: "card",
t: rtl ? "دفعات مجدولة وآمنة" : "Scheduled secure payouts",
d: rtl
? "تحويل أسبوعي لحسابك البنكي"
: "Weekly payouts to your bank account",
},
].map((b, i) => (
))}
{rtl ? "العمولة الشفافة" : "Transparent commission"}
{rtl
? "عمولة ضيوف 8% فقط من سعر الباقة الأساسي. لا رسوم مخفية، لا اشتراك شهري."
: "Just 8% Duyuf commission on base package price. No hidden fees, no monthly subscription."}
>
);
}
// ─── Contact page ──────────────────────────────────────────────────────
function WebContactPage({ theme, lang }) {
const T = theme;
const rtl = isRTL(lang);
const submitContact = (e) => {
e.preventDefault();
const data = new FormData(e.currentTarget);
const subject = encodeURIComponent(
data.get("subject") || "Duyuf website enquiry",
);
const body = encodeURIComponent(
`${data.get("name")}\n${data.get("email")}\n\n${data.get("message")}`,
);
window.location.href = `mailto:info@duyufsa.com?subject=${subject}&body=${body}`;
};
return (
<>
{[
{
i: "phone",
t: rtl ? "واتساب" : "WhatsApp",
sub: "+966 800 200 0",
meta: rtl ? "الرد خلال دقائق" : "Reply in minutes",
tone: "green",
},
{
i: "mail",
t: rtl ? "بريد إلكتروني" : "Email",
sub: "support@duyuf.app",
meta: rtl ? "الرد خلال 24 ساعة" : "Reply in 24h",
tone: "camel",
},
{
i: "shield",
t: rtl ? "للمزوّدين" : "Providers",
sub: "providers@duyuf.app",
meta: rtl ? "فريق مخصّص" : "Dedicated team",
tone: "ink",
},
].map((c, i) => (
))}
{/* Contact form */}
{/* Office */}
{rtl ? "المكتب الرئيسي" : "Head office"}
{rtl
? "برج المملكة، الطابق 22\nطريق الملك فهد، الرياض 12241\nالمملكة العربية السعودية"
: "Kingdom Tower, Floor 22\nKing Fahd Road, Riyadh 12241\nSaudi Arabia"}
>
);
}
// ─── Sign-in page ──────────────────────────────────────────────────────
function WebSignInPage({ theme, lang }) {
const T = theme;
const rtl = isRTL(lang);
const { setPage } = useWeb();
const [phone, setPhone] = React.useState("");
const [otp, setOtp] = React.useState("");
const [step, setStep] = React.useState("phone");
const [busy, setBusy] = React.useState(false);
const [message, setMessage] = React.useState("");
const loginVerifyingRef = React.useRef(false);
React.useEffect(() => {
if (localStorage.getItem("duyuf_provider_session"))
setPage("provider-dashboard");
}, []);
const normalize = (value) => {
const d = String(value || "").replace(/\D/g, "");
if (/^05\d{8}$/.test(d)) return `+966${d.slice(1)}`;
if (/^9665\d{8}$/.test(d)) return `+${d}`;
if (/^5\d{8}$/.test(d)) return `+966${d}`;
return "";
};
const send = async () => {
const mobile = normalize(phone);
if (!mobile)
return setMessage(
rtl ? "أدخل رقم جوال سعودي صحيح." : "Enter a valid Saudi mobile.",
);
setBusy(true);
setMessage("");
try {
if (window.duyufLoginRecaptcha) window.duyufLoginRecaptcha.clear();
window.duyufLoginRecaptcha = new firebase.auth.RecaptchaVerifier(
"duyuf-login-recaptcha",
{ size: "invisible" },
);
window.duyufLoginConfirmation = await firebase
.auth()
.signInWithPhoneNumber(mobile, window.duyufLoginRecaptcha);
setStep("otp");
setMessage(
rtl ? "أرسلنا رمز التحقق إلى جوالك." : "Verification code sent.",
);
} catch (e) {
console.error(e);
setMessage(
rtl
? "تعذر إرسال الرمز. راجع إعدادات Firebase."
: "Could not send the code.",
);
} finally {
setBusy(false);
}
};
const verify = async (codeValue = otp) => {
if (!/^\d{6}$/.test(codeValue) || loginVerifyingRef.current) return;
loginVerifyingRef.current = true;
setBusy(true);
setMessage("");
let credential;
try {
credential = await window.duyufLoginConfirmation.confirm(codeValue);
} catch (e) {
console.error("Firebase OTP confirmation failed", e);
setMessage(
rtl
? "الرمز غير صحيح أو انتهت صلاحيته. اطلب رمزاً جديداً."
: "Invalid or expired code. Request a new one.",
);
setBusy(false);
loginVerifyingRef.current = false;
return;
}
try {
const idToken = await credential.user.getIdToken(true);
const response = await fetch(
`${window.DUYUF_API_URL}/v1/auth/firebase-provider`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ idToken }),
},
);
const result = await response.json();
if (!response.ok || !result.ok)
throw new Error(result.error || "login_failed");
localStorage.setItem("duyuf_provider_session", result.sessionToken);
localStorage.setItem("duyuf_provider", JSON.stringify(result.provider));
setPage("provider-dashboard");
} catch (e) {
console.error("Provider login failed", e);
setMessage(
e.message === "provider_not_registered"
? rtl
? "تم توثيق الجوال، لكن الرقم غير مسجل كمزوّد. اختر «سجّل هنا» أولاً."
: "Phone verified, but it is not registered as a provider."
: rtl
? "تم توثيق الجوال، لكن تعذّر فتح حساب المزود. حاول مرة أخرى."
: "Phone verified, but the provider account could not be opened. Try again.",
);
} finally {
setBusy(false);
loginVerifyingRef.current = false;
}
};
return (
{rtl ? "مرحباً بعودتك" : "Welcome back"}
{rtl ? "سجّل الدخول برقم جوالك" : "Sign in with your phone number"}
{step === "phone"
? rtl
? "رقم الجوال"
: "Mobile number"
: rtl
? "رمز التحقق"
: "Verification code"}
{step === "phone" ? (
🇸🇦
+966
setPhone(e.target.value.replace(/\D/g, "").slice(0, 10))
}
inputMode="numeric"
autoComplete="tel-national"
dir="ltr"
placeholder="5xxxxxxxx"
aria-label={rtl ? "رقم الجوال" : "Mobile number"}
style={{
minWidth: 0,
flex: 1,
height: "100%",
padding: "0 12px",
border: 0,
outline: 0,
background: "transparent",
color: T.text,
fontFamily: "Alexandria",
fontSize: 16,
textAlign: "left",
}}
/>
) : (
{
const value = e.target.value.replace(/\D/g, "").slice(0, 6);
setOtp(value);
if (value.length === 6) setTimeout(() => verify(value), 0);
}}
inputMode="numeric"
autoComplete="one-time-code"
dir="ltr"
placeholder="••••••"
style={{
boxSizing: "border-box",
width: "100%",
height: 52,
padding: "0 14px",
textAlign: "center",
letterSpacing: 8,
borderRadius: 14,
background: T.bg,
color: T.text,
border: `1.5px solid ${T.primary}`,
fontSize: 22,
}}
/>
)}
{message && (
{message}
)}
verify()}
style={{
width: "100%",
marginTop: 22,
padding: "14px",
borderRadius: 14,
border: 0,
background: T.primary,
color: "#fff",
fontFamily: "Alexandria",
fontWeight: 700,
fontSize: 14,
cursor: "pointer",
boxShadow: "0 8px 20px rgba(193,152,114,0.35)",
opacity: busy ? 0.6 : 1,
}}
>
{busy
? rtl
? "جارٍ التنفيذ…"
: "Please wait…"
: step === "phone"
? rtl
? "إرسال رمز التحقق"
: "Send verification code"
: rtl
? "تحقق وادخل"
: "Verify and sign in"}
setPage("browse")}
style={{
width: "100%",
padding: "12px",
borderRadius: 14,
border: `1px solid ${T.hairlineStrong}`,
background: "transparent",
color: T.text,
fontFamily: "Alexandria",
fontWeight: 600,
fontSize: 13,
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
}}
>
{" "}
{rtl ? "تصفّح كضيف" : "Continue as guest"}
{rtl
? "بالمتابعة، أنت توافق على الشروط وسياسة الخصوصية"
: "By continuing you agree to the Terms & Privacy Policy"}
);
}
function WebProviderMeetingPointsPage({ theme, lang }) {
const T = theme,
rtl = isRTL(lang),
{ setPage } = useWeb(),
token = localStorage.getItem("duyuf_provider_session");
const [items, setItems] = React.useState([]),
[busy, setBusy] = React.useState(false),
[message, setMessage] = React.useState("");
const [form, setForm] = React.useState({
city: "جدة",
name: "",
address: "",
latitude: 0,
longitude: 0,
departureTime: "08:00",
arrivalTime: "10:00",
hotelDepartureTime: "14:00",
returnArrivalTime: "18:00",
});
const load = () =>
fetch(`${window.DUYUF_API_URL}/v1/provider/meeting-points`, {
headers: { authorization: `Bearer ${token}` },
})
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((b) =>
setItems(Array.isArray(b.meetingPoints) ? b.meetingPoints : []),
)
.catch(() =>
setMessage(
rtl
? "تعذر تحميل النقاط المحفوظة، ويمكنك إضافة نقطة جديدة الآن."
: "Saved points could not be loaded; you can add a new point now.",
),
);
React.useEffect(() => {
load();
}, []);
const edit = (p) => {
setForm({
city: p.city,
name: p.name,
address: p.address,
latitude: Number(p.latitude) || 0,
longitude: Number(p.longitude) || 0,
departureTime: p.departure_time || "08:00",
arrivalTime: p.arrival_time || "10:00",
hotelDepartureTime: p.hotel_departure_time || "14:00",
returnArrivalTime: p.return_arrival_time || "18:00",
});
window.scrollTo({ top: 0, behavior: "smooth" });
};
const save = async (e) => {
e.preventDefault();
setBusy(true);
setMessage("");
try {
const r = await fetch(
`${window.DUYUF_API_URL}/v1/provider/meeting-points`,
{
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${token}`,
},
body: JSON.stringify(form),
},
);
if (r.ok) {
setMessage(
rtl
? "تم حفظ نقطة التجمع لهذه المدينة."
: "Meeting point saved for this city.",
);
load();
} else
setMessage(
rtl
? "أكمل المدينة واسم النقطة والعنوان ووقت المغادرة."
: "Complete the city, point name, address, and departure time.",
);
} catch (_) {
setMessage(
rtl
? "تعذر الاتصال مؤقتًا. حاول مرة أخرى."
: "Connection failed. Please try again.",
);
} finally {
setBusy(false);
}
};
const field = (key, label, type = "text") => (
{label}
setForm({ ...form, [key]: e.target.value })}
style={{
height: 48,
border: `1px solid ${T.hairlineStrong}`,
borderRadius: 11,
padding: "0 12px",
background: T.bg,
color: T.text,
font: "500 16px Alexandria",
}}
/>
);
return (
setPage("provider-dashboard")}
style={{ border: 0, background: "transparent", color: T.textMuted }}
>
→ {rtl ? "لوحة التحكم" : "Dashboard"}
{rtl ? "نقاط التجمع" : "Meeting points"}
{rtl
? "حدد نقطة تجمع واحدة لكل مدينة ووقت المغادرة. الوقت الافتراضي 08:00 ويمكنك تغييره."
: "Set one meeting point per city and its departure time. The default is 08:00 and can be changed."}
{field("city", rtl ? "المدينة" : "City")}
{field("name", rtl ? "اسم نقطة التجمع" : "Meeting point name")}
{field(
"departureTime",
rtl ? "ساعة المغادرة" : "Departure time",
"time",
)}
{field(
"address",
rtl ? "عنوان نقطة التجمع" : "Meeting point address",
)}
{message && (
{message}
)}
{busy
? rtl
? "جارٍ الحفظ…"
: "Saving…"
: rtl
? "حفظ نقطة التجمع"
: "Save meeting point"}
{rtl ? "النقاط المحفوظة" : "Saved points"}
{items.length ? (
items.map((p) => (
{p.city} — {p.name}
{p.address}
{rtl ? "ساعة المغادرة" : "Departure time"}:{" "}
{p.departure_time || "08:00"}
edit(p)}
style={{
padding: "9px 13px",
border: `1px solid ${T.primary}`,
borderRadius: 9,
background: "transparent",
color: T.primary,
font: "600 11px Alexandria",
}}
>
{rtl ? "تعديل" : "Edit"}
))
) : (
{rtl ? "لم تضف نقاط تجمع بعد." : "No meeting points yet."}
)}
);
}
function WebProviderBusinessProfilePage({ theme, lang }) {
const T = theme,
rtl = isRTL(lang),
{ setPage } = useWeb(),
token = localStorage.getItem("duyuf_provider_session");
const [data, setData] = React.useState(null),
[form, setForm] = React.useState({
email: "",
commercialRegistration: "",
tourismLicense: "",
}),
[busy, setBusy] = React.useState(false),
[message, setMessage] = React.useState("");
const load = () =>
fetch(`${window.DUYUF_API_URL}/v1/provider/business-profile`, {
headers: { authorization: `Bearer ${token}` },
})
.then((r) => r.json())
.then((b) => {
if (!b.ok) throw new Error();
setData(b);
setForm({
email: b.profile.email || "",
commercialRegistration: b.profile.commercial_registration || "",
tourismLicense: b.profile.tourism_license || "",
});
})
.catch(() =>
setMessage(
rtl
? "تعذر تحميل الملف التجاري."
: "Could not load business profile.",
),
);
React.useEffect(load, []);
const docs = data?.documents || [],
has = (type) => docs.some((d) => d.document_type === type),
status = data?.profile?.status || "incomplete",
complete = Math.round(
([
form.email,
form.commercialRegistration,
form.tourismLicense,
has("commercial_registration"),
has("tourism_license"),
has("brand_logo"),
].filter(Boolean).length /
6) *
100,
);
const upload = async (type, file) => {
if (!file) return;
setBusy(true);
setMessage("");
const fd = new FormData();
fd.append("type", type);
fd.append("file", file);
try {
const r = await fetch(
`${window.DUYUF_API_URL}/v1/provider/business-profile/documents`,
{
method: "POST",
headers: { authorization: `Bearer ${token}` },
body: fd,
},
);
if (!r.ok) throw new Error();
setMessage(rtl ? "تم رفع الوثيقة." : "Document uploaded.");
load();
} catch (_) {
setMessage(
rtl
? "تعذر رفع الوثيقة. استخدم PDF أو صورة بحجم أقل من 5MB."
: "Upload failed. Use a PDF or image under 5MB.",
);
} finally {
setBusy(false);
}
};
const save = async (submit) => {
setBusy(true);
setMessage("");
try {
const r = await fetch(
`${window.DUYUF_API_URL}/v1/provider/business-profile`,
{
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${token}`,
},
body: JSON.stringify({ ...form, submit }),
},
);
const b = await r.json();
if (!r.ok) throw new Error(b.error);
setMessage(
submit
? rtl
? "تم إرسال الملف لاعتماد الأدمن."
: "Profile submitted for admin approval."
: rtl
? "تم حفظ البيانات."
: "Profile saved.",
);
load();
} catch (e) {
setMessage(
e.message === "documents_required"
? rtl
? "ارفع السجل التجاري وترخيص النشاط أولًا."
: "Upload both required documents first."
: rtl
? "أكمل جميع البيانات المطلوبة."
: "Complete all required fields.",
);
} finally {
setBusy(false);
}
};
const input = (key, label, type = "text") => (
{label}
setForm({ ...form, [key]: e.target.value })}
style={{
height: 48,
border: `1px solid ${T.hairlineStrong}`,
borderRadius: 11,
padding: "0 12px",
background: T.bg,
color: T.text,
font: "500 16px Alexandria",
}}
/>
);
const oneDoc = (type, label) => (
{label}
{has(type)
? rtl
? "تم الرفع ✓"
: "Uploaded ✓"
: rtl
? "PDF أو صورة — حتى 5MB"
: "PDF or image — up to 5MB"}
{has(type)
? rtl
? "استبدال"
: "Replace"
: rtl
? "رفع الملف"
: "Upload"}
upload(type, e.target.files?.[0])}
/>
);
const doc = (type, label) =>
type === "tourism_license" ? (
<>
{oneDoc(type, label)}
{oneDoc(
"brand_logo",
rtl ? "شعار العلامة التجارية (إلزامي)" : "Brand logo (required)",
)}
>
) : (
oneDoc(type, label)
);
const statusText =
status === "approved"
? rtl
? "معتمد — الملف مكتمل 100%"
: "Approved — profile 100% complete"
: status === "pending"
? rtl
? "بانتظار اعتماد الأدمن"
: "Awaiting admin approval"
: status === "rejected"
? rtl
? "يحتاج تعديل وإعادة إرسال"
: "Needs changes and resubmission"
: rtl
? "غير مكتمل"
: "Incomplete";
return (
setPage("provider-dashboard")}
style={{ border: 0, background: "transparent", color: T.textMuted }}
>
→ {rtl ? "لوحة التحكم" : "Dashboard"}
{rtl ? "الملف التجاري" : "Business profile"}
{data?.provider?.businessName || "—"}
{data?.provider?.phone || "—"}
{statusText}
{status === "approved" ? 100 : complete}%
{input("email", rtl ? "البريد الإلكتروني" : "Email", "email")}
{input(
"commercialRegistration",
rtl ? "رقم السجل التجاري" : "Commercial registration number",
)}
{input(
"tourismLicense",
rtl ? "رقم ترخيص النشاط" : "Tourism activity license",
)}
{rtl ? "الوثائق المطلوبة" : "Required documents"}
{doc(
"commercial_registration",
rtl ? "صورة السجل التجاري" : "Commercial registration",
)}
{doc(
"tourism_license",
rtl ? "صورة ترخيص النشاط" : "Tourism license",
)}
{data?.profile?.rejection_reason && (
{data.profile.rejection_reason}
)}
{message && (
{message}
)}
save(false)}
style={{
padding: "12px 20px",
border: `1px solid ${T.primary}`,
borderRadius: 11,
background: "transparent",
color: T.primary,
font: "700 12px Alexandria",
}}
>
{rtl ? "حفظ" : "Save"}
save(true)}
style={{
padding: "12px 20px",
border: 0,
borderRadius: 11,
background: T.primary,
color: "#fff",
font: "700 12px Alexandria",
opacity:
busy || status === "approved" || complete < 100 ? 0.55 : 1,
}}
>
{rtl ? "إرسال للاعتماد" : "Submit for approval"}
);
}
function WebProviderDashboardPage({ theme, lang }) {
const T = theme,
rtl = isRTL(lang),
{ setPage, setPkg } = useWeb();
const [data, setData] = React.useState({ provider: null, packages: [] });
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
const token = localStorage.getItem("duyuf_provider_session");
if (!token) return setPage("signin");
fetch(`${window.DUYUF_API_URL}/v1/provider/packages`, {
headers: { authorization: `Bearer ${token}` },
})
.then((r) => r.json().then((body) => ({ ok: r.ok, body })))
.then(({ ok, body }) => {
if (!ok) throw new Error();
setData(body);
if (body.provider) {
localStorage.setItem("duyuf_provider", JSON.stringify(body.provider));
window.dispatchEvent(new Event("duyuf-provider-updated"));
}
})
.catch(() => {
localStorage.removeItem("duyuf_provider_session");
setPage("signin");
})
.finally(() => setLoading(false));
}, []);
const provider =
data.provider ||
JSON.parse(localStorage.getItem("duyuf_provider") || "null");
return (
{rtl ? "لوحة المزود" : "Provider dashboard"}
{rtl ? "أهلاً بك، " : "Welcome, "}
{provider?.businessName || (rtl ? "مزود ضيوف" : "Duyuf provider")}
setPage("provider-add-package")}
style={{
border: 0,
borderRadius: 13,
background: T.primary,
color: "#fff",
padding: "13px 20px",
fontFamily: "Alexandria",
fontWeight: 700,
cursor: "pointer",
}}
>
+ {rtl ? "إضافة باقة" : "Add package"}
{provider && !provider.profileComplete && (
{rtl ? "ملفك غير مكتمل" : "Your profile is incomplete"}
{rtl
? "أكمل السجل التجاري والترخيص والبريد والوثائق. يمكنك إضافة باقاتك الآن."
: "Complete registration, license, email and documents. You can add packages now."}
)}
{rtl ? "باقاتي" : "My packages"}
{loading ? (
{rtl ? "جارٍ التحميل…" : "Loading…"}
) : data.packages.length === 0 ? (
{rtl ? "لا توجد باقات بعد" : "No packages yet"}
{rtl
? "أضف أول باقة عمرة خلال أقل من دقيقة."
: "Add your first Umrah package in under a minute."}
setPage("provider-add-package")}
style={{
border: 0,
borderRadius: 12,
background: T.primary,
color: "#fff",
padding: "12px 18px",
fontFamily: "Alexandria",
fontWeight: 700,
}}
>
{rtl ? "إضافة أول باقة" : "Add first package"}
) : (
{data.packages.map((p) => (
{p.title}
{p.status === "active"
? rtl
? "نشطة"
: "Active"
: p.status === "rejected"
? rtl
? "مرفوضة"
: "Rejected"
: rtl
? "بانتظار الاعتماد"
: "Pending approval"}
{p.hotel_name}
{p.duration_days} {rtl ? "أيام" : "days"} · {p.available_from}{" "}
— {p.available_to}
{rtl ? "السعة" : "Capacity"}: {p.seats} ·{" "}
{rtl ? "المحجوز" : "Booked"}: {p.booked || 0} ·{" "}
{rtl ? "دخل" : "Checked in"}: {p.checked_in || 0}
{rtl ? "الصور" : "Images"}: {p.image_count || 0}
{Number(p.price_per_person).toLocaleString()}{" "}
{rtl ? "ر.س / فرد" : "SAR / person"}
{
setPkg(p);
setPage("provider-edit-package");
}}
style={{
padding: "9px 13px",
border: 0,
borderRadius: 10,
background: T.primary,
color: "#fff",
font: "700 12px Alexandria",
}}
>
{rtl ? "تعديل وإضافة صور" : "Edit & images"}
{
setPkg(p);
setPage("provider-package-bookings");
}}
style={{
padding: "9px 13px",
border: `1px solid ${T.hairlineStrong}`,
borderRadius: 10,
background: T.surface,
color: T.text,
font: "700 12px Alexandria",
}}
>
{rtl ? "الطلبات والوصول" : "Bookings & arrivals"}
))}
)}
);
}
const BEDBANK_CITY_NAMES = {
"مكة المكرمة": "Makkah",
مكة: "Makkah",
"المدينة المنورة": "Madinah",
المدينة: "Madinah",
جدة: "Jeddah",
الرياض: "Riyadh",
الدمام: "Dammam",
Makkah: "Makkah",
Madinah: "Madinah",
Jeddah: "Jeddah",
Riyadh: "Riyadh",
Dammam: "Dammam",
};
function ProviderHotelPicker({ theme, lang, form, setForm }) {
const T = theme,
rtl = isRTL(lang),
[hotels, setHotels] = React.useState([]),
[query, setQuery] = React.useState(""),
[busy, setBusy] = React.useState(false),
[detailsBusy, setDetailsBusy] = React.useState(false),
[error, setError] = React.useState("");
const destination =
BEDBANK_CITY_NAMES[form.destinationCity] ||
form.destinationCity ||
"Makkah";
React.useEffect(() => {
let live = true;
setBusy(true);
setError("");
setHotels([]);
Promise.resolve()
.then(async () => {
if (!window.hotelApi) throw new Error("hotel_api_missing");
const cityBody = await window.hotelApi("cities", { countryCode: "SA" }),
cities = cityBody.cities || [];
const wanted = String(destination).toLowerCase(),
city =
cities.find(
(x) => String(x.cityName || "").toLowerCase() === wanted,
) ||
cities.find((x) =>
String(x.cityName || "")
.toLowerCase()
.includes(wanted),
);
if (!city) throw new Error("city_not_found");
const body = await window.hotelApi("hotels", { cityId: city.cityId });
if (live) setHotels(body.hotels || []);
})
.catch(
() =>
live &&
setError(
rtl
? "تعذر تحميل قائمة الفنادق الآن. حاول مرة أخرى."
: "Could not load the hotel catalog. Please retry.",
),
)
.finally(() => live && setBusy(false));
return () => {
live = false;
};
}, [destination]);
const pick = async (hotel) => {
const id = String(hotel.hotelId || hotel.id || hotel.hotelCode || "");
const catalogImages = (
Array.isArray(hotel.images)
? hotel.images
: [hotel.imageUrl || hotel.image || hotel.thumbnailUrl]
)
.map((image) =>
typeof image === "string"
? image
: image?.url || image?.imageUrl || image?.path,
)
.filter(Boolean)
.slice(0, 12);
setForm((v) => ({
...v,
hotelName: hotel.hotelName || hotel.name,
hotelExternalId: id,
hotelSource: "bedbank",
hotelAddress: hotel.address || "",
hotelStarRating: Number(hotel.starRating) || 0,
hotelImages: catalogImages,
hotelRoomOptions: [],
}));
// Hotel identity and static content are independent of the package dates.
// Prefer catalog imagery whenever the supplier exposes it. The current
// BedBank catalog response contains identity/address only, so live details
// remain a best-effort fallback until a content endpoint is provided.
setDetailsBusy(true);
setError("");
try {
const rooms = [{ adults: 2, childrenAges: [] }],
base = {
destination,
countryCode: "SA",
checkInDate: form.departureAt,
checkOutDate: form.returnAt,
nationality: "SA",
residency: "SA",
rooms,
hotelIds: [Number(id) || id],
},
search = await window.hotelApi("search", base),
found =
(search.hotels || []).find(
(x) => String(x.hotelCode || x.hotelId || x.id) === id,
) || (search.hotels || [])[0];
if (!found) throw new Error("not_available");
const detail = await window.hotelApi("details", {
token: found.token,
hotelCode: found.hotelCode,
countryCode: "SA",
destination,
checkInDate: form.departureAt,
checkOutDate: form.returnAt,
nationality: "SA",
residency: "SA",
rooms,
});
const images = (
detail.images?.length
? detail.images
: [detail.imageUrl || found.imageUrl]
)
.filter(Boolean)
.slice(0, 12);
setForm((v) => ({
...v,
hotelName: detail.hotelName || found.hotelName || v.hotelName,
hotelExternalId: id,
hotelSource: "bedbank",
hotelAddress: detail.address || found.address || v.hotelAddress,
hotelStarRating:
Number(detail.starRating || found.starRating) || v.hotelStarRating,
hotelImages: images.length ? images : v.hotelImages,
hotelRoomOptions: (detail.rooms?.length?detail.rooms:found.rooms||[]).map(room=>({name:room.roomName||room.name||"",boardType:room.boardType||"",price:Number(room.price)||0,isRefundable:Boolean(room.isRefundable)})).filter(room=>room.name).slice(0,30),
}));
} catch (_) {
setError(
rtl
? "تم اختيار الفندق. مصدر HotelBeds الحالي لم يرسل صور المحتوى الثابت لهذا الفندق."
: "Hotel selected. The current HotelBeds source did not return static property images.",
);
} finally {
setDetailsBusy(false);
}
};
const shown = hotels
.filter((h) =>
String(h.hotelName || h.name || "")
.toLowerCase()
.includes(query.toLowerCase()),
)
.slice(0, 30);
return (
{rtl ? "اختر الفندق من قائمة HotelBeds" : "Choose a HotelBeds property"}
setQuery(e.target.value)}
placeholder={rtl ? "ابحث باسم الفندق…" : "Search hotel name…"}
style={{
boxSizing: "border-box",
width: "100%",
height: 50,
borderRadius: 13,
border: `1px solid ${T.hairlineStrong}`,
background: T.bg,
color: T.text,
padding: "0 13px",
font: "500 14px Alexandria",
}}
/>
{busy ? (
{rtl ? "جارٍ تحميل فنادق الوجهة…" : "Loading destination hotels…"}
) : (
{shown.map((h) => {
const id = String(h.hotelId || h.id || h.hotelCode || ""),
selected = id === String(form.hotelExternalId);
return (
pick(h)}
style={{
padding: "11px 12px",
textAlign: "start",
border: `1px solid ${selected ? T.primary : T.hairline}`,
borderRadius: 11,
background: selected ? T.camelMist : T.surface,
color: T.text,
font: "600 12px Alexandria",
cursor: "pointer",
}}
>
{h.hotelName || h.name}
{h.address && (
{h.address}
)}
);
})}
)}
{form.hotelName && (
✓ {form.hotelName}
{detailsBusy
? rtl
? "جارٍ جلب الصور والتفاصيل…"
: "Loading images and details…"
: form.hotelImages?.length
? rtl
? `تم جلب ${form.hotelImages.length} صور تلقائياً.`
: `${form.hotelImages.length} images imported automatically.`
: rtl
? "تم ربط الفندق بقائمة HotelBeds."
: "Property linked to HotelBeds."}
{form.hotelImages?.length > 0 && (
{form.hotelImages.slice(0, 5).map((src) => (
))}
)}
)}
{error && (
{error}
)}
);
}
function PackageCommercialFields({ theme, lang, form, setForm, inputStyle }) {
const T = theme,
rtl = isRTL(lang),
price = (key, label) => (
{label}
setForm((v) => ({
...v,
[key]: e.target.value,
...(key === "doublePrice"
? { pricePerPerson: e.target.value }
: {}),
}))
}
style={inputStyle}
/>
);
return (
{rtl ? "أسعار الإشغال للغرفة" : "Room occupancy pricing"}
{price("singlePrice", rtl ? "غرفة مفردة" : "Single")}
{price("doublePrice", rtl ? "غرفة ثنائية" : "Double")}
{price("triplePrice", rtl ? "غرفة ثلاثية" : "Triple")}
{price("quadPrice", rtl ? "غرفة رباعية" : "Quad")}
{rtl ? "سياسة إلغاء الباقة" : "Package cancellation policy"}
setForm((v) => ({ ...v, cancellationPolicy: e.target.value }))
}
style={{
...inputStyle,
height: "auto",
padding: 12,
resize: "vertical",
}}
/>
);
}
function WebProviderAddPackagePage({ theme, lang }) {
const T = theme,
rtl = isRTL(lang),
{ setPage } = useWeb();
const iso = (date) => date.toISOString().slice(0, 10),
today = new Date(),
after30 = new Date(Date.now() + 30 * 86400000);
const [step, setStep] = React.useState(0);
const [form, setForm] = React.useState({
travelMode: "flight",
title: "",
originCity: "الرياض",
destinationCity: "مكة المكرمة",
departureAt: iso(today),
returnAt: iso(new Date(Date.now() + 7 * 86400000)),
busType: "فاخرة",
airline: "",
flightNumber: "",
flightDepartureTime: "08:00",
flightArrivalTime: "09:30",
flightArrivalCity: "جدة",
airportTransferType: "نقل خاص",
airportTransferTime: "10:00",
returnOriginCity: "جدة",
returnDestinationCity: "الرياض",
returnAirline: "",
returnFlightNumber: "",
returnDepartureTime: "20:00",
returnArrivalTime: "21:30",
hotelName: "",
hotelExternalId: "",
hotelSource: "bedbank",
hotelAddress: "",
hotelStarRating: 0,
hotelImages: [],
hotelRoomOptions: [],
roomType: "غرفة مزدوجة",
mealPlan: "إفطار",
durationDays: 7,
pricePerPerson: "",
singlePrice: "",
doublePrice: "",
triplePrice: "",
quadPrice: "",
cancellationPolicy:
"استرداد كامل قبل 7 أيام من موعد الانطلاق، وبعد ذلك تطبق رسوم الإلغاء الموضحة في شروط الباقة.",
paymentConfirmationMode: "pay_on_arrival",
supportsTabby: false,
supportsTamara: false,
childPrice: "",
infantPrice: 0,
availableFrom: iso(today),
availableTo: iso(after30),
seats: 1,
bookingClosesHours: 72,
});
const [busy, setBusy] = React.useState(false),
[message, setMessage] = React.useState("");
const nights = Math.max(
1,
Math.round(
(new Date(`${form.returnAt}T12:00:00`) -
new Date(`${form.departureAt}T12:00:00`)) /
86400000,
) || 1,
),
durationDays = nights + 1;
const steps = rtl
? ["الأساسيات", "المسار والموعد", "الفندق", "السعة والسعر"]
: ["Basics", "Route & dates", "Hotel", "Capacity & pricing"];
const inputStyle = {
boxSizing: "border-box",
width: "100%",
height: 50,
borderRadius: 13,
border: `1px solid ${T.hairlineStrong}`,
background: T.bg,
color: T.text,
padding: "0 13px",
fontFamily: "Alexandria",
fontSize: 14,
outline: "none",
};
const adjustTime = (key, minutes) => {
const [hours, mins] = String(form[key] || "00:00").split(":").map(Number);
const total = (hours * 60 + mins + minutes + 1440) % 1440;
setForm({ ...form, [key]:`${String(Math.floor(total/60)).padStart(2,"0")}:${String(total%60).padStart(2,"0")}` });
};
const field = (key, label, type = "text", options) =>
key === "hotelName" ? (
) : key === "pricePerPerson" ? (
) : (
{label}
{options ? (
{
const value = e.target.value;
if (key === "travelMode") {
setForm({
...form,
travelMode: value,
destinationCity: "مكة المكرمة",
returnOriginCity: value === "flight" ? "جدة" : "",
returnDestinationCity:
value === "flight" ? form.originCity : "",
});
} else {
setForm({ ...form, [key]: value });
}
}}
style={inputStyle}
>
{options.map((item) => {
const pair = Array.isArray(item) ? item : [item, item];
return (
{pair[1]}
);
})}
) : type === "time" ? (
adjustTime(key,-15)} style={{border:0,background:T.surface,color:T.text,fontSize:22}}>− setForm({...form,[key]:e.target.value})} style={{width:"100%",minWidth:0,border:0,borderInline:`1px solid ${T.hairline}`,background:"transparent",color:T.text,textAlign:"center",font:"700 16px Alexandria",padding:"0 6px"}}/>adjustTime(key,15)} style={{border:0,background:T.primary,color:"#fff",fontSize:22}}>+
) : (
setForm({ ...form, [key]: e.target.value })}
style={inputStyle}
/>
)}
);
const stepper = (key, label, min, max) => {
const value = Number(form[key]) || min;
const update = (next) =>
setForm({ ...form, [key]: Math.min(max, Math.max(min, next)) });
return (
{label}
update(value - 1)}
disabled={value <= min}
style={{
flex: "0 0 56px",
width: 56,
minWidth: 56,
height: "100%",
border: 0,
borderRight: `1px solid ${T.hairline}`,
background: "transparent",
color: T.text,
fontSize: 26,
lineHeight: 1,
cursor: "pointer",
opacity: value <= min ? 0.35 : 1,
}}
>
−
update(Number(e.target.value))}
style={{
display: "block",
flex: "1 1 auto",
width: 0,
minWidth: 0,
height: "100%",
padding: 0,
border: 0,
outline: 0,
background: "transparent",
color: T.text,
textAlign: "center",
fontFamily: "Alexandria",
fontSize: 17,
fontWeight: 700,
MozAppearance: "textfield",
}}
/>
update(value + 1)}
disabled={value >= max}
style={{
flex: "0 0 56px",
width: 56,
minWidth: 56,
height: "100%",
border: 0,
borderLeft: `1px solid ${T.hairline}`,
background: T.primary,
color: "#fff",
fontSize: 24,
lineHeight: 1,
cursor: "pointer",
opacity: value >= max ? 0.35 : 1,
}}
>
+
);
};
const validate = () => {
const required =
step === 0
? ["travelMode", "title"]
: step === 1
? ["originCity", "departureAt", "returnAt"]
: step === 2
? ["hotelName", "roomType", "mealPlan"]
: ["pricePerPerson"];
const missing = required.filter((k) => !String(form[k] ?? "").trim());
if (step === 1 && form.travelMode === "flight") {
[
"airline",
"flightNumber",
"flightDepartureTime",
"flightArrivalTime",
"flightArrivalCity",
"airportTransferType",
"airportTransferTime",
"returnOriginCity",
"returnDestinationCity",
"returnAirline",
"returnFlightNumber",
"returnDepartureTime",
"returnArrivalTime",
].forEach((key) => {
if (!String(form[key] ?? "").trim() && !missing.includes(key)) missing.push(key);
});
}
if (missing.length) {
setMessage(
rtl
? "أكمل الحقول المطلوبة للمتابعة."
: "Complete the required fields.",
);
return false;
}
if (step === 1 && form.returnAt < form.departureAt) {
setMessage(
rtl
? "تاريخ العودة يجب أن يكون بعد تاريخ الانطلاق."
: "Return must be after departure.",
);
return false;
}
setMessage("");
return true;
};
const save = async (e) => {
e.preventDefault();
if (step < 3) {
if (validate()) setStep(step + 1);
return;
}
if (!validate()) return;
setBusy(true);
setMessage("");
try {
const token = localStorage.getItem("duyuf_provider_session");
const occupancyPrices = {
single: Number(form.singlePrice) || 0,
double: Number(form.doublePrice || form.pricePerPerson) || 0,
triple: Number(form.triplePrice) || 0,
quad: Number(form.quadPrice) || 0,
};
const payload = {
title: form.title.trim() || `باقة ${form.hotelName.trim()}`,
hotelName: form.hotelName,
hotelExternalId: form.hotelExternalId,
hotelSource: form.hotelSource,
hotelAddress: form.hotelAddress,
hotelStarRating: form.hotelStarRating,
hotelImages: form.hotelImages,
hotelRoomOptions: form.hotelRoomOptions,
occupancyPrices,
cancellationPolicy: form.cancellationPolicy,
paymentConfirmationMode: form.paymentConfirmationMode,
supportsTabby: form.supportsTabby,
supportsTamara: form.supportsTamara,
durationDays,
pricePerPerson: Number(form.doublePrice || form.pricePerPerson),
availableFrom: form.availableFrom,
availableTo: form.availableTo,
seats: Number(form.seats),
journey: {
travelMode: form.travelMode,
originCity: form.originCity,
destinationCity: form.destinationCity,
departureAt:
form.travelMode === "flight"
? `${form.departureAt}T${form.flightDepartureTime}:00`
: form.departureAt,
returnAt: form.returnAt,
busType: form.travelMode === "bus" ? form.busType : "",
airline: form.travelMode === "flight" ? form.airline : "",
flightNumber: form.travelMode === "flight" ? form.flightNumber : "",
arrivalAt:
form.travelMode === "flight"
? `${form.departureAt}T${form.flightArrivalTime}:00`
: "",
flightArrivalCity:
form.travelMode === "flight" ? form.flightArrivalCity : "",
airportTransferType:
form.travelMode === "flight" ? form.airportTransferType : "",
airportTransferAt:
form.travelMode === "flight"
? `${form.departureAt}T${form.airportTransferTime}:00`
: "",
returnOriginCity:
form.travelMode === "flight" ? form.returnOriginCity : "",
returnDestinationCity:
form.travelMode === "flight" ? form.returnDestinationCity : "",
returnDepartureAt:
form.travelMode === "flight"
? `${form.returnAt}T${form.returnDepartureTime}:00`
: "",
returnAirline:
form.travelMode === "flight" ? form.returnAirline : "",
returnFlightNumber:
form.travelMode === "flight" ? form.returnFlightNumber : "",
returnArrivalAt:
form.travelMode === "flight"
? `${form.returnAt}T${form.returnArrivalTime}:00`
: "",
roomType: form.roomType,
mealPlan: form.mealPlan,
childPrice: Number(
form.childPrice || form.doublePrice || form.pricePerPerson,
),
infantPrice: Number(form.infantPrice || 0),
bookingClosesHours: Number(form.bookingClosesHours),
},
};
const r = await fetch(`${window.DUYUF_API_URL}/v1/provider/packages`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
});
const b = await r.json();
if (r.status === 401) {
localStorage.removeItem("duyuf_provider_session");
setPage("signin");
return;
}
if (!r.ok || !b.ok)
throw new Error(
(b.fields || []).join("، ") || b.error || "save_failed",
);
setPage("provider-dashboard");
} catch (error) {
setMessage(
rtl
? "تعذر حفظ الباقة. راجع جميع البيانات."
: "Could not save the package. Check all fields.",
);
} finally {
setBusy(false);
}
};
const grid = (
{step === 0 && (
<>
{field("title", rtl ? "اسم الباقة" : "Package title")}
{field("travelMode", rtl ? "نوع العمرة" : "Umrah type", "text", [
["flight", rtl ? "عمرة بالطائرة" : "Umrah by flight"],
["bus", rtl ? "عمرة بالحافلة" : "Umrah by bus"],
])}
>
)}
{step === 1 && (
<>
{field(
"originCity",
rtl ? "مدينة الانطلاق" : "Departure city",
"text",
rtl
? [
"جدة",
"الرياض",
"المدينة المنورة",
"الدمام",
"القاهرة",
"دبي",
"كراتشي",
"إسطنبول",
]
: [
"Jeddah",
"Riyadh",
"Madinah",
"Dammam",
"Cairo",
"Dubai",
"Karachi",
"Istanbul",
],
)}
{field("destinationCity", rtl ? "الوجهة" : "Destination")}
{field(
"departureAt",
rtl ? "موعد الانطلاق" : "Departure date",
"date",
)}
{field("returnAt", rtl ? "موعد العودة" : "Return date", "date")}
{form.travelMode === "bus" ? (
field(
"busType",
rtl ? "نوع الحافلة" : "Bus type",
"text",
rtl ? ["عادية", "فاخرة"] : ["Regular", "Luxury"],
)
) : (
<>
{rtl?"1. رحلة الذهاب":"1. Outbound flight"}
{field("airline", rtl ? "شركة الطيران" : "Airline")}
{field(
"flightNumber",
rtl ? "رقم الرحلة" : "Flight number",
)}
{field(
"flightDepartureTime",
rtl ? "وقت الإقلاع" : "Departure time",
"time",
)}
{field(
"flightArrivalTime",
rtl ? "وقت الوصول" : "Arrival time",
"time",
)}
{field(
"flightArrivalCity",
rtl ? "مدينة وصول رحلة الذهاب" : "Outbound arrival city",
)}
{rtl?"2. الانتقال من المطار إلى الفندق":"2. Airport to hotel"}
{field(
"airportTransferType",
rtl ? "النقل من المطار إلى الفندق" : "Airport to hotel transfer",
"text",
rtl
? ["نقل خاص", "قطار", "حافلة", "سيارة أجرة", "بدون نقل"]
: ["Private transfer", "Train", "Bus", "Taxi", "No transfer"],
)}
{field(
"airportTransferTime",
rtl ? "موعد التحرك من المطار" : "Airport transfer time",
"time",
)}
{rtl?"3. رحلة العودة":"3. Return flight"}
{field(
"returnOriginCity",
rtl ? "مدينة انطلاق رحلة العودة" : "Return departure city",
)}
{field(
"returnDestinationCity",
rtl ? "مدينة وصول رحلة العودة" : "Return arrival city",
)}
{field(
"returnAirline",
rtl ? "الناقل الجوي لرحلة العودة" : "Return airline",
)}
{field(
"returnFlightNumber",
rtl ? "رقم رحلة العودة" : "Return flight number",
)}
{field(
"returnDepartureTime",
rtl ? "وقت إقلاع رحلة العودة" : "Return departure time",
"time",
)}
{field(
"returnArrivalTime",
rtl ? "وقت وصول رحلة العودة" : "Return arrival time",
"time",
)}
>
)}
>
)}
{step === 2 && (
<>
{field("hotelName", rtl ? "اسم الفندق" : "Hotel name")}
{field(
"roomType",
rtl ? "نوع الغرفة" : "Room type",
"text",
rtl
? ["مفردة", "مزدوجة", "ثلاثية", "رباعية"]
: ["Single", "Double", "Triple", "Quad"],
)}
{field(
"mealPlan",
rtl ? "الإعاشة" : "Meal plan",
"text",
rtl
? ["بدون وجبات", "إفطار", "إفطار وعشاء", "ثلاث وجبات"]
: ["Room only", "Breakfast", "Half board", "Full board"],
)}
{rtl
? `مدة الرحلة محسوبة تلقائياً: ${durationDays} أيام / ${nights} ليالٍ`
: `Calculated trip duration: ${durationDays} days / ${nights} nights`}
>
)}
{step === 3 && (
<>
{stepper(
"seats",
rtl ? "السعة بالأشخاص" : "Capacity (people)",
1,
999,
)}
{field(
"pricePerPerson",
rtl ? "سعر البالغ بالريال" : "Adult price",
"number",
)}
{field(
"childPrice",
rtl ? "سعر الطفل بالريال" : "Child price",
"number",
)}
{field(
"infantPrice",
rtl ? "سعر الرضيع بالريال" : "Infant price",
"number",
)}
{field(
"availableFrom",
rtl ? "إتاحة الحجز من" : "Booking available from",
"date",
)}
{field(
"availableTo",
rtl ? "إتاحة الحجز إلى" : "Booking available to",
"date",
)}
{field(
"bookingClosesHours",
rtl
? "إغلاق الحجز قبل المغادرة (ساعة)"
: "Booking closes before (hours)",
"number",
)}
>
)}
);
return (
step ? setStep(step - 1) : setPage("provider-dashboard")
}
style={{
border: 0,
background: "transparent",
color: T.textMuted,
cursor: "pointer",
fontFamily: "Alexandria",
}}
>
→{" "}
{step
? rtl
? "رجوع"
: "Back"
: rtl
? "العودة إلى باقاتي"
: "Back to packages"}
{steps.map((s, i) => (
{i < step ? "✓" : i + 1}
{i < 3 && (
)}
))}
{steps.map((s, i) => (
{s}
))}
{steps[step]}
{rtl
? "أدخل بيانات الرحلة كما ستظهر للعميل في مسار حجز العمرة."
: "Enter the journey details exactly as customers will see them."}
{grid}
{message && (
{message}
)}
{busy
? rtl
? "جارٍ الحفظ…"
: "Saving…"
: step < 3
? rtl
? "متابعة"
: "Continue"
: rtl
? "مراجعة وإرسال للاعتماد"
: "Review and submit"}
);
}
const PACKAGE_FEATURES_AR = [
"إفطار يومي",
"عشاء يومي",
"نقل من المطار",
"نقل إلى المطار",
"مرشد ميداني",
"تأمين سفر",
"تأشيرة العمرة",
"تذكرة طيران ذهاب وعودة",
"مواصلات بين مكة والمدينة",
"زيارات المزارات",
"مياه زمزم",
"رسوم إضافية",
];
function WebProviderEditPackagePage({ theme, lang }) {
const T = theme,
rtl = isRTL(lang),
{ pkg, setPage } = useWeb(),
token = localStorage.getItem("duyuf_provider_session");
const [form, setForm] = React.useState(null),
[images, setImages] = React.useState([]),
[busy, setBusy] = React.useState(false),
[message, setMessage] = React.useState("");
React.useEffect(() => {
if (!pkg?.id) return setPage("provider-dashboard");
fetch(`${window.DUYUF_API_URL}/v1/provider/packages/${pkg.id}`, {
headers: { authorization: `Bearer ${token}` },
})
.then((r) => r.json())
.then((b) => {
const p = b.package,
occupancy = JSON.parse(p.occupancy_prices_json || "{}");
setForm({
title: p.title,
hotelName: p.hotel_name,
durationDays: p.duration_days,
pricePerPerson: p.price_per_person,
singlePrice: occupancy.single || "",
doublePrice: occupancy.double || p.price_per_person,
triplePrice: occupancy.triple || "",
quadPrice: occupancy.quad || "",
cancellationPolicy: p.cancellation_policy || "",
paymentConfirmationMode: p.payment_confirmation_mode || "pay_on_arrival",
supportsTabby: Boolean(p.supports_tabby),
supportsTamara: Boolean(p.supports_tamara),
availableFrom: p.available_from,
availableTo: p.available_to,
seats: p.seats,
inclusions: JSON.parse(p.inclusions || "[]"),
exclusions: JSON.parse(p.exclusions || "[]"),
notes: p.notes || "",
});
setImages(b.images || []);
})
.catch(() =>
setMessage(rtl ? "تعذر تحميل الباقة." : "Could not load package."),
);
}, []);
const setFeature = (name, state) =>
setForm((v) => ({
...v,
inclusions:
state === "in"
? [...new Set([...v.inclusions, name])]
: v.inclusions.filter((x) => x !== name),
exclusions:
state === "out"
? [...new Set([...v.exclusions, name])]
: v.exclusions.filter((x) => x !== name),
}));
const upload = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setBusy(true);
setMessage("");
const fd = new FormData();
fd.append("image", file);
try {
const r = await fetch(
`${window.DUYUF_API_URL}/v1/provider/packages/${pkg.id}/images`,
{
method: "POST",
headers: { authorization: `Bearer ${token}` },
body: fd,
},
);
const b = await r.json();
if (!r.ok) throw new Error(b.error);
setImages((v) => [...v, { id: b.image.id, storage_key: b.image.key }]);
setMessage(rtl ? "تم رفع الصورة." : "Image uploaded.");
} catch (e) {
setMessage(
rtl
? "تعذر رفع الصورة. استخدم JPG أو PNG أو WebP بحجم أقل من 3MB."
: "Upload failed. Use JPG, PNG or WebP under 3MB.",
);
} finally {
setBusy(false);
e.target.value = "";
}
};
const save = async (e) => {
e.preventDefault();
setBusy(true);
setMessage("");
try {
const occupancyPrices = {
single: Number(form.singlePrice) || 0,
double: Number(form.doublePrice) || 0,
triple: Number(form.triplePrice) || 0,
quad: Number(form.quadPrice) || 0,
};
const r = await fetch(
`${window.DUYUF_API_URL}/v1/provider/packages/${pkg.id}`,
{
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${token}`,
},
body: JSON.stringify({
...form,
occupancyPrices,
durationDays: Number(form.durationDays),
pricePerPerson: Number(form.doublePrice || form.pricePerPerson),
seats: Number(form.seats),
}),
},
);
const b = await r.json();
if (!r.ok) throw new Error(b.error);
setMessage(
b.status === "pending"
? rtl
? "تم الحفظ وأُعيد إرسال التعديلات للاعتماد."
: "Saved and resubmitted for approval."
: rtl
? "تم حفظ التعديلات."
: "Changes saved.",
);
} catch (e) {
setMessage(
rtl ? "تعذر حفظ التعديلات. راجع البيانات." : "Could not save changes.",
);
} finally {
setBusy(false);
}
};
if (!form)
return (
{message || (rtl ? "جارٍ التحميل…" : "Loading…")}
);
const input = (key, label, type = "text") => (
{label}
setForm({ ...form, [key]: e.target.value })}
style={{
height: 46,
border: `1px solid ${T.hairlineStrong}`,
borderRadius: 11,
padding: "0 12px",
background: T.bg,
color: T.text,
font: "500 14px Alexandria",
}}
/>
);
return (
setPage("provider-dashboard")}
style={{ border: 0, background: "transparent", color: T.textMuted }}
>
→ {rtl ? "باقاتي" : "My packages"}
{rtl ? "تعديل الباقة والصور" : "Edit package & images"}
{input("title", rtl ? "اسم الباقة" : "Package title")}
{input("hotelName", rtl ? "الفندق" : "Hotel")}
{input("durationDays", rtl ? "عدد الأيام" : "Days", "number")}
{input(
"pricePerPerson",
rtl ? "سعر الفرد" : "Price per person",
"number",
)}
{input("availableFrom", rtl ? "متاحة من" : "Available from", "date")}
{input("availableTo", rtl ? "متاحة إلى" : "Available to", "date")}
{input("seats", rtl ? "السعة بالأشخاص" : "Capacity", "number")}
{rtl ? "صور الباقة" : "Package images"}
{rtl
? "ارفع حتى 8 صور أصلية. الصورة الأولى ستكون صورة الغلاف."
: "Upload up to 8 original images. The first image is the cover."}
{rtl ? "ما تشمله وما لا تشمله الباقة" : "Included and excluded"}
{rtl
? "اختر لكل عنصر: تشمل، لا تشمل، أو اتركه بدون تحديد. عدد الليالي يُعرض تلقائيًا من مدة الباقة."
: "Mark each item included, excluded, or leave it unset. Nights come automatically from duration."}
{PACKAGE_FEATURES_AR.map((name) => {
const state = form.inclusions.includes(name)
? "in"
: form.exclusions.includes(name)
? "out"
: "";
return (
{name}
{[
["in", rtl ? "تشمل" : "Included"],
["out", rtl ? "لا تشمل" : "Excluded"],
["", rtl ? "بدون" : "Unset"],
].map(([v, label]) => (
setFeature(name, v)}
style={{
padding: "7px 5px",
border: `1px solid ${state === v ? T.primary : T.hairline}`,
borderRadius: 8,
background: state === v ? T.camelMist : "transparent",
color: state === v ? T.primary : T.textMuted,
font: "600 10px Alexandria",
}}
>
{label}
))}
);
})}
{rtl ? "ملاحظات إضافية" : "Additional notes"}
setForm({ ...form, notes: e.target.value })}
rows="4"
style={{
resize: "vertical",
border: `1px solid ${T.hairlineStrong}`,
borderRadius: 11,
padding: 12,
background: T.bg,
color: T.text,
font: "500 13px Alexandria",
}}
/>
{message && (
{message}
)}
{busy
? rtl
? "جارٍ الحفظ…"
: "Saving…"
: rtl
? "حفظ التعديلات"
: "Save changes"}
);
}
function WebProviderBookingsPage({ theme, lang }) {
const T = theme,
rtl = isRTL(lang),
{ pkg, setPage } = useWeb(),
token = localStorage.getItem("duyuf_provider_session");
const [items, setItems] = React.useState([]),
[loading, setLoading] = React.useState(true),
[message, setMessage] = React.useState(""),
[details, setDetails] = React.useState(null),
[showPackage,setShowPackage]=React.useState(false),
[detailsBusy, setDetailsBusy] = React.useState(false);
const load = () => {
if (!pkg?.id) return setPage("provider-dashboard");
fetch(`${window.DUYUF_API_URL}/v1/provider/packages/${pkg.id}/bookings`, {
headers: { authorization: `Bearer ${token}` },
})
.then((r) => r.json())
.then((b) => {setItems(b.bookings || []);setDetails(b.package||null);})
.catch(() =>
setMessage(rtl ? "تعذر تحميل الطلبات." : "Could not load bookings."),
)
.finally(() => setLoading(false));
};
React.useEffect(load, []);
const status = async (id, next, reason="") => {
const r = await fetch(
`${window.DUYUF_API_URL}/v1/provider/bookings/${id}/status`,
{
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${token}`,
},
body: JSON.stringify({ status: next,reason }),
},
);
const body=await r.json().catch(()=>({}));
if (r.ok){setMessage(rtl?"تم تحديث الحجز وسيظهر التحديث للعميل.":"Booking updated and visible to the customer.");load();}
else setMessage(body.error==="reason_required"?(rtl?"سبب الإلغاء مطلوب.":"A reason is required."):(rtl?"تعذر تحديث الحالة.":"Could not update status."));
};
const cancel=(booking,next)=>{const reason=window.prompt(next==="cancelled"?(rtl?"اكتب سبب الإلغاء الذي سيظهر للعميل":"Enter the cancellation reason shown to the customer"):(rtl?"اكتب ملاحظة عدم الحضور":"Enter the no-show note"));if(reason?.trim())status(booking.id,next,reason.trim());};
return (
setPage("provider-dashboard")}
style={{ border: 0, background: "transparent", color: T.textMuted }}
>
→ {rtl ? "باقاتي" : "My packages"}
{rtl ? "طلبات الباقة ومواعيد الوصول" : "Package bookings & arrivals"}
{pkg?.title}
setShowPackage(v=>!v)} style={{padding:"10px 14px",border:`1px solid ${T.hairlineStrong}`,borderRadius:10,background:T.surface,color:T.text}}>{showPackage?(rtl?"إخفاء تفاصيل الباقة":"Hide package"):(rtl?"استعراض الباقة":"View package")} setPage("provider-edit-package")} style={{padding:"10px 14px",border:0,borderRadius:10,background:T.primary,color:"#fff"}}>{rtl?"تعديل الباقة والسعة":"Edit package & capacity"}
{showPackage&&details&&
{rtl?"الفندق":"Hotel"} {details.hotel_name}
{rtl?"نوع الرحلة":"Travel mode"} {details.travel_mode}
{rtl?"المسار":"Route"} {details.origin_city} - {details.destination_city}
{rtl?"السعة":"Capacity"} {details.seats}
{rtl?"السداد":"Payment"} {details.payment_confirmation_mode==="prepay_required"?(rtl?"دفع مسبق":"Prepayment"):(rtl?"عند الوصول":"On arrival")}
{rtl?"سياسة الإلغاء":"Cancellation policy"} {details.cancellation_policy||"—"}
}
{message &&
{message}
}
{loading ? (
{rtl ? "جارٍ التحميل…" : "Loading…"}
) : items.length === 0 ? (
{rtl
? "لا توجد حجوزات على هذه الباقة بعد. ستظهر هنا تلقائيًا بعد بدء الحجوزات."
: "No bookings yet. They will appear here automatically."}
) : (
{items.map((b) => (
{b.customer_name}
{b.customer_phone} · {b.travelers}{" "}
{rtl ? "أشخاص" : "people"}
{rtl ? "الوصول" : "Arrival"}: {b.arrival_at || "—"} ·{" "}
{rtl ? "المغادرة" : "Departure"}: {b.departure_at || "—"}
{rtl ? "الحالة" : "Status"}: {b.status}
{rtl?"رقم الحجز":"Reference"}: {b.booking_reference||"—"}
{rtl?"تفاصيل النزلاء":"Guests"}: {b.adults||0} {rtl?"بالغ":"adults"} · {b.children||0} {rtl?"طفل":"children"} · {b.infants||0} {rtl?"رضيع":"infants"}
{rtl?"الغرف":"Rooms"}: {(b.rooms||[]).map(r=>`${r.count}× ${{single:rtl?"مفردة":"single",double:rtl?"مزدوجة":"double",triple:rtl?"ثلاثية":"triple",quad:rtl?"رباعية":"quad"}[r.type]||r.type}`).join(" · ")||"—"}
{rtl?"القيمة والسداد":"Amount & payment"}: {Number(b.total_amount||0).toLocaleString()} {b.currency||"SAR"} · {b.payment_method==="pay_on_arrival"?(rtl?"الدفع عند الوصول":"Pay on arrival"):b.payment_status}
{b.customer_email&&<> {rtl?"البريد":"Email"}: {b.customer_email}>}
{b.cancellation_reason&&<> {rtl?"سبب الإلغاء/عدم الحضور":"Cancellation / no-show reason"}: {b.cancellation_reason}>}
{b.status === "confirmed" && (
<>
status(b.id, "checked_in")}
style={{
padding: "9px 12px",
border: 0,
borderRadius: 9,
background: "#238753",
color: "#fff",
font: "700 11px Alexandria",
}}
>
{rtl ? "تسجيل دخول" : "Check-in"}
cancel(b,"no_show")} style={{padding:"9px 12px",border:`1px solid #c47a15`,borderRadius:9,background:"#fff8eb",color:"#9a5b08",font:"700 11px Alexandria"}}>{rtl?"لم يحضر":"No-show"}
cancel(b,"cancelled")} style={{padding:"9px 12px",border:`1px solid #c93c37`,borderRadius:9,background:"#fff",color:"#b42318",font:"700 11px Alexandria"}}>{rtl?"إلغاء من المزود":"Cancel booking"}
>
)}
{b.status === "checked_in" && (
status(b.id, "checked_out")}
style={{
padding: "9px 12px",
border: 0,
borderRadius: 9,
background: T.primary,
color: "#fff",
font: "700 11px Alexandria",
}}
>
{rtl ? "تسجيل خروج" : "Check-out"}
)}
))}
)}
);
}
function WebAdminLoginPage({ theme, lang }) {
const T = theme,
rtl = isRTL(lang),
{ setPage } = useWeb();
const [phone, setPhone] = React.useState(""),
[otp, setOtp] = React.useState(""),
[step, setStep] = React.useState("phone"),
[busy, setBusy] = React.useState(false),
[message, setMessage] = React.useState("");
const verifying = React.useRef(false);
React.useEffect(() => {
if (localStorage.getItem("duyuf_admin_session")) setPage("admin-dashboard");
}, []);
const normalized = () => {
const d = phone.replace(/\D/g, "");
if (/^05\d{8}$/.test(d)) return `+966${d.slice(1)}`;
if (/^5\d{8}$/.test(d)) return `+966${d}`;
return "";
};
const send = async () => {
const mobile = normalized();
if (!mobile)
return setMessage(
rtl ? "أدخل رقم جوال سعودي صحيح." : "Enter a valid Saudi mobile.",
);
setBusy(true);
setMessage("");
try {
if (window.duyufAdminRecaptcha) window.duyufAdminRecaptcha.clear();
window.duyufAdminRecaptcha = new firebase.auth.RecaptchaVerifier(
"duyuf-admin-recaptcha",
{ size: "invisible" },
);
window.duyufAdminConfirmation = await firebase
.auth()
.signInWithPhoneNumber(mobile, window.duyufAdminRecaptcha);
setStep("otp");
setMessage(
rtl ? "أرسلنا رمز التحقق إلى جوالك." : "Verification code sent.",
);
} catch (e) {
console.error(e);
setMessage(rtl ? "تعذر إرسال الرمز." : "Could not send the code.");
} finally {
setBusy(false);
}
};
const verify = async (value) => {
const code = value || otp;
if (!/^\d{6}$/.test(code) || verifying.current) return;
verifying.current = true;
setBusy(true);
setMessage("");
try {
const credential = await window.duyufAdminConfirmation.confirm(code);
const idToken = await credential.user.getIdToken(true);
const response = await fetch(
`${window.DUYUF_API_URL}/v1/auth/firebase-admin`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ idToken }),
},
);
const body = await response.json();
if (!response.ok) throw new Error(body.error || "login_failed");
localStorage.setItem("duyuf_admin_session", body.sessionToken);
localStorage.setItem("duyuf_admin", JSON.stringify(body.admin));
setPage("admin-dashboard");
} catch (e) {
console.error(e);
setMessage(
e.message === "not_admin"
? rtl
? "هذا الرقم غير مصرح له كأدمن."
: "This number is not an admin."
: rtl
? "تعذر الدخول أو رمز التحقق غير صحيح."
: "Sign-in failed or the code is invalid.",
);
} finally {
setBusy(false);
verifying.current = false;
}
};
return (
{rtl ? "دخول إدارة ضيوف" : "Duyuf admin sign-in"}
{rtl
? "الدخول متاح لأرقام الأدمن المعتمدة فقط."
: "Only approved admin numbers can sign in."}
{step === "phone" ? (
🇸🇦 +966
setPhone(e.target.value.replace(/\D/g, "").slice(0, 10))
}
inputMode="numeric"
placeholder="5xxxxxxxx"
style={{
minWidth: 0,
flex: 1,
border: 0,
outline: 0,
padding: "0 12px",
background: "transparent",
color: T.text,
font: "500 16px Alexandria",
}}
/>
) : (
{
const v = e.target.value.replace(/\D/g, "").slice(0, 6);
setOtp(v);
if (v.length === 6) setTimeout(() => verify(v), 0);
}}
inputMode="numeric"
autoComplete="one-time-code"
dir="ltr"
placeholder="••••••"
style={{
width: "100%",
height: 52,
boxSizing: "border-box",
border: `1.5px solid ${T.primary}`,
borderRadius: 14,
background: T.bg,
color: T.text,
textAlign: "center",
letterSpacing: 8,
fontSize: 22,
}}
/>
)}
{message && (
{message}
)}
verify()}
style={{
width: "100%",
minHeight: 52,
marginTop: 18,
border: 0,
borderRadius: 14,
background: T.primary,
color: "#fff",
font: "700 15px Alexandria",
opacity: busy ? 0.6 : 1,
}}
>
{busy
? rtl
? "جارٍ التنفيذ…"
: "Please wait…"
: step === "phone"
? rtl
? "إرسال رمز التحقق"
: "Send verification code"
: rtl
? "تحقق وادخل"
: "Verify and sign in"}
);
}
function WebAdminDashboardPage({ theme, lang }) {
const T = theme,
rtl = isRTL(lang),
{ setPage } = useWeb();
const [items, setItems] = React.useState([]),
[loading, setLoading] = React.useState(true),
[message, setMessage] = React.useState("");
const load = () => {
const token = localStorage.getItem("duyuf_admin_session");
if (!token) {
setPage("admin-login");
return;
}
setLoading(true);
fetch(`${window.DUYUF_API_URL}/v1/admin/packages?status=pending`, {
headers: { authorization: `Bearer ${token}` },
})
.then(async (r) => {
const b = await r.json();
if (!r.ok) throw new Error(b.error);
setItems(b.packages || []);
})
.catch(() => {
localStorage.removeItem("duyuf_admin_session");
setPage("admin-login");
})
.finally(() => setLoading(false));
};
React.useEffect(load, []);
const view = async (id) => {
setDetailsBusy(true);
setMessage("");
try {
const token = localStorage.getItem("duyuf_admin_session");
const r = await fetch(
`${window.DUYUF_API_URL}/v1/admin/packages/${encodeURIComponent(id)}`,
{ headers: { authorization: `Bearer ${token}` } },
);
const b = await r.json();
if (!r.ok) throw new Error(b.error);
setDetails(b);
} catch (_) {
setMessage(
rtl ? "تعذر تحميل تفاصيل الباقة." : "Could not load package details.",
);
} finally {
setDetailsBusy(false);
}
};
const review = async (id, decision) => {
setMessage("");
const token = localStorage.getItem("duyuf_admin_session");
const r = await fetch(
`${window.DUYUF_API_URL}/v1/admin/packages/${encodeURIComponent(id)}/review`,
{
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${token}`,
},
body: JSON.stringify({ decision }),
},
);
if (r.ok) {
setItems((v) => v.filter((x) => x.id !== id));
setMessage(
decision === "approve"
? rtl
? "تم اعتماد الباقة ونشرها."
: "Package approved."
: rtl
? "تم رفض الباقة."
: "Package rejected.",
);
} else
setMessage(rtl ? "تعذر تنفيذ القرار." : "Could not complete the action.");
};
const parse = (value) => {
try {
return JSON.parse(value || "[]");
} catch (_) {
return [];
}
};
const detailPanel =
details &&
(() => {
const p = details.package,
inc = parse(p.inclusions),
exc = parse(p.exclusions);
const rows = [
[rtl ? "المزود" : "Provider", `${p.business_name} · ${p.phone}`],
[
rtl ? "نوع العمرة" : "Umrah type",
p.travel_mode === "bus"
? rtl
? "بالحافلة"
: "Bus"
: rtl
? "بالطائرة"
: "Flight",
],
[
rtl ? "المسار" : "Route",
`${p.origin_city || "—"} ← ${p.destination_city || "—"}`,
],
[
rtl ? "الموعد" : "Schedule",
`${p.departure_at || "—"} — ${p.return_at || "—"}`,
],
[
rtl ? "النقل" : "Transport",
p.travel_mode === "bus"
? p.bus_type || "—"
: [p.airline, p.flight_number].filter(Boolean).join(" · ") || "—",
],
...(p.travel_mode === "flight"
? [
[
rtl ? "رحلة الذهاب" : "Outbound flight",
`${p.origin_city || "—"} ← ${p.flight_arrival_city || "—"} · ${p.departure_at || "—"} · ${[p.airline, p.flight_number].filter(Boolean).join(" · ")}`,
],
[
rtl ? "النقل من المطار" : "Airport transfer",
[p.airport_transfer_at, p.airport_transfer_type]
.filter(Boolean)
.join(" · ") || "—",
],
[
rtl ? "رحلة العودة" : "Return flight",
`${p.return_origin_city || "—"} ← ${p.return_destination_city || "—"} · ${p.return_departure_at || "—"} · ${[p.return_airline, p.return_flight_number].filter(Boolean).join(" · ")}`,
],
]
: []),
[
rtl ? "الفندق والغرفة" : "Hotel & room",
`${p.hotel_name} · ${p.room_type || "—"} · ${p.meal_plan || "—"}`,
],
[
rtl ? "المدة" : "Duration",
`${p.duration_days} ${rtl ? "أيام" : "days"}`,
],
[rtl ? "السعة" : "Capacity", `${p.seats} ${rtl ? "شخص" : "people"}`],
[
rtl ? "الأسعار" : "Prices",
`${rtl ? "بالغ" : "Adult"} ${Number(p.adult_price || p.price_per_person).toLocaleString()} · ${rtl ? "طفل" : "Child"} ${Number(p.child_price || 0).toLocaleString()} · ${rtl ? "رضيع" : "Infant"} ${Number(p.infant_price || 0).toLocaleString()} ${rtl ? "ر.س" : "SAR"}`,
],
[
rtl ? "إتاحة الحجز" : "Booking window",
`${p.available_from} — ${p.available_to}`,
],
[
rtl ? "إغلاق الحجز" : "Booking closes",
`${p.booking_closes_hours || 72} ${rtl ? "ساعة قبل المغادرة" : "hours before departure"}`,
],
];
return (
{rtl
? "مراجعة كاملة قبل الاعتماد"
: "Full review before approval"}
{p.title}
setDetails(null)}
style={{
width: 40,
height: 40,
border: 0,
borderRadius: 99,
background: T.bg,
color: T.text,
fontSize: 24,
}}
>
×
{rows.map(([k, v]) => (
{k}
{v}
))}
{rtl
? "أوقات البرنامج ونقطة التجمع يحددها المزود من إعدادات كل مدينة؛ الأدمن يراجعها فقط."
: "The provider sets itinerary times and meeting point per city; admin review is read-only."}
{rtl ? "الصور المرفقة" : "Attached images"}
{details.images.length ? (
details.images.map((img) => (
))
) : (
{rtl ? "لا توجد صور مرفقة." : "No images attached."}
)}
{rtl ? "تشمل الباقة" : "Included"}
{inc.length ? (
inc.map((x) => {x} )
) : (
{rtl ? "لم يحدد المزود عناصر." : "Not specified."}
)}
{rtl ? "لا تشمل الباقة" : "Excluded"}
{exc.length ? (
exc.map((x) => {x} )
) : (
{rtl ? "لم يحدد المزود عناصر." : "Not specified."}
)}
{p.notes && (
{p.notes}
)}
{
review(p.id, "approve");
setDetails(null);
}}
style={{
padding: "12px 22px",
border: 0,
borderRadius: 11,
background: "#238753",
color: "#fff",
font: "700 13px Alexandria",
}}
>
{rtl ? "اعتماد ونشر" : "Approve & publish"}
{
review(p.id, "reject");
setDetails(null);
}}
style={{
padding: "12px 22px",
border: "1px solid #b42318",
borderRadius: 11,
background: "transparent",
color: "#b42318",
font: "700 13px Alexandria",
}}
>
{rtl ? "رفض" : "Reject"}
);
})();
return (
{rtl ? "إدارة ضيوف" : "Duyuf administration"}
{rtl ? "طلبات اعتماد الباقات" : "Package approval requests"}
{
localStorage.removeItem("duyuf_admin_session");
setPage("admin-login");
}}
style={{
padding: "10px 16px",
border: `1px solid ${T.hairlineStrong}`,
borderRadius: 11,
background: T.surface,
color: T.text,
font: "600 12px Alexandria",
}}
>
{rtl ? "تسجيل الخروج" : "Sign out"}
{message && (
{message}
)}
{loading ? (
{rtl ? "جارٍ التحميل…" : "Loading…"}
) : items.length === 0 ? (
{rtl
? "لا توجد باقات بانتظار الاعتماد."
: "No packages are awaiting approval."}
) : (
{items.map((p) => (
{p.business_name} · {p.phone}
{p.title}
{p.hotel_name} · {p.duration_days} {rtl ? "أيام" : "days"} ·{" "}
{p.seats} {rtl ? "شخص" : "people"}
{p.available_from} — {p.available_to}
{Number(p.price_per_person).toLocaleString()}{" "}
{rtl ? "ر.س / فرد" : "SAR / person"}
review(p.id, "approve")}
style={{
padding: "12px 18px",
border: 0,
borderRadius: 11,
background: "#238753",
color: "#fff",
font: "700 13px Alexandria",
}}
>
{rtl ? "اعتماد" : "Approve"}
review(p.id, "reject")}
style={{
padding: "12px 18px",
border: "1px solid #b42318",
borderRadius: 11,
background: "transparent",
color: "#b42318",
font: "700 13px Alexandria",
}}
>
{rtl ? "رفض" : "Reject"}
))}
)}
);
}
function WebUmrahJourneyPage({ theme, lang }) {
const T = theme,
rtl = isRTL(lang),
{ setPage, setPkg, umrahSearch } = useWeb();
const [step, setStep] = React.useState(0),
[mode, setMode] = React.useState(""),
[origin, setOrigin] = React.useState(umrahSearch?.origin || ""),
[destination, setDestination] = React.useState(
umrahSearch?.destination || "",
),
[departure, setDeparture] = React.useState(umrahSearch?.date || ""),
[travelers, setTravelers] = React.useState({
adult: Number(umrahSearch?.travelers || 1),
child: 0,
infant: 0,
}),
[packages, setPackages] = React.useState([]),
[loading, setLoading] = React.useState(true);
React.useEffect(() => {
fetch(`${window.DUYUF_API_URL}/v1/public/packages`)
.then((r) => r.json())
.then((b) => setPackages(b.packages || []))
.finally(() => setLoading(false));
}, []);
const modePackages = packages.filter((p) => p.travel_mode === mode),
origins = [
...new Set(modePackages.map((p) => p.origin_city).filter(Boolean)),
],
destinations = [
...new Set(
modePackages
.filter((p) => !origin || p.origin_city === origin)
.map((p) => p.destination_city)
.filter(Boolean),
),
],
routePackages = modePackages.filter(
(p) =>
(!origin || p.origin_city === origin) &&
(!destination || p.destination_city === destination),
),
dates = [
...new Set(routePackages.map((p) => p.departure_at).filter(Boolean)),
],
total = travelers.adult + travelers.child + travelers.infant,
results = routePackages.filter(
(p) =>
(!departure || p.departure_at === departure) &&
Number(p.seats) >= total,
);
const choose = (p) => {
setPkg({
id: p.id,
hotel: p.hotel_name,
title: p.title,
dist: rtl
? `${p.origin_city} إلى ${p.destination_city}`
: `${p.origin_city} to ${p.destination_city}`,
stars: 5,
nights: Math.max(1, Number(p.duration_days) - 1),
price: Number(p.price_per_person),
seats: Number(p.seats),
board: p.meal_plan || "",
tone: "camel",
provider: p.business_name,
dates: `${p.departure_at || p.available_from} — ${p.return_at || p.available_to}`,
imageUrl: p.cover_key
? `${window.DUYUF_API_URL}/v1/public/package-images/${encodeURIComponent(p.cover_key)}`
: "",
inclusions: parseJson(p.inclusions),
exclusions: parseJson(p.exclusions),
notes: p.notes || "",
isDynamic: true,
travelMode: p.travel_mode,
originCity: p.origin_city,
destinationCity: p.destination_city,
departureAt: p.departure_at,
returnAt: p.return_at,
busType: p.bus_type,
airline: p.airline,
flightNumber: p.flight_number,
airportTransferType: p.airport_transfer_type,
airportTransferAt: p.airport_transfer_at,
flightArrivalCity: p.flight_arrival_city,
returnOriginCity: p.return_origin_city,
returnDestinationCity: p.return_destination_city,
returnDepartureAt: p.return_departure_at,
returnAirline: p.return_airline,
returnFlightNumber: p.return_flight_number,
roomType: p.room_type,
mealPlan: p.meal_plan,
meetingPoint: p.provider_meeting_name || p.meeting_point,
meetingAddress: p.provider_meeting_address,
meetingLatitude: p.provider_meeting_latitude,
meetingLongitude: p.provider_meeting_longitude,
meetingAt: p.provider_departure_time || p.meeting_at,
boardingAt: p.boarding_at,
arrivalAt: p.provider_arrival_time || p.arrival_at,
hotelDepartureAt: p.provider_hotel_departure_time || p.hotel_departure_at,
returnArrivalAt: p.provider_return_arrival_time || p.return_arrival_at,
itineraryNotes: p.itinerary_notes,
travelers,
});
setPage("detail");
};
const parseJson = (v) => {
try {
return JSON.parse(v || "[]");
} catch (_) {
return [];
}
};
const cardStyle = (active) => ({
padding: 18,
borderRadius: 16,
border: `2px solid ${active ? T.primary : T.hairline}`,
background: active ? T.camelMist : T.surface,
color: T.text,
textAlign: "start",
fontFamily: "Alexandria",
cursor: "pointer",
});
const next = () => {
if (step === 0 && mode) setStep(1);
else if (step === 1 && origin && destination) setStep(2);
else if (step === 2 && departure) setStep(3);
else if (step === 3) setStep(4);
};
const counter = (key, label) => {
const value = travelers[key],
min = key === "adult" ? 1 : 0;
return (
{label}
setTravelers((v) => ({ ...v, [key]: Math.max(min, value - 1) }))
}
style={{
width: 38,
height: 38,
borderRadius: 99,
border: 0,
background: T.surfaceAlt,
fontSize: 22,
}}
>
−
{value}
setTravelers((v) => ({ ...v, [key]: value + 1 }))}
style={{
width: 38,
height: 38,
borderRadius: 99,
border: 0,
background: T.primary,
color: "#fff",
fontSize: 20,
}}
>
+
);
};
const titles = rtl
? [
"اختر نوع العمرة",
"اختر المسار",
"موعد الرحلة",
"المسافرون",
"الباقات المتاحة",
]
: [
"Choose Umrah type",
"Choose route",
"Trip date",
"Travelers",
"Available packages",
];
return (
setStep(Math.max(0, step - 1))}
style={{
border: 0,
background: "transparent",
color: T.textMuted,
font: "600 12px Alexandria",
opacity: step === 0 ? 0.35 : 1,
}}
>
→ {rtl ? "رجوع" : "Back"}
{titles.map((_, i) => (
))}
{rtl ? `الخطوة ${step + 1} من 5` : `Step ${step + 1} of 5`}
{titles[step]}
{loading ? (
{rtl ? "جارٍ تحميل الرحلات…" : "Loading trips…"}
) : (
<>
{step === 0 && (
setMode("flight")}
style={cardStyle(mode === "flight")}
>
{rtl ? "عمرة بالطائرة" : "Umrah by flight"}
{rtl
? "رحلات مجدولة شاملة الطيران والإقامة"
: "Scheduled flight and stay packages"}
setMode("bus")}
style={cardStyle(mode === "bus")}
>
{rtl ? "عمرة بالحافلة" : "Umrah by bus"}
{rtl
? "اختر المسار ونوع الحافلة والفندق"
: "Choose route, bus type and hotel"}
)}
{step === 1 && (
{origin && (
{rtl ? "إلى — الوجهة" : "To — destination"}
{ setDestination(e.target.value); setDeparture(""); }}
style={{ height: 54, padding: "0 14px", border: `1px solid ${T.hairlineStrong}`, borderRadius: 13, background: T.surface, color: T.text, font: "700 14px Alexandria" }}
>
{rtl ? "اختر الوجهة" : "Select destination"}
{destinations.map((city) => {city} )}
)}
{origins.length ? (
origins.map((city) => (
{ setOrigin(city); setDestination(""); setDeparture(""); }}
style={cardStyle(origin === city)}
>
{city}
{
modePackages.filter((p) => p.origin_city === city)
.length
}{" "}
{rtl ? "رحلات متاحة" : "available trips"}
))
) : (
{rtl
? "لا توجد مدن انطلاق متاحة لهذا النوع حالياً."
: "No departure cities available."}
)}
)}
{step === 2 && (
{dates.map((date) => (
setDeparture(date)}
style={cardStyle(departure === date)}
>
{" "}
{date}
))}
)}
{step === 3 && (
{counter("adult", rtl ? "بالغ" : "Adult")}
{counter("child", rtl ? "طفل" : "Child")}
{counter("infant", rtl ? "رضيع" : "Infant")}
{rtl
? `الإجمالي: ${total} مسافرين`
: `Total: ${total} travelers`}
)}
{step === 4 && (
{results.length ? (
results.map((p) => (
{p.travel_mode === "bus"
? p.bus_type || (rtl ? "حافلة" : "Bus")
: [p.airline, p.flight_number].filter(Boolean).join(" · ") ||
(rtl ? "طيران" : "Flight")}{" "}
· {p.business_name}
{p.title}
{p.origin_city} ← {p.destination_city}
{p.departure_at} — {p.return_at}
{p.hotel_name} · {p.room_type || ""} ·{" "}
{p.meal_plan || ""}
{Number(p.price_per_person).toLocaleString()}{" "}
{rtl ? "ر.س / بالغ" : "SAR / adult"}
choose(p)}
style={{
padding: "11px 17px",
border: 0,
borderRadius: 11,
background: T.primary,
color: "#fff",
font: "700 12px Alexandria",
}}
>
{rtl
? "عرض الباقة والبرنامج"
: "View package & itinerary"}
))
) : (
{rtl
? "لا توجد باقات تستوعب العدد المحدد في هذا الموعد."
: "No packages fit this party size."}
)}
)}
>
)}
{step < 4 && (
{rtl ? "متابعة" : "Continue"}
)}
);
}
function WebItineraryTimeline({ theme, lang, pkg }) {
if (!pkg?.isDynamic) return null;
const T = theme,
rtl = isRTL(lang),
bus = pkg.travelMode === "bus";
const busMilestones = [
{
t: rtl ? "التجمع" : "Meeting",
d: [
pkg.meetingAt || pkg.departureAt,
pkg.meetingPoint || pkg.originCity,
]
.filter(Boolean)
.join(" · "),
},
{
t: rtl
? bus
? "ركوب الحافلة والانطلاق"
: "التوجه للمطار والانطلاق"
: bus
? "Board bus & depart"
: "Airport & departure",
d:
[
pkg.boardingAt,
bus
? pkg.busType
: [pkg.airline, pkg.flightNumber].filter(Boolean).join(" · "),
]
.filter(Boolean)
.join(" · ") || "—",
},
{
t: rtl ? "الوصول إلى مكة" : "Arrive in Makkah",
d: [pkg.arrivalAt, pkg.destinationCity].filter(Boolean).join(" · "),
},
{
t: rtl ? "الإقامة وأداء العمرة" : "Stay & perform Umrah",
d: `${pkg.hotel} · ${pkg.nights} ${rtl ? "ليالٍ" : "nights"}`,
},
{
t: rtl ? "المغادرة من الفندق" : "Hotel departure",
d: pkg.hotelDepartureAt || pkg.returnAt || "—",
},
{
t: rtl ? "الوصول إلى نقطة التجمع" : "Return to meeting point",
d: [pkg.returnArrivalAt, pkg.meetingPoint || pkg.originCity]
.filter(Boolean)
.join(" · "),
},
];
const flightMilestones = [
{
t: rtl ? "رحلة الذهاب" : "Outbound flight",
d: [
rtl ? `من ${pkg.originCity || "—"} إلى ${pkg.flightArrivalCity || "—"}` : `From ${pkg.originCity || "—"} to ${pkg.flightArrivalCity || "—"}`,
pkg.departureAt,
[pkg.airline, pkg.flightNumber].filter(Boolean).join(" · "),
]
.filter(Boolean)
.join(" · "),
},
{
t: rtl
? `الوصول إلى ${pkg.flightArrivalCity || "مدينة الوصول"}`
: `Arrive in ${pkg.flightArrivalCity || "destination"}`,
d: pkg.arrivalAt || "—",
},
{
t: rtl ? "الانتقال من المطار إلى الفندق" : "Airport to hotel transfer",
d:
[pkg.airportTransferAt, pkg.airportTransferType]
.filter(Boolean)
.join(" · ") || "—",
},
{
t: rtl ? "الإقامة وأداء العمرة" : "Stay & perform Umrah",
d: `${pkg.hotel} · ${pkg.nights} ${rtl ? "ليالٍ" : "nights"}`,
},
{
t: rtl ? "المغادرة من الفندق" : "Hotel departure",
d: pkg.hotelDepartureAt || pkg.returnDepartureAt || "—",
},
{
t: rtl ? "رحلة العودة" : "Return flight",
d: [
rtl ? `من ${pkg.returnOriginCity || pkg.flightArrivalCity || "—"} إلى ${pkg.returnDestinationCity || pkg.originCity || "—"}` : `From ${pkg.returnOriginCity || pkg.flightArrivalCity || "—"} to ${pkg.returnDestinationCity || pkg.originCity || "—"}`,
pkg.returnDepartureAt || pkg.returnAt,
[pkg.returnAirline, pkg.returnFlightNumber]
.filter(Boolean)
.join(" · "),
]
.filter(Boolean)
.join(" · "),
},
{
t: rtl
? `الوصول إلى ${pkg.returnDestinationCity || pkg.originCity || "مدينة الوصول"}`
: `Arrive in ${pkg.returnDestinationCity || pkg.originCity || "destination"}`,
d: pkg.returnArrivalAt || "—",
},
];
const milestones = bus ? busMilestones : flightMilestones;
return (
{rtl ? "برنامج الرحلة" : "Trip itinerary"}
{milestones.map((m, i) => (
{m.t}
{m.d}
))}
);
}
function AdminItineraryEditor({ theme, lang, data, onChange, token }) {
const T = theme,
rtl = isRTL(lang),
p = data.package;
const keys = [
[
"boarding_at",
rtl ? "وقت الركوب والانطلاق" : "Boarding time",
"datetime-local",
],
[
"arrival_at",
rtl ? "وقت الوصول المتوقع" : "Expected arrival",
"datetime-local",
],
[
"hotel_departure_at",
rtl ? "وقت المغادرة من الفندق" : "Hotel departure",
"datetime-local",
],
[
"return_arrival_at",
rtl ? "وقت العودة لنقطة التجمع" : "Return arrival",
"datetime-local",
],
];
const [busy, setBusy] = React.useState(false),
[saved, setSaved] = React.useState(false);
const set = (key, value) =>
onChange({ ...data, package: { ...p, [key]: value } });
const save = async () => {
setBusy(true);
setSaved(false);
const body = {
meetingPoint: p.meeting_point,
meetingAt: p.meeting_at,
boardingAt: p.boarding_at,
arrivalAt: p.provider_arrival_time || p.arrival_at,
hotelDepartureAt: p.provider_hotel_departure_time || p.hotel_departure_at,
returnArrivalAt: p.provider_return_arrival_time || p.return_arrival_at,
itineraryNotes: p.itinerary_notes,
};
const r = await fetch(
`${window.DUYUF_API_URL}/v1/admin/packages/${encodeURIComponent(p.id)}/itinerary`,
{
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
},
);
setBusy(false);
setSaved(r.ok);
};
return (
{rtl ? "برنامج الرحلة ونقاط التجمع" : "Itinerary & meeting points"}
{rtl
? "أدخل هذه البيانات قبل الاعتماد؛ ستظهر للعميل كخط زمني داخل تفاصيل الباقة."
: "Set these before approval; customers will see them as a timeline."}
{keys.map(([key, label, type]) => (
{label}
set(key, e.target.value)}
style={{
width: "100%",
height: 46,
border: `1px solid ${T.hairlineStrong}`,
borderRadius: 10,
padding: "0 11px",
background: T.surface,
color: T.text,
font: "500 16px Alexandria",
}}
/>
))}
{rtl ? "ملاحظات برنامج الرحلة" : "Itinerary notes"}
set("itinerary_notes", e.target.value)}
rows="3"
style={{
border: `1px solid ${T.hairlineStrong}`,
borderRadius: 10,
padding: 11,
background: T.surface,
color: T.text,
font: "500 16px Alexandria",
}}
/>
{busy
? rtl
? "جارٍ الحفظ…"
: "Saving…"
: saved
? rtl
? "تم حفظ البرنامج ✓"
: "Saved ✓"
: rtl
? "حفظ برنامج الرحلة"
: "Save itinerary"}
);
}
function WebAdminDashboardPageV2({ theme, lang }) {
const T = theme,
rtl = isRTL(lang),
{ setPage } = useWeb(),
token = localStorage.getItem("duyuf_admin_session");
const [items, setItems] = React.useState([]),
[selected, setSelected] = React.useState(null),
[busy, setBusy] = React.useState(true),
[message, setMessage] = React.useState("");
const parse = (v) => {
try {
return JSON.parse(v || "[]");
} catch (_) {
return [];
}
};
const load = () => {
if (!token) return setPage("admin-login");
setBusy(true);
fetch(`${window.DUYUF_API_URL}/v1/admin/packages?status=pending`, {
headers: { authorization: `Bearer ${token}` },
})
.then((r) => r.json().then((body) => ({ ok: r.ok, body })))
.then(({ ok, body }) => {
if (!ok) throw new Error();
setItems(body.packages || []);
})
.catch(() => {
localStorage.removeItem("duyuf_admin_session");
setPage(
localStorage.getItem("duyuf_provider_session")
? "provider-dashboard"
: "admin-login",
);
})
.finally(() => setBusy(false));
};
React.useEffect(load, []);
const view = async (id) => {
setBusy(true);
const r = await fetch(
`${window.DUYUF_API_URL}/v1/admin/packages/${encodeURIComponent(id)}`,
{ headers: { authorization: `Bearer ${token}` } },
);
const b = await r.json();
setBusy(false);
if (r.ok) setSelected(b);
else setMessage(rtl ? "تعذر تحميل التفاصيل." : "Could not load details.");
};
const review = async (decision) => {
const id = selected?.package?.id;
if (!id) return;
setBusy(true);
const r = await fetch(
`${window.DUYUF_API_URL}/v1/admin/packages/${encodeURIComponent(id)}/review`,
{
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${token}`,
},
body: JSON.stringify({ decision }),
},
);
setBusy(false);
if (r.ok) {
setItems((v) => v.filter((x) => x.id !== id));
setSelected(null);
setMessage(
decision === "approve"
? rtl
? "تم اعتماد الباقة ونشرها."
: "Package approved and published."
: rtl
? "تم رفض الباقة."
: "Package rejected.",
);
} else
setMessage(rtl ? "تعذر تنفيذ القرار." : "Could not complete the action.");
};
const signOut = () => {
localStorage.removeItem("duyuf_admin_session");
localStorage.removeItem("duyuf_admin");
setPage(
localStorage.getItem("duyuf_provider_session")
? "provider-dashboard"
: "admin-login",
);
};
const p = selected?.package,
inc = p ? parse(p.inclusions) : [],
exc = p ? parse(p.exclusions) : [];
const facts = p
? [
[rtl ? "المزود" : "Provider", `${p.business_name} · ${p.phone}`],
[
rtl ? "نوع العمرة" : "Umrah type",
p.travel_mode === "bus"
? rtl
? "بالحافلة"
: "Bus"
: rtl
? "بالطائرة"
: "Flight",
],
[
rtl ? "المسار" : "Route",
`${p.origin_city || "—"} ← ${p.destination_city || "—"}`,
],
[
rtl ? "موعد الرحلة" : "Schedule",
`${p.departure_at || "—"} — ${p.return_at || "—"}`,
],
[
rtl ? "وسيلة النقل" : "Transport",
p.travel_mode === "bus"
? p.bus_type || "—"
: [p.airline, p.flight_number].filter(Boolean).join(" · ") || "—",
],
[
rtl ? "الفندق" : "Hotel",
`${p.hotel_name} · ${p.room_type || "—"} · ${p.meal_plan || "—"}`,
],
[
rtl ? "المدة والسعة" : "Duration & capacity",
`${p.duration_days} ${rtl ? "أيام" : "days"} · ${p.seats} ${rtl ? "شخص" : "people"}`,
],
[
rtl ? "الأسعار" : "Prices",
`${rtl ? "بالغ" : "Adult"} ${Number(p.adult_price || p.price_per_person).toLocaleString()} · ${rtl ? "طفل" : "Child"} ${Number(p.child_price || 0).toLocaleString()} · ${rtl ? "رضيع" : "Infant"} ${Number(p.infant_price || 0).toLocaleString()} ${rtl ? "ر.س" : "SAR"}`,
],
[
rtl ? "فترة الحجز" : "Booking window",
`${p.available_from} — ${p.available_to}`,
],
[
rtl ? "إغلاق الحجز" : "Booking closes",
`${p.booking_closes_hours || 72} ${rtl ? "ساعة قبل المغادرة" : "hours before departure"}`,
],
]
: [];
return (
{message && (
{message}
)}
{p && (
{rtl
? "التفاصيل الكاملة قبل الاعتماد"
: "Full details before approval"}
{p.title}
setSelected(null)}
style={{
width: 40,
height: 40,
border: 0,
borderRadius: 99,
background: T.bg,
color: T.text,
fontSize: 24,
}}
>
×
{facts.map(([k, v]) => (
{k}
{v}
))}
{rtl
? "أوقات البرنامج ونقطة التجمع يحددها المزود من إعدادات كل مدينة؛ الأدمن يراجعها فقط."
: "The provider sets itinerary times and meeting point per city; admin review is read-only."}
{rtl ? "الصور المرفقة" : "Attached images"}
{selected.images?.length ? (
selected.images.map((img) => (
))
) : (
{rtl ? "لا توجد صور مرفقة." : "No images attached."}
)}
{rtl ? "تشمل الباقة" : "Included"}
{inc.length ? (
inc.map((x) => {x} )
) : (
{rtl ? "غير محدد" : "Not specified"}
)}
{rtl ? "لا تشمل الباقة" : "Excluded"}
{exc.length ? (
exc.map((x) => {x} )
) : (
{rtl ? "غير محدد" : "Not specified"}
)}
{p.notes && (
{p.notes}
)}
review("approve")}
style={{
padding: "12px 22px",
border: 0,
borderRadius: 11,
background: "#238753",
color: "#fff",
font: "700 13px Alexandria",
}}
>
{rtl ? "اعتماد ونشر" : "Approve & publish"}
review("reject")}
style={{
padding: "12px 22px",
border: "1px solid #b42318",
borderRadius: 11,
background: "transparent",
color: "#b42318",
font: "700 13px Alexandria",
}}
>
{rtl ? "رفض" : "Reject"}
)}
{busy && !p ? (
{rtl ? "جارٍ التحميل…" : "Loading…"}
) : !p && items.length === 0 ? (
{rtl
? "لا توجد باقات بانتظار الاعتماد."
: "No packages await approval."}
) : (
!p && (
{items.map((item) => (
{item.business_name} · {item.phone}
{item.title}
{item.hotel_name} · {item.duration_days}{" "}
{rtl ? "أيام" : "days"} ·{" "}
{Number(item.price_per_person).toLocaleString()}{" "}
{rtl ? "ر.س" : "SAR"}
view(item.id)}
style={{
padding: "11px 17px",
border: 0,
borderRadius: 11,
background: T.primary,
color: "#fff",
font: "700 12px Alexandria",
}}
>
{rtl ? "استعراض التفاصيل" : "View details"}
))}
)
)}
);
}
WebBrowsePage = WebUmrahJourneyPage;
function WebAdminProvidersPanel({ theme, lang, providers, onRefresh }) {
const T = theme,
rtl = isRTL(lang),
token = localStorage.getItem("duyuf_admin_session"),
[selected, setSelected] = React.useState(null),
[editing, setEditing] = React.useState(false),
[editName, setEditName] = React.useState(""),
[editPhone, setEditPhone] = React.useState(""),
[busy, setBusy] = React.useState(false),
[message, setMessage] = React.useState("");
const view = async (id) => {
setBusy(true);
setMessage("");
try {
const r = await fetch(
`${window.DUYUF_API_URL}/v1/admin/providers/${encodeURIComponent(id)}`,
{ headers: { authorization: `Bearer ${token}` } },
);
const b = await r.json();
if (!r.ok) throw new Error();
setSelected(b);
setEditName(b.provider.business_name || "");
setEditPhone(b.provider.phone || "");
setEditing(false);
} catch (_) {
setMessage(
rtl ? "تعذر فتح ملف المزود." : "Could not open provider profile.",
);
} finally {
setBusy(false);
}
};
const saveProvider = async () => {
if (!selected) return;
setBusy(true); setMessage("");
try {
const r = await fetch(`${window.DUYUF_API_URL}/v1/admin/providers/${encodeURIComponent(selected.provider.id)}`, {
method: "PATCH",
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
body: JSON.stringify({ businessName: editName, phone: editPhone }),
});
const b = await r.json();
if (!r.ok) throw new Error(b.error || "update_failed");
setSelected((current) => ({ ...current, provider: { ...current.provider, ...b.provider } }));
setEditing(false);
setMessage(rtl ? "تم تحديث اسم المزود ورقم الجوال." : "Provider name and phone updated.");
onRefresh?.();
} catch (error) {
setMessage(error.message === "phone_already_used" ? (rtl ? "رقم الجوال مستخدم لمزود آخر." : "Phone is already used by another provider.") : (rtl ? "تحقق من الاسم ورقم سعودي صحيح يبدأ بـ 05 أو +9665." : "Check the name and Saudi phone number."));
} finally { setBusy(false); }
};
const review = async (decision) => {
if (!selected) return;
setBusy(true);
try {
const r = await fetch(
`${window.DUYUF_API_URL}/v1/admin/providers/${encodeURIComponent(selected.provider.id)}/review`,
{
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${token}`,
},
body: JSON.stringify({ decision }),
},
);
if (!r.ok) throw new Error();
setMessage(
decision === "approve"
? rtl
? "تم اعتماد المزود وبلغ اكتمال ملفه 100%."
: "Provider approved; profile is now 100% complete."
: rtl
? "تم رفض الملف لإعادة استكماله."
: "Profile rejected for resubmission.",
);
setSelected(null);
onRefresh?.();
} catch (_) {
setMessage(rtl ? "تعذر تنفيذ القرار." : "Could not complete the review.");
} finally {
setBusy(false);
}
};
const status = (p) =>
p.profile_status === "approved"
? rtl
? "معتمد"
: "Approved"
: p.profile_status === "pending"
? rtl
? "بانتظار الاعتماد"
: "Awaiting approval"
: p.profile_status === "rejected"
? rtl
? "مرفوض"
: "Rejected"
: rtl
? "غير مكتمل"
: "Incomplete";
if (selected) {
const p = selected.provider;
return (
{rtl ? "تفاصيل المزود" : "Provider details"}
{p.business_name}
{!editing && setEditing(true)} style={{ marginTop: 8, padding: "8px 12px", border: `1px solid ${T.primary}`, borderRadius: 9, background: "transparent", color: T.primary, font: "700 11px Alexandria" }}>{rtl ? "تعديل بيانات المزود" : "Edit provider"} }
setSelected(null)}
style={{
width: 40,
height: 40,
border: 0,
borderRadius: 99,
background: T.bg,
fontSize: 23,
}}
>
×
{editing &&
{rtl ? "اسم المزود" : "Provider name"} setEditName(e.target.value)} style={{ padding: 11, borderRadius: 9, border: `1px solid ${T.hairlineStrong}`, background: T.surface, color: T.text, font: "600 12px Alexandria" }} />
{rtl ? "رقم الجوال" : "Phone"} setEditPhone(e.target.value)} placeholder="+9665XXXXXXXX" style={{ padding: 11, borderRadius: 9, border: `1px solid ${T.hairlineStrong}`, background: T.surface, color: T.text, font: "600 12px Alexandria" }} />
{rtl ? "حفظ التعديلات" : "Save changes"} setEditing(false)} style={{ padding: "11px 16px", border: `1px solid ${T.hairlineStrong}`, borderRadius: 9, background: T.surface, color: T.text, font: "700 11px Alexandria" }}>{rtl ? "إلغاء" : "Cancel"}
}
{[
[rtl ? "الجوال" : "Phone", p.phone],
[rtl ? "البريد" : "Email", p.email || "—"],
[
rtl ? "السجل التجاري" : "Commercial registration",
p.commercial_registration || "—",
],
[
rtl ? "ترخيص النشاط" : "Tourism license",
p.tourism_license || "—",
],
[rtl ? "الحالة" : "Status", status(p)],
].map(([k, v]) => (
{k}
{v}
))}
{rtl ? "الوثائق المرفقة" : "Attached documents"}
review("approve")}
style={{
padding: "11px 18px",
border: 0,
borderRadius: 10,
background: "#238753",
color: "#fff",
font: "700 12px Alexandria",
}}
>
{rtl ? "اعتماد المزود" : "Approve provider"}
review("reject")}
style={{
padding: "11px 18px",
border: "1px solid #b42318",
borderRadius: 10,
background: "transparent",
color: "#b42318",
font: "700 12px Alexandria",
}}
>
{rtl ? "رفض" : "Reject"}
);
}
return (
<>
{message && (
{message}
)}
{providers.map((p) => (
{p.business_name}
{p.phone}
{rtl ? "الباقات" : "Packages"}: {p.package_count || 0} ·{" "}
{rtl ? "الحجوزات" : "Bookings"}: {p.booking_count || 0}
{status(p)}
view(p.id)}
style={{
alignSelf: "center",
padding: "9px 12px",
border: 0,
borderRadius: 9,
background: T.primary,
color: "#fff",
font: "700 10px Alexandria",
}}
>
{rtl ? "استعراض الملف" : "View profile"}
))}
>
);
}
function WebAdminDashboardPageV3({ theme, lang }) {
const T = theme,
rtl = isRTL(lang),
token = localStorage.getItem("duyuf_admin_session");
const [data, setData] = React.useState(null),
[tab, setTab] = React.useState("overview"),
[selected, setSelected] = React.useState(null),
[detailBusy, setDetailBusy] = React.useState(false),
[providerFilter, setProviderFilter] = React.useState("all"),
[travelFilter, setTravelFilter] = React.useState("all"),
[statusBusy, setStatusBusy] = React.useState(""),
[statusMessage, setStatusMessage] = React.useState(""),
[pushData, setPushData] = React.useState(null),
[pushBusy, setPushBusy] = React.useState(false),
[pushError, setPushError] = React.useState("");
const load = () =>
fetch(`${window.DUYUF_API_URL}/v1/admin/overview`, {
headers: { authorization: `Bearer ${token}` },
})
.then((r) => r.json())
.then((b) => {
if (b.ok) setData(b);
})
.catch(() => {});
React.useEffect(load, []);
const loadPushAnalytics = () => {
setPushBusy(true);
setPushError("");
fetch(`${window.DUYUF_API_URL}/v1/admin/pushwoosh-analytics`, {
headers: { authorization: `Bearer ${token}` },
})
.then(async (response) => {
const body = await response.json();
if (!response.ok || !body.ok) throw new Error(body.error || "request_failed");
setPushData(body);
})
.catch(() => setPushError(rtl ? "تعذر تحميل بيانات Pushwoosh." : "Could not load Pushwoosh analytics."))
.finally(() => setPushBusy(false));
};
React.useEffect(() => {
if (tab === "notifications" && !pushData && !pushBusy) loadPushAnalytics();
}, [tab]);
const m = data?.metrics || {},
statusLabel = (s) =>
({
active: rtl ? "نشطة" : "Active",
paused: rtl ? "موقوفة" : "Paused",
pending: rtl ? "بانتظار الاعتماد" : "Pending",
rejected: rtl ? "مرفوضة" : "Rejected",
})[s] || s;
const filteredPackages = (data?.packages || []).filter(
(item) =>
(providerFilter === "all" || item.provider_id === providerFilter) &&
(travelFilter === "all" || item.travel_mode === travelFilter || (travelFilter === "unspecified" && !item.travel_mode)),
);
const changePackageStatus = async (item, action) => {
setStatusBusy(item.id);
setStatusMessage("");
try {
const response = await fetch(
`${window.DUYUF_API_URL}/v1/admin/packages/${encodeURIComponent(item.id)}/status`,
{
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
body: JSON.stringify({ action }),
},
);
const body = await response.json();
if (!response.ok) throw new Error(body.error || "status_failed");
setData((current) => ({
...current,
packages: (current?.packages || []).map((pkg) =>
pkg.id === item.id ? { ...pkg, status: body.status } : pkg,
),
activity: [
{
id: `local-${Date.now()}`,
package_id: item.id,
title: item.title,
business_name: item.business_name,
action,
new_status: body.status,
created_at: Date.now(),
},
...(current?.activity || []),
],
}));
setStatusMessage(action === "pause" ? (rtl ? "تم إيقاف الباقة وتسجيل العملية." : "Package paused and logged.") : (rtl ? "تم تنشيط الباقة وتسجيل العملية." : "Package activated and logged."));
} catch (_) {
setStatusMessage(rtl ? "تعذر تغيير حالة الباقة." : "Could not change package status.");
} finally {
setStatusBusy("");
}
};
const metric = (label, value) => (
{Number(value || 0).toLocaleString()}
{label}
);
const view = async (id) => {
setDetailBusy(true);
const r = await fetch(
`${window.DUYUF_API_URL}/v1/admin/packages/${encodeURIComponent(id)}`,
{ headers: { authorization: `Bearer ${token}` } },
);
const b = await r.json();
setDetailBusy(false);
if (r.ok) setSelected(b);
};
const parse = (v) => {
try {
const value = JSON.parse(v || "[]");
return Array.isArray(value) ? value : [];
} catch (_) {
return [];
}
},
p = selected?.package;
const facts = p
? [
[rtl ? "المزود" : "Provider", `${p.business_name} · ${p.phone}`],
[rtl ? "الحالة" : "Status", statusLabel(p.status)],
[
rtl ? "نوع العمرة" : "Umrah type",
p.travel_mode === "bus"
? rtl
? "بالحافلة"
: "Bus"
: rtl
? "بالطائرة"
: "Flight",
],
[
rtl ? "المسار" : "Route",
`${p.origin_city || "—"} ← ${p.destination_city || "—"}`,
],
[
rtl ? "موعد الرحلة" : "Schedule",
`${p.departure_at || "—"} — ${p.return_at || "—"}`,
],
[
rtl ? "وسيلة النقل" : "Transport",
p.travel_mode === "bus"
? p.bus_type || "—"
: [p.airline, p.flight_number].filter(Boolean).join(" · ") || "—",
],
[
rtl ? "الفندق" : "Hotel",
`${p.hotel_name} · ${p.room_type || "—"} · ${p.meal_plan || "—"}`,
],
[
rtl ? "المدة والسعة" : "Duration & capacity",
`${p.duration_days} ${rtl ? "أيام" : "days"} · ${p.seats} ${rtl ? "شخص" : "people"}`,
],
[
rtl ? "الأسعار" : "Prices",
`${rtl ? "بالغ" : "Adult"} ${Number(p.adult_price || p.price_per_person).toLocaleString()} · ${rtl ? "طفل" : "Child"} ${Number(p.child_price || 0).toLocaleString()} · ${rtl ? "رضيع" : "Infant"} ${Number(p.infant_price || 0).toLocaleString()} ${rtl ? "ر.س" : "SAR"}`,
],
[
rtl ? "فترة الحجز" : "Booking window",
`${p.available_from} — ${p.available_to}`,
],
]
: [];
return (
{rtl ? "نظرة عامة على المنصة" : "Platform overview"}
{[
["overview", rtl ? "المؤشرات" : "Overview"],
["providers", rtl ? "المزودون" : "Providers"],
["packages", rtl ? "جميع الباقات" : "All packages"],
["pending", rtl ? "طلبات الاعتماد" : "Approvals"],
["activity", rtl ? "سجل العمليات" : "Activity log"],
["notifications", rtl ? "تحليلات الإشعارات" : "Push analytics"],
].map(([id, label]) => (
setTab(id)}
style={{
padding: "10px 14px",
border: `1px solid ${tab === id ? T.primary : T.hairlineStrong}`,
borderRadius: 10,
background: tab === id ? T.camelMist : T.surface,
color: tab === id ? T.primary : T.text,
font: "700 11px Alexandria",
}}
>
{label}
))}
{tab === "overview" && (
<>
{metric(rtl ? "المزودون" : "Providers", m.providers)}
{metric(
rtl ? "ملفات مكتملة" : "Complete profiles",
m.complete_profiles,
)}
{metric(
rtl ? "الباقات النشطة" : "Active packages",
m.active_packages,
)}
{metric(rtl ? "بانتظار الاعتماد" : "Pending", m.pending_packages)}
{metric(rtl ? "الحجوزات" : "Bookings", m.bookings)}
{metric(rtl ? "إجمالي المسافرين" : "Travelers", m.travelers)}
{rtl
? "اكتمال الملفات التجارية"
: "Business profile completion"}
{m.providers
? Math.round(
(Number(m.complete_profiles || 0) / Number(m.providers)) *
100,
)
: 0}
%
>
)}
{tab === "providers" && (
)}{" "}
{tab === "notifications" && (
PUSHWOOSH · WEB PUSH
{rtl ? "أداء إشعارات ضيوف" : "Duyuf push performance"}
{rtl ? "بيانات آخر 30 يومًا، مباشرة من Pushwoosh" : "Last 30 days, directly from Pushwoosh"}
{pushBusy ? (rtl ? "جارٍ التحديث…" : "Refreshing…") : (rtl ? "تحديث البيانات" : "Refresh")}
{pushError && {pushError}
}
{pushBusy && !pushData && {rtl ? "جارٍ جلب التحليلات…" : "Loading analytics…"}
}
{pushData && <>
{metric(rtl ? "إجمالي المشتركين" : "Subscribers", pushData.subscribers)}
{metric(rtl ? "الرسائل خلال 30 يومًا" : "Messages · 30d", pushData.messages?.count)}
{metric(rtl ? "تم الإرسال" : "Sent", pushData.messages?.sent)}
{metric(rtl ? "تم التسليم" : "Delivered", pushData.messages?.delivered)}
{metric(rtl ? "مرات الفتح" : "Opened", pushData.messages?.opened)}
{rtl ? "المشتركون حسب المنصة" : "Subscribers by platform"}
{Object.entries(pushData.platforms || {}).filter(([, value]) => Number(value) > 0).map(([platform, value]) => (
{Number(value).toLocaleString()}
{platform}
))}
{!Object.values(pushData.platforms || {}).some((value) => Number(value) > 0) &&
{rtl ? "لا توجد اشتراكات مسجلة بعد." : "No subscriptions recorded yet."} }
{rtl ? "آخر تحديث" : "Last updated"}: {new Date(pushData.updatedAt).toLocaleString(rtl ? "ar-SA" : "en-US")}
>}
)}
{tab === "packages" && p && (
{rtl ? "التفاصيل الكاملة للباقة" : "Full package details"}
{p.title}
setSelected(null)}
style={{
width: 40,
height: 40,
border: 0,
borderRadius: 99,
background: T.bg,
fontSize: 23,
}}
>
×
{facts.map(([k, v]) => (
{k}
{v}
))}
{rtl ? "الصور المرفقة" : "Attached images"}
{selected.images?.length ? (
selected.images.map((img) => (
))
) : (
{rtl ? "لا توجد صور مرفقة." : "No images attached."}
)}
{rtl ? "تشمل الباقة" : "Included"}
{parse(p.inclusions).length ? (
parse(p.inclusions).map((x) => {x} )
) : (
{rtl ? "غير محدد" : "Not specified"}
)}
{rtl ? "لا تشمل الباقة" : "Excluded"}
{parse(p.exclusions).length ? (
parse(p.exclusions).map((x) => {x} )
) : (
{rtl ? "غير محدد" : "Not specified"}
)}
{p.notes && (
{p.notes}
)}
)}
{tab === "packages" && (
{rtl ? "فلترة حسب المزود" : "Filter by provider"}
setProviderFilter(e.target.value)} style={{ minWidth: 190, padding: "10px 12px", borderRadius: 10, border: `1px solid ${T.hairlineStrong}`, background: T.bg, color: T.text, font: "600 11px Alexandria" }}>
{rtl ? `كل المزودين (${(data?.providers || []).length})` : `All providers (${(data?.providers || []).length})`}
{(data?.providers || []).map((provider) => {provider.business_name} )}
{rtl ? "فلترة حسب وسيلة السفر" : "Filter by travel mode"}
setTravelFilter(e.target.value)} style={{ minWidth: 190, padding: "10px 12px", borderRadius: 10, border: `1px solid ${T.hairlineStrong}`, background: T.bg, color: T.text, font: "600 11px Alexandria" }}>
{rtl ? "كل وسائل السفر" : "All travel modes"}
{rtl ? "بالحافلة / النقل" : "Bus / transport"}
{rtl ? "بالطيران" : "Flight"}
{rtl ? "غير محدد" : "Unspecified"}
{rtl ? `${filteredPackages.length} باقة` : `${filteredPackages.length} packages`}
{statusMessage &&
{statusMessage}
}
{filteredPackages.map((p) => (
view(p.id)}
role="button"
tabIndex="0"
style={{
padding: 16,
borderRadius: 14,
background: T.surface,
border: `1px solid ${T.hairline}`,
cursor: "pointer",
}}
>
{p.travel_mode === "bus" ? (rtl ? "🚌 بالحافلة / نقل" : "🚌 Bus / transport") : p.travel_mode === "flight" ? (rtl ? "✈️ بالطيران" : "✈️ Flight") : (rtl ? "وسيلة السفر غير محددة" : "Travel mode unspecified")}
{p.title}
{p.business_name} · {p.hotel_name}
{statusLabel(p.status)}
{rtl ? "الحجوزات" : "Bookings"}: {p.booking_count || 0} ·{" "}
{rtl ? "المحجوز بالأشخاص" : "Booked travelers"}:{" "}
{p.booked_travelers || 0}/{p.seats} ·{" "}
{rtl ? "تم الدخول" : "Checked in"}:{" "}
{p.checked_in_travelers || 0}
view(p.id)}
style={{
marginTop: 10,
padding: "9px 13px",
border: 0,
borderRadius: 9,
background: T.primary,
color: "#fff",
font: "700 11px Alexandria",
}}
>
{detailBusy
? rtl
? "جارٍ التحميل…"
: "Loading…"
: rtl
? "استعراض التفاصيل"
: "View details"}
{(p.status === "active" || p.status === "paused") && (
{ event.stopPropagation(); changePackageStatus(p, p.status === "active" ? "pause" : "activate"); }}
style={{ marginTop: 10, marginInlineStart: 8, padding: "9px 13px", border: `1px solid ${p.status === "active" ? "#a15c09" : "#237a4b"}`, borderRadius: 9, background: "transparent", color: p.status === "active" ? "#a15c09" : "#237a4b", font: "700 11px Alexandria" }}
>
{statusBusy === p.id ? (rtl ? "جارٍ الحفظ…" : "Saving…") : p.status === "active" ? (rtl ? "إيقاف الباقة" : "Pause package") : (rtl ? "تنشيط الباقة" : "Activate package")}
)}
))}
{!filteredPackages.length &&
{rtl ? "لا توجد باقات مطابقة للفلاتر." : "No packages match these filters."}
}
)}
{tab === "activity" && (
{(data?.activity || []).map((entry) => (
{entry.action === "update_provider" ? (rtl ? "تعديل بيانات مزود" : "Provider details updated") : entry.action === "pause" ? (rtl ? "إيقاف باقة" : "Package paused") : (rtl ? "تنشيط باقة" : "Package activated")} {entry.title ? `${entry.title} · ` : ""}{entry.business_name}
{entry.new_status && {statusLabel(entry.new_status)} }{new Date(Number(entry.created_at)).toLocaleString(rtl ? "ar-SA" : "en-US")} {entry.admin_phone && {entry.admin_phone} }
))}
{!(data?.activity || []).length &&
{rtl ? "لا توجد عمليات إيقاف أو تنشيط بعد." : "No pause or activation activity yet."}
}
)}
{tab === "pending" &&
}
);
}
WebAdminDashboardPage = WebAdminDashboardPageV3;
Object.assign(window, {
WebContext,
useWeb,
WebPageHeader,
WebBrowsePage,
WebDetailPage,
WebCheckoutPage,
WebPaymentResultPage,
WebProviderSignupPage,
WebContactPage,
WebSignInPage,
WebProviderMeetingPointsPage,
WebProviderBusinessProfilePage,
WebProviderDashboardPage,
WebProviderAddPackagePage,
WebProviderEditPackagePage,
WebProviderBookingsPage,
WebAdminLoginPage,
WebAdminDashboardPage,
});