/* ============================================================================ MS_ListingDetail — Airbnb-style listing-detail modal (trips + catalogue) Renders a normalized `listing` (L) object. By request: NO reviews / host sections (we don't fabricate named customer reviews on a live site). ========================================================================== */ (function () { const { useState, useEffect, useRef } = React; const T = (lang) => (en, no, fr, sv) => lang === 'no' ? no : lang === 'fr' ? fr : lang === 'sv' ? (sv || no || en) : lang === 'da' ? (no || en) : en; const locale = (lang) => lang === 'no' ? 'nb-NO' : lang === 'fr' ? 'fr-FR' : lang === 'da' ? 'da-DK' : lang === 'de' ? 'de-DE' : 'en-US'; const fmtDate = (d, loc) => d ? d.toLocaleDateString(loc, { day: 'numeric', month: 'short' }) : ''; /* ---- Photo mosaic (desktop) ---- */ function Mosaic({ images, alt, onShowAll, tx }) { const imgs = images.slice(0, 5); // use only the real images — never duplicate to pad const n = imgs.length; return (
{imgs.map((src, i) => (
onShowAll(i)} role="button" aria-label={alt} /> ))}
{images.length > 1 && ( )}
); } /* ---- Mobile swipe carousel ---- */ function MobileCarousel({ images, alt }) { const [i, setI] = useState(0); const n = images.length; const start = useRef(null); const go = (d) => setI(x => (x + d + n) % n); return (
{ start.current = e.touches[0].clientX; }} onTouchEnd={e => { const dx = e.changedTouches[0].clientX - (start.current || 0); if (Math.abs(dx) > 40) go(dx < 0 ? 1 : -1); start.current = null; }}>
{n > 1 &&
{i + 1} / {n}
} {n > 1 &&
{images.map((_, k) => )}
}
); } /* ---- Full-screen lightbox ---- */ function Lightbox({ images, alt, start, onClose }) { const [i, setI] = useState(start || 0); const n = images.length; useEffect(() => { const k = e => { if (e.key === 'Escape') onClose(); if (e.key === 'ArrowLeft') setI(x => (x - 1 + n) % n); if (e.key === 'ArrowRight') setI(x => (x + 1) % n); }; document.addEventListener('keydown', k); return () => document.removeEventListener('keydown', k); }, []); return (
{alt} e.stopPropagation()} />
{i + 1} / {n}
); } /* ---- 2-month availability calendar ---- */ function Calendar({ lang, sel, setSel }) { const today = new Date(); today.setHours(0, 0, 0, 0); const thisMonth = new Date(today.getFullYear(), today.getMonth(), 1); const [base, setBase] = useState(thisMonth); const loc = locale(lang); const dows = []; for (let i = 0; i < 7; i++) dows.push(new Date(2024, 0, 1 + i).toLocaleDateString(loc, { weekday: 'short' }).slice(0, 2)); const pick = (d) => { if (!sel.in || sel.out || d < sel.in) setSel({ in: d, out: null }); else setSel({ in: sel.in, out: d }); }; const month = (off) => { const m = new Date(base.getFullYear(), base.getMonth() + off, 1); const y = m.getFullYear(), mo = m.getMonth(); const fd = (new Date(y, mo, 1).getDay() + 6) % 7; const dim = new Date(y, mo + 1, 0).getDate(); const cells = []; for (let i = 0; i < fd; i++) cells.push(null); for (let d = 1; d <= dim; d++) cells.push(new Date(y, mo, d)); return (
{m.toLocaleDateString(loc, { month: 'long', year: 'numeric' })}
{dows.map((w, i) => {w})}
{cells.map((c, i) => { if (!c) return ; const past = c < today; const isIn = sel.in && c.getTime() === sel.in.getTime(); const isOut = sel.out && c.getTime() === sel.out.getTime(); const inRange = sel.in && sel.out && c > sel.in && c < sel.out; return ; })}
); }; return (
{month(0)}{month(1)}
); } /* ---- Geo gazetteer + route → stops resolver ---- */ const MS_GEO = { 'marrakech': [31.6295, -7.9811], 'agafay': [31.47, -8.16], "tizi n'tichka": [31.29, -7.37], 'tizi': [31.29, -7.37], 'ait ben haddou': [31.047, -7.13], 'aït ben haddou': [31.047, -7.13], 'ouarzazate': [30.92, -6.91], 'dades': [31.36, -5.99], 'dadès': [31.36, -5.99], 'todra': [31.52, -5.53], 'todgha': [31.52, -5.53], 'tinghir': [31.51, -5.53], 'merzouga': [31.10, -4.01], 'sahara': [31.10, -4.01], 'erg chebbi': [31.10, -4.01], 'tangier': [35.76, -5.83], 'tanger': [35.76, -5.83], 'chefchaouen': [35.17, -5.27], 'fes': [34.04, -4.99], 'fez': [34.04, -4.99], 'fès': [34.04, -4.99], 'agadir': [30.42, -9.60], 'essaouira': [31.51, -9.77], 'ourika': [31.36, -7.76], 'high atlas': [31.13, -7.92], 'atlas': [31.13, -7.92], 'volubilis': [34.07, -5.55], 'rissani': [31.28, -4.26], 'midelt': [32.68, -4.74], 'azrou': [33.43, -5.22], 'ifrane': [33.53, -5.11], 'taroudant': [30.47, -8.88], }; function resolveStops(route, single) { if (!route) return []; const frags = String(route).split(/→|->|—|·|>|\//).map(x => x.trim()).filter(Boolean); const out = []; frags.forEach(fr => { const low = fr.toLowerCase(); let best = null, key = null; for (const k in MS_GEO) { if (low.indexOf(k) > -1 && (!key || k.length > key.length)) { key = k; best = MS_GEO[k]; } } if (best) { const last = out[out.length - 1]; if (!last || last.lat !== best[0] || last.lng !== best[1]) out.push({ name: fr, lat: best[0], lng: best[1] }); } }); return single ? out.slice(0, 1) : out; } /* ---- Leaflet map of the itinerary stops (OpenStreetMap, no key) ---- */ function StopMap({ stops }) { const ref = useRef(null); const mapRef = useRef(null); useEffect(() => { if (!window.L || !ref.current || !stops.length) return; const map = window.L.map(ref.current, { scrollWheelZoom: false, zoomControl: true }); mapRef.current = map; window.L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18, attribution: '© OpenStreetMap' }).addTo(map); const pts = stops.map(s => [s.lat, s.lng]); if (pts.length > 1) window.L.polyline(pts, { color: '#e0432a', weight: 3, opacity: .85, dashArray: '7 7' }).addTo(map); stops.forEach((s, i) => { window.L.circleMarker([s.lat, s.lng], { radius: 8, color: '#fff', weight: 2, fillColor: '#e0432a', fillOpacity: 1 }) .addTo(map).bindTooltip((stops.length > 1 ? (i + 1) + '. ' : '') + s.name, { direction: 'top', offset: [0, -6] }); }); if (pts.length > 1) map.fitBounds(pts, { padding: [34, 34] }); else map.setView(pts[0], 11); setTimeout(() => map.invalidateSize(), 120); return () => { try { map.remove(); } catch (e) {} }; }, []); return
; } /* ---- Main listing-detail modal ---- */ function MS_ListingDetail(L) { const lang = L.lang || 'en'; const tx = T(lang); const loc = locale(lang); // De-duplicate the photo set (same URL never shown twice). const images = (L.images || []).filter(Boolean).filter((v, i, a) => a.indexOf(v) === i); const [lightbox, setLightbox] = useState(null); const [showAllAmen, setShowAllAmen] = useState(false); const [descOpen, setDescOpen] = useState(false); const [sel, setSel] = useState({ in: null, out: null }); const [guests, setGuests] = useState(2); const [saved, setSaved] = useState(false); const [form, setForm] = useState({ name: '', email: '', phone: '', notes: '' }); const [sent, setSent] = useState(false); const bookRef = useRef(null); useEffect(() => { const k = e => { if (e.key === 'Escape' && lightbox == null) L.onClose(); }; document.addEventListener('keydown', k); document.body.style.overflow = 'hidden'; return () => { document.removeEventListener('keydown', k); document.body.style.overflow = ''; }; }, [lightbox]); useEffect(() => { try { const w = JSON.parse(localStorage.getItem('ms_wishlist') || '[]'); setSaved(w.includes(L.id)); } catch {} }, []); useEffect(() => { try { const p = JSON.parse(localStorage.getItem('ms_profile_data') || '{}'); setForm(f => ({ name: p.name || '', email: p.email || '', phone: p.phone || '' })); } catch {} }, []); const share = async () => { const url = location.origin + location.pathname + '#' + (L.id || ''); try { if (navigator.share) await navigator.share({ title: L.title, url }); else { await navigator.clipboard.writeText(url); } } catch {} }; const toggleSave = () => { try { let w = JSON.parse(localStorage.getItem('ms_wishlist') || '[]'); if (w.includes(L.id)) w = w.filter(x => x !== L.id); else w.push(L.id); localStorage.setItem('ms_wishlist', JSON.stringify(w)); setSaved(w.includes(L.id)); } catch {} }; const amenities = L.amenities || []; const amenShown = showAllAmen ? amenities : amenities.slice(0, 8); const desc = L.description || ''; const longDesc = desc.length > 320; const reserve = () => { if (L.reserveForm) { if (!form.name.trim() || !form.email.trim()) { bookRef.current && bookRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }); return; } L.onReserve && L.onReserve({ sel, guests, ...form }); setSent(true); } else { L.onReserve && L.onReserve({ sel, guests }); } }; const bookingCard = (
{L.banner &&
{L.banner}
}
{L.price && L.price.from} {L.price && L.price.per && {L.price.per}}
{sent ? (
{tx('Request sent', 'Forespørsel sendt', 'Demande envoyée')} {tx('We’ll reply by email shortly.', 'Vi svarer på e-post snart.', 'Nous répondrons par e-mail sous peu.')}
) : ( <>
{tx('Check-in', 'Innsjekk', 'Arrivée')}{sel.in ? fmtDate(sel.in, loc) : tx('Add date', 'Legg til dato', 'Ajouter une date')}
{tx('Check-out', 'Utsjekk', 'Départ')}{sel.out ? fmtDate(sel.out, loc) : tx('Add date', 'Legg til dato', 'Ajouter une date')}
{tx('Guests', 'Gjester', 'Voyageurs')}
{L.reserveForm && (
setForm({ ...form, name: e.target.value })} placeholder={tx('Full name', 'Fullt navn', 'Nom complet')} autoComplete="name" /> setForm({ ...form, email: e.target.value })} placeholder={tx('Email', 'E-post', 'E-mail')} type="email" autoComplete="email" /> setForm({ ...form, phone: e.target.value })} placeholder={tx('Phone (optional)', 'Telefon (valgfritt)', 'Téléphone (optionnel)')} autoComplete="tel" />