/* ============================================================================
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 && (
onShowAll(0)}>
▦ {tx('Show all photos', 'Vis alle bilder', 'Voir toutes les photos')}
)}
);
}
/* ---- 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 (
✕
{ e.stopPropagation(); setI((i - 1 + n) % n); }} aria-label="Previous">‹
e.stopPropagation()} />
{ e.stopPropagation(); setI((i + 1) % n); }} aria-label="Next">›
{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 pick(c)}>{c.getDate()} ;
})}
);
};
return (
setBase(new Date(base.getFullYear(), base.getMonth() - 1, 1))} aria-label="Previous month">‹
setBase(new Date(base.getFullYear(), base.getMonth() + 1, 1))} aria-label="Next month">›
{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')}
setGuests(+e.target.value)}>
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(n => {n} {n === 1 ? tx('guest', 'gjest', 'voyageur') : 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" />
)}
{L.reserveLabel || tx('Reserve', 'Reserver', 'Réserver')}
{tx("You won’t be charged yet", 'Du belastes ikke ennå', 'Vous ne serez pas débité')}
{L.onTweak &&
{L.tweakLabel || tx('Customise this trip', 'Tilpass denne reisen', 'Personnaliser')} }
>
)}
);
return (
e.stopPropagation()}>
{/* sticky top bar */}
✕
↗ {tx('Share', 'Del', 'Partager')}
{saved ? '♥' : '♡'} {tx('Save', 'Lagre', 'Enregistrer')}
{L.title}
setLightbox(i)} />
{/* header block */}
{L.subtitle &&
{L.subtitle} }
{L.metaDots && L.metaDots.length > 0 &&
{L.metaDots.map((m, i) => {m} )}
}
{/* trust / highlight badge card */}
{L.trust && (
✦
{L.badge &&
{L.badge}
}
{L.trust}
)}
{/* listing highlights */}
{L.highlights && L.highlights.length > 0 && (
{L.highlightsTitle &&
{L.highlightsTitle} }
{L.highlights.map((h, i) => (
› {h}
))}
)}
{/* description */}
{desc && (
{desc}
{longDesc &&
setDescOpen(o => !o)}>{descOpen ? tx('Show less', 'Vis mindre', 'Réduire') : tx('Show more', 'Vis mer', 'Voir plus')} › }
)}
{/* amenities / what's included */}
{amenities.length > 0 && (
{L.amenitiesTitle || tx("What’s included", 'Dette er inkludert', 'Ce qui est inclus')}
{amenShown.map((a, i) => (
✓ {a}
))}
{amenities.length > 8 && (
setShowAllAmen(o => !o)}>
{showAllAmen ? tx('Show less', 'Vis mindre', 'Réduire') : tx('Show all ' + amenities.length, 'Vis alle ' + amenities.length, 'Tout afficher (' + amenities.length + ')')}
)}
{L.excluded && L.excluded.length > 0 && (
{tx('Not included', 'Ikke inkludert', 'Non inclus')}
{L.excluded.map((x, i) => (
— {x}
))}
)}
)}
{/* passes & offers (camps) */}
{L.passes && (L.passes.day || L.passes.evening || L.passes.spa) && (
{tx('Passes & offers', 'Pass & tilbud', 'Pass & offres')}
{L.passes.day && (
☀️ {tx('Day pass', 'Dagpass', 'Pass journée')}
{L.passes.day}
)}
{L.passes.evening && (
🌙 {tx('Evening pass', 'Kveldspass', 'Pass soirée')}
{L.passes.evening}
)}
{L.passes.spa && (
💆 {tx('Spa & wellness', 'Spa & velvære', 'Spa & bien-être')}
{L.passes.spa}
)}
)}
{/* rooms & tents (camps) */}
{L.rooms && L.rooms.length > 0 && (
{tx('Rooms & tents', 'Rom & telt', 'Chambres & tentes')} ({L.rooms.length})
{L.rooms.map((r, i) => (
{r.img && (
{ const k = images.indexOf(r.img); if (k >= 0) setLightbox(k); }} />
)}
{r.name}
{r.desc &&
{r.desc}
}
{r.price &&
{r.price}
}
))}
{tx('Room rates are indicative and vary by season — we confirm the exact price for your dates.', 'Romprisene er veiledende og varierer med sesong — vi bekrefter nøyaktig pris for dine datoer.', 'Tarifs indicatifs selon la saison — nous confirmons le prix exact pour vos dates.')}
)}
{/* day by day (trips) */}
{L.timeline && L.timeline.length > 0 && (
{tx('Day by day', 'Dag for dag', 'Jour par jour')}
{L.timeline.map((d, i) => (
{d.day}
{d.route}
{d.rows.map((r, ri) => (
{r.t || '•'} {r.a}
))}
))}
)}
{/* availability calendar */}
{tx('Availability', 'Tilgjengelighet', 'Disponibilités')}
{sel.in && sel.out ? fmtDate(sel.in, loc) + ' – ' + fmtDate(sel.out, loc) : tx('Choose your dates — we tailor every departure', 'Velg dine datoer — vi skreddersyr hver avreise', 'Choisissez vos dates — chaque départ est sur mesure')}
{/* map — itinerary stops on OpenStreetMap. Hidden entirely if it can't render. */}
{(() => {
const stops = L.mapRoute ? resolveStops(L.mapRoute) : (L.mapPlace ? resolveStops(L.mapPlace, true) : []);
if (!window.L || stops.length === 0) return null;
return (
{stops.length > 1 ? tx('Your route', 'Din rute', 'Votre itinéraire') : tx('Where you’ll be', 'Hvor du skal', 'Où vous serez')}
{L.locationLabel &&
{L.locationLabel}
}
{stops.length > 1 && (
{stops.map((s, i) => {i + 1} {s.name} )}
)}
);
})()}
{/* things to know */}
{L.thingsToKnow && (
{tx('Things to know', 'Verdt å vite', 'Bon à savoir')}
{['cancellation', 'rules', 'safety'].map(k => L.thingsToKnow[k] && (
{L.thingsToKnow[k].title}
{L.thingsToKnow[k].items.map((it, i) => {it} )}
{L.thingsToKnow[k].more &&
{tx('Learn more', 'Les mer', 'En savoir plus')} › }
))}
)}
{/* breadcrumb */}
{L.breadcrumb &&
{L.breadcrumb.join(' › ')}
}
{/* sticky booking card */}
{bookingCard}
{ window.open('https://wa.me/4745774743', '_blank', 'noopener'); }}>
⚑ {tx('Report this listing', 'Rapporter denne oppføringen', 'Signaler cette annonce')}
{/* mobile fixed bottom bar */}
{L.price && L.price.from} {L.price && L.price.per && {L.price.per} }
{ if (L.reserveForm) bookRef.current && bookRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }); else reserve(); }}>
{L.reserveLabel || tx('Reserve', 'Reserver', 'Réserver')}
{lightbox != null &&
setLightbox(null)} />}
);
}
window.MS_ListingDetail = MS_ListingDetail;
})();