BIO
Dettagli profilo
Tatuaggi: No
Lingue parlate: Inglese
Informazioni su
Switzerland
SarahDream SarahDream Über mich Galerie Videos Service Buchen Gästebuch Kontakt DE English Deutsch Anrufen Über mich Galerie Videos Service Buchen Gästebuch Kontakt Anrufen Willkommen bei Sarah Erleben Sie private Sessions und diskrete, unvergessliche Momente für Ihr Wohlbefinden und Entspannung. Termin buchen Galerie ansehen Diskret & privat Mein OnlyFans besuchen AND6 Profil XDate Profil 36 Jahre 158 cm 49 kg Über Sarah Ich heisse Sarah, ich bin 36 Jahre alt, 158 cm gross, wiege 49 kg, habe schöne braune Augen und lange schwarze Haare. Ich biete ein ruhiges, anspruchsvolles Erlebnis mit Aufmerksamkeit, Wärme und Respekt — ohne Hektik und ohne Vulgarität. Erwarten Sie elegante Begleitung, private Unterhaltung und entspannte Momente in einer sauberen, diskreten Umgebung. Ich freue mich auf seriöse, gebildete Herren, die Qualität und persönliche Beziehung schätzen. Mit mir ist jedes Treffen besonders. Qualität hat ihren Preis. Diskretion 100%. Echte Fotos. Echtes Vergnügen. Service und Preise Wähle das Erlebnis, das zu dir passt. Jeder Termin ist diskret, respektvoll und auf deine Wünsche abgestimmt. Mein Service Klassische private Begleitung Herzliche Gespräche, entspannende Berührung und pure Chemie GFE — Girlfriend Experience Intime, persönliche Aufmerksamkeit Zärtliche Momente & persönliche Aufmerksamkeit Nicht angeboten: Grenzüberschreitende oder unsichere Wünsche. Preise in EUR 30 Minuten€100 60 Minuten€200 90 Minuten€300 120 Minuten€400 Abend (4+ Stunden)€1000 So buchst du Wählen Sie die gewünschte Dauer Wählen Sie ein Datum und einen verfügbaren ZeitSlot Senden Sie die Anfrage oder rufen Sie mich direkt an Ich bestätige Ihren Termin sobald möglich Jetzt buchen Fotogalerie Ein Blick auf Sarah Natürliche Anmut Mehr Fotos ansehen Lebendige Momente Video-Momente Termin buchen Keine Registrierung nötig. Wählen Sie einen Termin und senden Sie Ihre Anfrage — ich bestätige so schnell wie möglich. Service / Dauer 30 Minuten €100 60 Minuten €200 90 Minuten €300 120 Minuten €400 Abend (4+ Stunden) €1000 Gewünschtes Datum Juli 2026 MoDiMiDoFrSaSo 12345678910111213141516171819202122232425262728293031 Verfügbar Ausgebucht Gewünschte Uhrzeit Wählen Sie oben ein Datum, um freie Zeiten zu sehen Ihr Name Telefonnummer E-Mail (für Bestätigung) Nachricht / Wünsche Bestätigen Sie, dass Sie ein Mensch sind: Terminanfrage senden Auf dem Handy öffnet sich die SMS-App, auf dem Desktop die E-Mail-App. Sie können mir auch auf Viber schreiben — keine unterdrückten Nummern. Gästebuch Hinterlassen Sie gerne ein paar nette Worte. The best girl you can find , anywhere!!!— Iloveher 2026-06-23 Einen Kommentar hinterlassen Ihr Name Ihr Kommentar Bestätigen Sie, dass Sie ein Mensch sind: Kommentar posten Vielen Dank! Ihr Kommentar wurde erhalten. Kontaktiere mich Kontakt Standort Venus 64, Frutigenstrasse 64 3604 Thun, Switzerland Gemütlicher, sauberer und diskreter Ort in der Schweiz — Hotelbesuche nach Wahl. Telefon +417 82 329 996 Viber Nachricht auf Viber WhatsApp Nachricht auf WhatsApp Öffnungszeiten Heute geöffnet10:00 – 00:00 Nachricht senden Name E-Mail * Nachricht Senden SarahDream Disrektes, exklusives Erlebnis in der Schweiz. Links Über mich Service Galerie Videos Buchen Kontakt Kontakt +417 82 329 996 Nachricht auf Viber Nachricht auf WhatsApp OnlyFans Profil AND6 Profil XDate Profil Copyright © 2026 SarahDream - All Rights Reserved. Anrufen Jetzt buchen × ❮ ❯ const translations = { en: { "site.title": "SarahDream - Exclusive Companionship & Discreet Moments", "site.description": "SarahDream offers exclusive companionship and private, discreet entertainment experiences in Switzerland. Book your appointment easily online or by phone.", "nav.about": "About", "nav.gallery": "Gallery", "nav.services": "Services", "nav.booking": "Book", "nav.contact": "Contact", "nav.video": "Videos", "nav.menu": "Menu", "top.call": "Call Now", "hero.subtitle": "Exclusive Experience", "hero.title": "Welcome to <em>Sarah</em>", "hero.trustBadge": "Discreet & Private", "alo.badge": "ALO.bg listings", "alo.hero": "Listed on", "alo.ad1": "Link 1", "alo.ad2": "Link 2", "alo.ad3": "Link 3", "onlyfans.hero": "Visit my OnlyFans", "onlyfans.footer": "OnlyFans Profile", "and6.hero": "AND6 Profile", "and6.footer": "AND6 Profile", "xdate.hero": "XDate Profile", "xdate.footer": "XDate Profile", "hero.desc": "Experience <strong class='highlight'>private sessions</strong> and <strong class='highlight'>discreet, memorable moments</strong> designed for your complete wellbeing and relaxation.", "hero.cta1": "Book an Appointment", "hero.cta2": "View Gallery", "stats.age": "Years", "stats.height": "cm", "stats.weight": "kg", "about.label": "About", "about.title": "About Sarah", "about.p1": "My name is Sarah, I am 36 years old, 158 cm tall, weigh 49 kg, with beautiful brown eyes and long black hair.", "about.p2": "I offer a calm, sophisticated experience with <strong class='highlight'>attention, warmth and respect</strong> — <strong class='highlight'>no rush and no vulgarity</strong>.", "about.p3": "You can expect <strong class='highlight'>elegant companionship, private entertainment, and relaxed moments</strong> in a <strong class='highlight'>clean, discreet setting</strong>.", "about.p4": "I welcome <strong class='highlight'>serious, educated gentlemen</strong> who value <strong class='highlight'>quality and personal connection</strong>.", "about.p5": "With me every meeting is special. <strong class='highlight'>Quality has a price. Discretion 100%. Real photos. Real pleasure.</strong>", "services.label": "Experience", "services.title": "Services & Prices", "services.intro": "Choose the experience that suits you. Every appointment is discreet, respectful and tailored to your desires.", "services.myService": "My Service", "services.item1": "Classic private companionship", "services.item2": "Warm conversation, relaxing touch and pure chemistry", "services.item3": "GFE — Girlfriend Experience", "services.item4": "Intimate private attention", "services.item5": "Affectionate moments & personalized attention", "services.prices": "Prices in EUR", "services.noteTitle": "Not offered:", "services.noteText": " <strong class='highlight'>Extreme or unsafe requests.</strong>", "services.howTitle": "How to Book", "services.how1": "Pick your preferred duration", "services.how2": "Select a date and available time slot", "services.how3": "Send the request or call me directly", "services.how4": "I will confirm your appointment shortly", "services.bookBtn": "Book Now", "price.30": "30 Minutes", "price.60": "60 Minutes", "price.90": "90 Minutes", "price.120": "120 Minutes", "price.evening": "Evening (4+ hours)", "gallery.label": "Portfolio", "gallery.title": "Photo Gallery", "gallery.featuredLabel": "Featured", "gallery.featuredTitle": "A Glimpse of Sarah", "gallery.filmstripLabel": "Candid", "gallery.filmstripTitle": "Living Moments", "gallery.candidTitle": "Natural Grace", "gallery.viewMore": "View More Photos", "gallery.viewLess": "Show Less", "booking.label": "Appointment", "booking.title": "Book Your Appointment", "booking.subtitle": "No registration required. Choose a time and send your request — I will confirm as soon as possible.", "booking.service": "Service / Duration", "booking.date": "Preferred Date", "booking.time": "Preferred Time", "booking.name": "Your Name", "booking.phone": "Phone Number", "booking.email": "Email (for confirmation)", "booking.message": "Message / Requests", "booking.messagePlaceholder": "Let me know if you have any special requests…", "booking.submit": "Send Appointment Request", "booking.note": "On mobile this opens your SMS app; on desktop your email app. You can also message me on <strong class='highlight'>Viber</strong> — <strong class='highlight'>no hidden numbers</strong>.", "booking.selectService": "Select a service…", "booking.opt30": "30 Minutes - €100", "booking.opt60": "60 Minutes - €200", "booking.opt90": "90 Minutes - €300", "booking.opt120": "120 Minutes - €400", "booking.optEvening": "Evening (4+ hours) - €1000", "booking.needTime": "Please select a preferred time.", "booking.needDate": "Please choose a date first.", "booking.summaryLabel": "Your appointment request:", "booking.captchaQuestion": "Verify you are human:", "booking.captchaError": "Incorrect captcha code. Please try again.", "booking.successTitle": "Appointment Request Sent!", "booking.successDesc": "Thank you, {name}. Your request has been sent to Sarah. I will review it and confirm your appointment shortly.", "booking.serviceLabel": "Service", "booking.dateLabel": "Date", "booking.timeLabel": "Time", "booking.bookAnother": "Book Another Appointment", "cta.bookNow": "Book Now", "nav.reviews": "Guestbook", "reviews.label": "Kind Words", "reviews.title": "Guestbook", "reviews.subtitle": "Feel free to leave a few good words.", "reviews.formTitle": "Leave a Comment", "reviews.name": "Your Name", "reviews.rating": "", "reviews.message": "Your Comment", "reviews.messagePlaceholder": "Write a few good words…", "reviews.submit": "Post Comment", "reviews.thanks": "Thank you! Your comment has been received.", "booking.pickDateFirst": "Select a date above to see available times", "booking.availableTimes": "Available times", "booking.noTimes": "No available times for this date.", "booking.slotTaken": "Sorry — that time was just booked. Please choose another.", "booking.sending": "Sending…", "booking.legendOpen": "Available", "booking.legendFull": "Fully booked", "booking.emailConfirmSent": "A confirmation email is on its way to you.", "booking.autoReply": "Hi {name},\n\nThank you for your appointment request at SarahDream. I have received your booking for {service} on {date} at {time}, and I will personally confirm it very soon.\n\nIf you need to change anything, simply reply to this email.\n\nWarmly,\nSarah", "cal.months": ["January","February","March","April","May","June","July","August","September","October","November","December"], "cal.monthsShort": ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"], "cal.weekdays": ["Mon","Tue","Wed","Thu","Fri","Sat","Sun"], "contact.label": "Connection", "contact.title": "Contact Me", "contact.detailsTitle": "Get in Touch", "contact.messageTitle": "Send a Message", "contact.intro": "If you have questions or would like to arrange an appointment, you can contact me as follows:", "contact.locationTitle": "Location", "contact.tempLocation": "Private location in <strong class='highlight'>Switzerland</strong> — <strong class='highlight'>hotel visits</strong> available on request.", "contact.phoneTitle": "Phone", "contact.viberTitle": "Viber", "contact.viberAction": "Message on Viber", "contact.whatsappTitle": "WhatsApp", "contact.whatsappAction": "Message on WhatsApp", "contact.hoursTitle": "Opening Hours", "hours.today": "Open today", "form.name": "Name", "form.email": "Email *", "form.message": "Message", "form.send": "Send", "footer.impressum": "Impressum / Contact", "footer.tagline": "Discreet, exclusive experiences in Switzerland.", "footer.linksTitle": "Quick Links", "footer.contactTitle": "Contact", "videos.label": "Video", "videos.title": "Video Moments", }, de: { "site.title": "SarahDream - Exklusive Begleitung & diskrete Momente", "site.description": "SarahDream bietet exklusive Begleitung und private, diskrete Erlebnisse in der Schweiz. Buchen Sie Ihren Termin einfach online oder telefonisch.", "nav.about": "Über mich", "nav.gallery": "Galerie", "nav.services": "Service", "nav.booking": "Buchen", "nav.contact": "Kontakt", "nav.video": "Videos", "nav.menu": "Menü", "top.call": "Anrufen", "hero.subtitle": "Exklusives Erlebnis", "hero.title": "Willkommen bei <em>Sarah</em>", "hero.trustBadge": "Diskret & privat", "alo.badge": "ALO.bg Anzeigen", "alo.hero": "Inserat auf", "alo.ad1": "Link 1", "alo.ad2": "Link 2", "alo.ad3": "Link 3", "onlyfans.hero": "Mein OnlyFans besuchen", "onlyfans.footer": "OnlyFans Profil", "and6.hero": "AND6 Profil", "and6.footer": "AND6 Profil", "xdate.hero": "XDate Profil", "xdate.footer": "XDate Profil", "hero.desc": "Erleben Sie <strong class='highlight'>private Sessions</strong> und <strong class='highlight'>diskrete, unvergessliche Momente</strong> für Ihr Wohlbefinden und Entspannung.", "hero.cta1": "Termin buchen", "hero.cta2": "Galerie ansehen", "stats.age": "Jahre", "stats.height": "cm", "stats.weight": "kg", "about.label": "Über mich", "about.title": "Über Sarah", "about.p1": "Ich heisse Sarah, ich bin 36 Jahre alt, 158 cm gross, wiege 49 kg, habe schöne braune Augen und lange schwarze Haare.", "about.p2": "Ich biete ein ruhiges, anspruchsvolles Erlebnis mit <strong class='highlight'>Aufmerksamkeit, Wärme und Respekt</strong> — <strong class='highlight'>ohne Hektik und ohne Vulgarität</strong>.", "about.p3": "Erwarten Sie <strong class='highlight'>elegante Begleitung, private Unterhaltung und entspannte Momente</strong> in einer <strong class='highlight'>sauberen, diskreten Umgebung</strong>.", "about.p4": "Ich freue mich auf <strong class='highlight'>seriöse, gebildete Herren</strong>, die <strong class='highlight'>Qualität und persönliche Beziehung</strong> schätzen.", "about.p5": "Mit mir ist jedes Treffen besonders. <strong class='highlight'>Qualität hat ihren Preis. Diskretion 100%. Echte Fotos. Echtes Vergnügen.</strong>", "services.label": "Erlebnis", "services.title": "Service und Preise", "services.intro": "Wähle das Erlebnis, das zu dir passt. Jeder Termin ist diskret, respektvoll und auf deine Wünsche abgestimmt.", "services.myService": "Mein Service", "services.item1": "Klassische private Begleitung", "services.item2": "Herzliche Gespräche, entspannende Berührung und pure Chemie", "services.item3": "GFE — Girlfriend Experience", "services.item4": "Intime, persönliche Aufmerksamkeit", "services.item5": "Zärtliche Momente & persönliche Aufmerksamkeit", "services.prices": "Preise in EUR", "services.noteTitle": "Nicht angeboten:", "services.noteText": " <strong class='highlight'>Grenzüberschreitende oder unsichere Wünsche.</strong>", "services.howTitle": "So buchst du", "services.how1": "Wählen Sie die gewünschte Dauer", "services.how2": "Wählen Sie ein Datum und einen verfügbaren ZeitSlot", "services.how3": "Senden Sie die Anfrage oder rufen Sie mich direkt an", "services.how4": "Ich bestätige Ihren Termin sobald möglich", "services.bookBtn": "Jetzt buchen", "price.30": "30 Minuten", "price.60": "60 Minuten", "price.90": "90 Minuten", "price.120": "120 Minuten", "price.evening": "Abend (4+ Stunden)", "gallery.label": "Portfolio", "gallery.title": "Fotogalerie", "gallery.featuredLabel": "Ausgewählt", "gallery.featuredTitle": "Ein Blick auf Sarah", "gallery.filmstripLabel": "Momente", "gallery.filmstripTitle": "Lebendige Momente", "gallery.candidTitle": "Natürliche Anmut", "gallery.viewMore": "Mehr Fotos ansehen", "gallery.viewLess": "Weniger anzeigen", "booking.label": "Termin", "booking.title": "Termin buchen", "booking.subtitle": "Keine Registrierung nötig. Wählen Sie einen Termin und senden Sie Ihre Anfrage — ich bestätige so schnell wie möglich.", "booking.service": "Service / Dauer", "booking.date": "Gewünschtes Datum", "booking.time": "Gewünschte Uhrzeit", "booking.name": "Ihr Name", "booking.phone": "Telefonnummer", "booking.email": "E-Mail (für Bestätigung)", "booking.message": "Nachricht / Wünsche", "booking.messagePlaceholder": "Lassen Sie mich wissen, ob Sie besondere Wünsche haben…", "booking.submit": "Terminanfrage senden", "booking.note": "Auf dem Handy öffnet sich die SMS-App, auf dem Desktop die E-Mail-App. Sie können mir auch auf <strong class='highlight'>Viber</strong> schreiben — <strong class='highlight'>keine unterdrückten Nummern</strong>.", "booking.selectService": "Service auswählen…", "booking.opt30": "30 Minuten - €100", "booking.opt60": "60 Minuten - €200", "booking.opt90": "90 Minuten - €300", "booking.opt120": "120 Minuten - €400", "booking.optEvening": "Abend (4+ Stunden) - €1000", "booking.needTime": "Bitte wählen Sie eine gewünschte Uhrzeit.", "booking.needDate": "Bitte wählen Sie zuerst ein Datum.", "booking.summaryLabel": "Ihre Terminanfrage:", "booking.captchaQuestion": "Bestätigen Sie, dass Sie ein Mensch sind:", "booking.captchaError": "Falscher Captcha-Code. Bitte versuchen Sie es erneut.", "booking.successTitle": "Terminanfrage gesendet!", "booking.successDesc": "Vielen Dank, {name}. Ihre Anfrage wurde erfolgreich gesendet. Ich werde sie prüfen und Ihren Termin in Kürze bestätigen.", "booking.serviceLabel": "Service", "booking.dateLabel": "Datum", "booking.timeLabel": "Uhrzeit", "booking.bookAnother": "Anderen Termin buchen", "cta.bookNow": "Jetzt buchen", "nav.reviews": "Gästebuch", "reviews.label": "Nette Worte", "reviews.title": "Gästebuch", "reviews.subtitle": "Hinterlassen Sie gerne ein paar nette Worte.", "reviews.formTitle": "Einen Kommentar hinterlassen", "reviews.name": "Ihr Name", "reviews.rating": "", "reviews.message": "Ihr Kommentar", "reviews.messagePlaceholder": "Schreiben Sie ein paar nette Worte…", "reviews.submit": "Kommentar posten", "reviews.thanks": "Vielen Dank! Ihr Kommentar wurde erhalten.", "booking.pickDateFirst": "Wählen Sie oben ein Datum, um freie Zeiten zu sehen", "booking.availableTimes": "Verfügbare Zeiten", "booking.noTimes": "Keine freien Zeiten an diesem Datum.", "booking.slotTaken": "Leider wurde diese Zeit gerade gebucht. Bitte wählen Sie eine andere.", "booking.sending": "Wird gesendet…", "booking.legendOpen": "Verfügbar", "booking.legendFull": "Ausgebucht", "booking.emailConfirmSent": "Eine Bestätigungs-E-Mail ist unterwegs zu Ihnen.", "booking.autoReply": "Hallo {name},\n\nvielen Dank für Ihre Terminanfrage bei SarahDream. Ich habe Ihre Buchung für {service} am {date} um {time} erhalten und werde sie sehr bald persönlich bestätigen.\n\nMöchten Sie etwas ändern, antworten Sie einfach auf diese E-Mail.\n\nHerzlich,\nSarah", "cal.months": ["Januar","Februar","März","April","Mai","Juni","Juli","August","September","Oktober","November","Dezember"], "cal.monthsShort": ["Jan","Feb","Mär","Apr","Mai","Jun","Jul","Aug","Sep","Okt","Nov","Dez"], "cal.weekdays": ["Mo","Di","Mi","Do","Fr","Sa","So"], "contact.label": "Verbindung", "contact.title": "Kontaktiere mich", "contact.detailsTitle": "Kontakt", "contact.messageTitle": "Nachricht senden", "contact.intro": "Haben Sie Fragen oder möchten Sie einen Termin vereinbaren? Kontaktieren Sie mich wie folgt:", "contact.locationTitle": "Standort", "contact.tempLocation": "Gemütlicher, sauberer und diskreter Ort in <strong class='highlight'>der Schweiz</strong> — <strong class='highlight'>Hotelbesuche</strong> nach Wahl.", "contact.phoneTitle": "Telefon", "contact.viberTitle": "Viber", "contact.viberAction": "Nachricht auf Viber", "contact.whatsappTitle": "WhatsApp", "contact.whatsappAction": "Nachricht auf WhatsApp", "contact.hoursTitle": "Öffnungszeiten", "hours.today": "Heute geöffnet", "form.name": "Name", "form.email": "E-Mail *", "form.message": "Nachricht", "form.send": "Senden", "footer.impressum": "Impressum / Kontakt", "footer.tagline": "Disrektes, exklusives Erlebnis in der Schweiz.", "footer.linksTitle": "Links", "footer.contactTitle": "Kontakt", "videos.label": "Video", "videos.title": "Video-Momente", } }; let currentLang = 'de'; function setLang(lang) { currentLang = lang; document.querySelectorAll('.lang-dropdown-item').forEach(btn => { const active = btn.dataset.lang === lang; btn.classList.toggle('active', active); btn.setAttribute('aria-pressed', String(active)); }); const activeItem = document.querySelector(`.lang-dropdown-item[data-lang="${lang}"]`); if (activeItem) { document.getElementById('activeLangFlag').innerHTML = activeItem.querySelector('svg').outerHTML; document.getElementById('activeLangText').textContent = lang.toUpperCase(); } document.querySelectorAll('[data-i18n]').forEach(el => { const key = el.dataset.i18n; if (translations[lang][key] === undefined) return; if (el.tagName === 'TEXTAREA' && el.hasAttribute('placeholder')) { el.setAttribute('placeholder', translations[lang][key]); } else if (el.hasAttribute('data-i18n-html')) { el.innerHTML = translations[lang][key]; } else { el.textContent = translations[lang][key]; } }); document.documentElement.lang = lang === 'bg' ? 'bg' : lang; // Dynamically update site title and description for SEO if (translations[lang]["site.title"]) { document.title = translations[lang]["site.title"]; const metaDesc = document.querySelector('meta[name="description"]'); if (metaDesc) metaDesc.setAttribute('content', translations[lang]["site.description"]); // Open Graph const ogTitle = document.querySelector('meta[property="og:title"]'); if (ogTitle) ogTitle.setAttribute('content', translations[lang]["site.title"]); const ogDesc = document.querySelector('meta[property="og:description"]'); if (ogDesc) ogDesc.setAttribute('content', translations[lang]["site.description"]); // Twitter const twTitle = document.querySelector('meta[property="twitter:title"]'); if (twTitle) twTitle.setAttribute('content', translations[lang]["site.title"]); const twDesc = document.querySelector('meta[property="twitter:description"]'); if (twDesc) twDesc.setAttribute('content', translations[lang]["site.description"]); } // Re-localise the booking calendar (months, weekdays, time labels) if (window.refreshBookingLocale) window.refreshBookingLocale(); } // Language Dropdown Event Handlers const langDropdown = document.getElementById('langDropdown'); const langDropdownBtn = document.getElementById('langDropdownBtn'); langDropdownBtn.addEventListener('click', (e) => { e.stopPropagation(); const isOpen = langDropdown.classList.contains('open'); langDropdown.classList.toggle('open', !isOpen); langDropdownBtn.setAttribute('aria-expanded', String(!isOpen)); }); document.addEventListener('click', (e) => { if (!langDropdown.contains(e.target)) { langDropdown.classList.remove('open'); langDropdownBtn.setAttribute('aria-expanded', 'false'); } }); function changeLang(lang) { setLang(lang); langDropdown.classList.remove('open'); langDropdownBtn.setAttribute('aria-expanded', 'false'); } function setNav(open) { const menu = document.getElementById('mobileMenu'); const overlay = document.getElementById('mobileOverlay'); const btn = document.getElementById('hamburger'); if (open) { menu.style.display = 'flex'; menu.offsetHeight; // force reflow for transition menu.classList.add('open'); } else { menu.classList.remove('open'); setTimeout(() => { if (!menu.classList.contains('open')) menu.style.display = 'none'; }, 350); } overlay.classList.toggle('open', open); btn.classList.toggle('active', open); btn.setAttribute('aria-expanded', String(open)); menu.setAttribute('aria-hidden', String(!open)); overlay.setAttribute('aria-hidden', String(!open)); document.body.style.overflow = open ? 'hidden' : ''; } function toggleNav() { setNav(!document.getElementById('mobileMenu').classList.contains('open')); } function closeNav() { setNav(false); } // Close mobile menu when switching to desktop window.addEventListener('resize', () => { if (window.innerWidth >= 768) closeNav(); }); // Close on Escape document.addEventListener('keydown', e => { if (e.key === 'Escape' && document.getElementById('mobileMenu').classList.contains('open')) closeNav(); }); // Gallery & Lightbox Logic const galleryImages = [ 'photo_2_2026-06-08_16-40-33.jpg', 'photo_4_2026-06-08_16-40-33.jpg', 'photo_5_2026-06-08_16-40-33.jpg', 'photo_6_2026-06-08_16-40-33.jpg', 'photo_7_2026-06-08_16-40-33.jpg', 'photo_8_2026-06-08_16-40-33.jpg', 'photo_9_2026-06-08_16-40-33.jpg', 'photo_10_2026-06-08_16-40-33.jpg', 'photo_11_2026-06-08_16-40-33.jpg', 'photo_12_2026-06-08_16-40-33.jpg' ]; // Gallery Grid View Toggle function toggleGalleryView() { const hiddenItems = document.querySelectorAll('.gallery-card.gallery-item-hidden'); const btn = document.getElementById('viewMoreBtn'); const textEl = document.getElementById('viewMoreText'); const isShowing = btn.classList.contains('active'); if (!isShowing) { hiddenItems.forEach(item => { item.classList.remove('gallery-item-hidden'); item.classList.add('gallery-item-show'); }); btn.classList.add('active'); textEl.setAttribute('data-i18n', 'gallery.viewLess'); } else { const visibleItems = document.querySelectorAll('.gallery-card.gallery-item-show'); visibleItems.forEach(item => { item.classList.add('gallery-item-hidden'); item.classList.remove('gallery-item-show'); }); btn.classList.remove('active'); textEl.setAttribute('data-i18n', 'gallery.viewMore'); // Scroll to gallery top smoothly document.getElementById('gallery').scrollIntoView({ behavior: 'smooth' }); } setLang(currentLang); } // Lightbox let lbIndex = 0; const lbImg = document.getElementById('lightbox-img'); const lbContainer = document.getElementById('lightbox'); function openLightbox(index) { lbIndex = index; updateLightbox(); lbContainer.classList.add('active'); document.body.style.overflow = 'hidden'; } function updateLightbox() { lbImg.style.opacity = '0'; setTimeout(() => { lbImg.src = galleryImages[lbIndex]; lbImg.style.opacity = '1'; }, 150); } function closeLightbox() { lbContainer.classList.remove('active'); document.body.style.overflow = ''; } function lightboxPrev() { lbIndex = (lbIndex - 1 + galleryImages.length) % galleryImages.length; updateLightbox(); } function lightboxNext() { lbIndex = (lbIndex + 1) % galleryImages.length; updateLightbox(); } // Close lightbox on background click lbContainer.addEventListener('click', (e) => { if (e.target === lbContainer || e.target.classList.contains('lightbox-inner')) { closeLightbox(); } }); document.addEventListener('keydown', e => { if (!lbContainer.classList.contains('active')) return; if (e.key === 'Escape') closeLightbox(); if (e.key === 'ArrowLeft') lightboxPrev(); if (e.key === 'ArrowRight') lightboxNext(); }); let lbTouchStartX = 0; lbContainer.addEventListener('touchstart', e => { lbTouchStartX = e.changedTouches[0].screenX; }, {passive: true}); lbContainer.addEventListener('touchend', e => { const diff = e.changedTouches[0].screenX - lbTouchStartX; if (diff > 50) lightboxPrev(); else if (diff < -50) lightboxNext(); }, {passive: true}); // ===== Booking availability + advanced calendar engine ===== // Per-device memory of slots this visitor booked (instant + offline-safe). let localBooked = JSON.parse(localStorage.getItem('bookedSlots') || '{}'); // Shared availability fetched from /api/slots (everyone sees the same). let serverBooked = {}; // Merged view used for rendering (server + this device). let bookedMap = {}; const dateInput = document.getElementById('date'); const timeInput = document.getElementById('time'); const serviceInput = document.getElementById('service'); // Whole-hour slots Sarah can be booked for. const BUSINESS_HOURS = ['10:00','11:00','12:00','13:00','14:00','15:00','16:00','17:00','18:00','19:00','20:00','21:00','22:00','23:00','00:00']; // Manual per-date availability overrides — set the exact open hours for a // given day; [] means the day is fully closed. Edit freely as Sarah's // schedule changes. (Launch day 2026-06-20 is open only 19:00–21:00.) const AVAILABILITY_OVERRIDES = { // Per-date OPEN-hours override. [] = fully closed. Omit a date for full hours. }; // Hours Sarah has already taken — shown struck-through on every device, // even before the shared store is live. Add "YYYY-MM-DD": ["HH:00", ...]. const MANUAL_BOOKED = { '2026-06-20': ['19:00','20:00','21:00'] }; const LEAD_MINUTES = 0; // minimum lead before a same-day slot const pad2 = n => String(n).padStart(2, '0'); const ymd = d => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; const startOfDay = d => { const x = new Date(d); x.setHours(0, 0, 0, 0); return x; }; const slotMinutes = tStr => { let [h, m] = tStr.split(':').map(Number); if (h === 0) h = 24; return h * 60 + m; }; const todayDate = startOfDay(new Date()); function mergeBooked() { bookedMap = {}; [MANUAL_BOOKED, serverBooked, localBooked].forEach(src => { Object.keys(src || {}).forEach(d => { (src[d] || []).forEach(tm => { bookedMap[d] = bookedMap[d] || []; if (!bookedMap[d].includes(tm)) bookedMap[d].push(tm); }); }); }); } function baseHoursForDate(dateStr) { if (Object.prototype.hasOwnProperty.call(AVAILABILITY_OVERRIDES, dateStr)) { return AVAILABILITY_OVERRIDES[dateStr].slice(); } return BUSINESS_HOURS.slice(); } // Hours still bookable = base hours − already booked − past (if today). function availableHoursForDate(dateStr) { let hours = baseHoursForDate(dateStr).filter(tm => !(bookedMap[dateStr] || []).includes(tm)); if (dateStr === ymd(new Date())) { const now = new Date(); const nowMin = now.getHours() * 60 + now.getMinutes() + LEAD_MINUTES; hours = hours.filter(tm => slotMinutes(tm) > nowMin); } return hours; } function dayState(dateStr, dateObj) { if (dateObj < todayDate) return 'past'; if (baseHoursForDate(dateStr).length === 0) return 'closed'; return availableHoursForDate(dateStr).length > 0 ? 'open' : 'full'; } // ---- Calendar rendering ---- const calGrid = document.getElementById('calGrid'); const calWeekdays = document.getElementById('calWeekdays'); const calTitle = document.getElementById('calTitle'); const calPrev = document.getElementById('calPrev'); const calNext = document.getElementById('calNext'); let viewYear = todayDate.getFullYear(); let viewMonth = todayDate.getMonth(); let selectedDate = ''; function tr(key) { return (translations[currentLang] && translations[currentLang][key]) || translations.en[key]; } function renderWeekdays() { const wd = tr('cal.weekdays') || ['Mon','Tue','Wed','Thu','Fri','Sat','Sun']; calWeekdays.innerHTML = wd.map(d => `<span>${d}</span>`).join(''); } function renderCalendar() { const months = tr('cal.months'); calTitle.textContent = `${months[viewMonth]} ${viewYear}`; const first = new Date(viewYear, viewMonth, 1); const lead = (first.getDay() + 6) % 7; // Monday-first const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate(); const todayStr = ymd(new Date()); let html = ''; for (let i = 0; i < lead; i++) html += '<div class="cal-day is-empty"></div>'; for (let day = 1; day <= daysInMonth; day++) { const dObj = new Date(viewYear, viewMonth, day); const dStr = ymd(dObj); const state = dayState(dStr, dObj); const classes = ['cal-day']; let disabled = false, dot = ''; if (dStr === todayStr) classes.push('is-today'); if (dStr === selectedDate) classes.push('is-selected'); if (state === 'open') { dot = '<i class="cal-dot"></i>'; } else if (state === 'full') { classes.push('is-full'); disabled = true; dot = '<i class="cal-dot"></i>'; } else { disabled = true; } // past / far / closed html += `<button type="button" class="${classes.join(' ')}" data-date="${dStr}" role="gridcell"${disabled ? ' disabled' : ''}>${day}${dot}</button>`; } calGrid.innerHTML = html; const viewFirst = new Date(viewYear, viewMonth, 1); const curFirst = new Date(todayDate.getFullYear(), todayDate.getMonth(), 1); calPrev.disabled = viewFirst <= curFirst; calNext.disabled = false; // endless calendar } calGrid.addEventListener('click', e => { const btn = e.target.closest('.cal-day'); if (!btn || btn.disabled || btn.classList.contains('is-empty')) return; selectedDate = btn.dataset.date; dateInput.value = selectedDate; timeInput.value = ''; renderCalendar(); renderTimeGrid(); updateSummary(); document.getElementById('timePanel').scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }); calPrev.addEventListener('click', () => { if (calPrev.disabled) return; viewMonth--; if (viewMonth < 0) { viewMonth = 11; viewYear--; } renderCalendar(); }); calNext.addEventListener('click', () => { if (calNext.disabled) return; viewMonth++; if (viewMonth > 11) { viewMonth = 0; viewYear++; } renderCalendar(); }); // ---- Time grid rendering ---- const timeGrid = document.getElementById('timeGrid'); const timeMsg = document.getElementById('timeMsg'); const timeHead = document.getElementById('timeHead'); function formatLongDate(dateStr) { const [y, m, d] = dateStr.split('-').map(Number); const dObj = new Date(y, m - 1, d); const wd = (tr('cal.weekdays') || [])[(dObj.getDay() + 6) % 7] || ''; const mo = (tr('cal.monthsShort') || [])[m - 1] || ''; return `${wd}, ${d} ${mo}`; } function renderTimeGrid() { if (!selectedDate) { timeMsg.hidden = false; timeMsg.setAttribute('data-i18n', 'booking.pickDateFirst'); timeMsg.textContent = tr('booking.pickDateFirst'); timeHead.hidden = true; timeGrid.hidden = true; timeGrid.innerHTML = ''; return; } let base = baseHoursForDate(selectedDate); const avail = availableHoursForDate(selectedDate); if (selectedDate === ymd(new Date())) { const nowMin = new Date().getHours() * 60 + new Date().getMinutes() + LEAD_MINUTES; base = base.filter(h => slotMinutes(h) > nowMin); // hide past hours today } timeHead.hidden = false; timeHead.textContent = `${tr('booking.availableTimes')} · ${formatLongDate(selectedDate)}`; if (base.length === 0 || avail.length === 0) { timeGrid.hidden = true; timeGrid.innerHTML = ''; timeMsg.hidden = false; timeMsg.removeAttribute('data-i18n'); timeMsg.textContent = tr('booking.noTimes'); return; } timeMsg.hidden = true; timeGrid.hidden = false; timeGrid.innerHTML = base.map(h => { const taken = !avail.includes(h); const sel = h === timeInput.value ? ' selected' : ''; return `<button type="button" class="time-slot${sel}" data-time="${h}"${taken ? ' disabled' : ''}>${h}</button>`; }).join(''); } timeGrid.addEventListener('click', e => { const btn = e.target.closest('.time-slot'); if (!btn || btn.disabled) return; timeGrid.querySelectorAll('.time-slot').forEach(s => s.classList.remove('selected')); btn.classList.add('selected'); timeInput.value = btn.dataset.time; updateSummary(); }); // ---- Shared availability fetch + light polling ---- async function refreshAvailability() { try { const r = await fetch('/api/slots', { headers: { 'Accept': 'application/json' } }); if (!r.ok) return; const data = await r.json(); serverBooked = data.slots || {}; mergeBooked(); if (selectedDate && timeInput.value && !availableHoursForDate(selectedDate).includes(timeInput.value)) { timeInput.value = ''; updateSummary(); } renderCalendar(); if (selectedDate) renderTimeGrid(); } catch (_) { /* offline / KV not configured — keep local view */ } } function isSlotBooked(dateStr, time) { return (bookedMap[dateStr] || []).includes(time); } async function recordBooking(dateStr, time) { if (!localBooked[dateStr]) localBooked[dateStr] = []; if (!localBooked[dateStr].includes(time)) localBooked[dateStr].push(time); localStorage.setItem('bookedSlots', JSON.stringify(localBooked)); mergeBooked(); renderCalendar(); if (selectedDate) renderTimeGrid(); try { await fetch('/api/slots', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ date: dateStr, time }) }); } catch (_) { /* shared store offline — local + email still done */ } } // Re-localise the calendar when language changes (hooked from setLang). window.refreshBookingLocale = function () { renderWeekdays(); renderCalendar(); renderTimeGrid(); }; mergeBooked(); renderWeekdays(); renderCalendar(); // Fetch shared availability now, then poll only while the booking // section is on screen (keeps it live without wasting requests). refreshAvailability(); (function pollWhileVisible() { const section = document.getElementById('booking'); if (!section || !('IntersectionObserver' in window)) { setInterval(refreshAvailability, 60000); return; } let timer = null; const io = new IntersectionObserver(entries => { entries.forEach(entry => { if (entry.isIntersecting && !timer) { refreshAvailability(); timer = setInterval(refreshAvailability, 45000); } else if (!entry.isIntersecting && timer) { clearInterval(timer); timer = null; } }); }, { threshold: 0.05 }); io.observe(section); })(); // Service selector cards click handler const serviceCards = document.querySelectorAll('.service-card'); serviceCards.forEach(card => { card.addEventListener('click', () => { serviceCards.forEach(c => c.classList.remove('selected')); card.classList.add('selected'); serviceInput.value = card.dataset.value; updateSummary(); }); }); // Booking Summary Updater const bookingForm = document.getElementById('bookingForm'); const summary = document.getElementById('bookingSummary'); // Form field event listeners to update summary live ['name', 'phone', 'email', 'message'].forEach(id => { document.getElementById(id).addEventListener('input', updateSummary); }); function updateSummary() { const service = serviceInput.value; const date = dateInput.value; const time = timeInput.value; const name = document.getElementById('name').value.trim(); const phone = document.getElementById('phone').value.trim(); const email = document.getElementById('email').value.trim(); const message = document.getElementById('message').value.trim(); if (!service && !date && !time && !name && !phone && !message) { summary.classList.remove('active'); return; } const lines = []; if (service) lines.push('<strong>Service:</strong> ' + service); if (date) lines.push('<strong>Date:</strong> ' + date); if (time) lines.push('<strong>Time:</strong> ' + time); if (name) lines.push('<strong>Name:</strong> ' + name); if (phone) lines.push('<strong>Phone:</strong> ' + phone); if (email) lines.push('<strong>Email:</strong> ' + email); if (message) lines.push('<strong>Message:</strong> ' + message); summary.innerHTML = '<strong>' + translations[currentLang]['booking.summaryLabel'] + '</strong><br>' + lines.join('<br>'); summary.classList.add('active'); } // Captcha generator // Reusable canvas captcha (booking + reviews). Stronger distortion than before. function createCaptcha(canvasId, inputId, feedbackId) { let answer = ''; const canvas = document.getElementById(canvasId); const input = document.getElementById(inputId); const feedback = document.getElementById(feedbackId); function regenerate(keepFeedback = false) { if (!canvas) return; const ctx = canvas.getContext('2d'); const W = canvas.width, H = canvas.height; ctx.clearRect(0, 0, W, H); ctx.fillStyle = '#fdf8f5'; ctx.fillRect(0, 0, W, H); // speckle noise for (let i = 0; i < 55; i++) { ctx.fillStyle = `rgba(156, 95, 89, ${Math.random() * 0.18 + 0.05})`; ctx.beginPath(); ctx.arc(Math.random() * W, Math.random() * H, Math.random() * 1.6, 0, 2 * Math.PI); ctx.fill(); } const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; let code = ""; for (let i = 0; i < 5; i++) code += chars.charAt(Math.floor(Math.random() * chars.length)); answer = code; // wavy curves behind the text for (let i = 0; i < 4; i++) { ctx.strokeStyle = `rgba(156, 95, 89, ${Math.random() * 0.28 + 0.12})`; ctx.lineWidth = Math.random() * 1.3 + 0.6; ctx.beginPath(); ctx.moveTo(0, Math.random() * H); ctx.bezierCurveTo(W * 0.3, Math.random() * H, W * 0.6, Math.random() * H, W, Math.random() * H); ctx.stroke(); } ctx.textBaseline = 'middle'; const cw = (W - 22) / code.length; for (let i = 0; i < code.length; i++) { const fs = Math.floor(Math.random() * 6) + 19; ctx.font = `bold ${fs}px "Outfit", "Segoe UI", sans-serif`; ctx.fillStyle = `rgba(122, 68, 63, ${Math.random() * 0.3 + 0.7})`; const x = 13 + i * cw + Math.random() * 4; const y = H / 2 + (Math.random() * 8 - 4); const ang = (Math.random() * 40 - 20) * Math.PI / 180; ctx.save(); ctx.translate(x, y); ctx.rotate(ang); ctx.fillText(code[i], 0, 0); ctx.restore(); } // foreground crossing lines for (let i = 0; i < 3; i++) { ctx.strokeStyle = `rgba(156, 95, 89, ${Math.random() * 0.32 + 0.15})`; ctx.lineWidth = Math.random() * 1.2 + 0.7; ctx.beginPath(); ctx.moveTo(Math.random() * W, Math.random() * H); ctx.lineTo(Math.random() * W, Math.random() * H); ctx.stroke(); } if (input) input.value = ''; if (feedback && !keepFeedback) feedback.textContent = ''; } if (input && feedback) input.addEventListener('input', () => { feedback.textContent = ''; }); return { regenerate, verify: () => !!input && input.value.trim().toUpperCase() === answer }; } const bookingCaptcha = createCaptcha('captchaCanvas', 'captchaInput', 'captchaFeedback'); bookingCaptcha.regenerate(); // Keep the inline refresh button working. function generateCaptcha(keepFeedback) { bookingCaptcha.regenerate(keepFeedback); } // AJAX Form Submission bookingForm.addEventListener('submit', async e => { e.preventDefault(); const service = serviceInput.value; const date = dateInput.value; const time = timeInput.value; const name = document.getElementById('name').value.trim(); const phone = document.getElementById('phone').value.trim(); const email = document.getElementById('email').value.trim(); const message = document.getElementById('message').value.trim(); if (!service) { alert(translations[currentLang]['booking.selectService']); return; } if (!date) { alert(translations[currentLang]['booking.needDate']); document.getElementById('calendar').scrollIntoView({ behavior: 'smooth', block: 'center' }); return; } if (!name || !phone) { bookingForm.reportValidity(); return; } if (!time) { alert(translations[currentLang]['booking.needTime']); return; } // Verify Captcha if (!bookingCaptcha.verify()) { const feedback = document.getElementById('captchaFeedback'); feedback.textContent = translations[currentLang]['booking.captchaError']; feedback.style.color = '#9c5f59'; bookingCaptcha.regenerate(true); return; } const submitBtn = bookingForm.querySelector('button[type="submit"]'); const originalBtnText = submitBtn.textContent; submitBtn.disabled = true; submitBtn.textContent = translations[currentLang]['booking.sending'] || 'Sending…'; // Last-second race check — make sure nobody grabbed this slot first. await refreshAvailability(); if (isSlotBooked(date, time)) { submitBtn.disabled = false; submitBtn.textContent = originalBtnText; timeInput.value = ''; renderTimeGrid(); updateSummary(); alert(translations[currentLang]['booking.slotTaken']); return; } // Build the FormSubmit payload. Including the visitor's email lets // FormSubmit send Sarah's automatic confirmation reply (_autoresponse). const payload = { _subject: '🌙 New Appointment Request — SarahDream', _template: 'table', Name: name, Phone: phone, Service: service, Date: date, Time: time, Message: message || 'None' }; if (email) { payload.email = email; // reply-to + autoresponse target payload._replyto = email; payload._autoresponse = (translations[currentLang]['booking.autoReply'] || translations.en['booking.autoReply']) .replace('{name}', name) .replace('{service}', service) .replace('{date}', date) .replace('{time}', time); } else { payload.Email = 'Not provided'; } try { const res = await fetch('https://formsubmit.co/ajax/[email protected]', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(payload) }); if (!res.ok) throw new Error('Server error'); await res.json(); // Record to the shared store + this device — greys the slot out // instantly for everyone (and for this visitor offline). await recordBooking(date, time); showBookingSuccess(name, service, date, time, !!email); } catch (err) { console.error(err); // Fallback to email/sms on failure alert('Connection error. Opening direct client...'); let body = `Hi Sarah, I would like to book an appointment:\nService: ${service}\nDate: ${date}\nTime: ${time}\nName: ${name}\nPhone: ${phone}`; if (email) body += `\nEmail: ${email}`; if (message) body += `\nMessage: ${message}`; const encodedBody = encodeURIComponent(body); if (window.matchMedia('(pointer: coarse)').matches) { window.location.href = `sms:+41782329996?body=${encodedBody}`; } else { window.location.href = `mailto:[email protected]?subject=Booking Request&body=${encodedBody}`; } submitBtn.disabled = false; submitBtn.textContent = originalBtnText; } }); function showBookingSuccess(name, service, date, time, emailed) { const cardInner = document.getElementById('bookingCardInner'); const emailNote = emailed ? `<p class="success-emailed"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-10 5L2 7"/></svg> ${translations[currentLang]['booking.emailConfirmSent']}</p>` : ''; cardInner.innerHTML = ` <div class="booking-success-card"> <div class="success-checkmark"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg> </div> <h3>${translations[currentLang]['booking.successTitle']}</h3> <p>${translations[currentLang]['booking.successDesc'].replace('{name}', `<strong>${name}</strong>`)}</p> ${emailNote} <div class="success-details"> <div><strong>${translations[currentLang]['booking.serviceLabel']}:</strong> <span>${service}</span></div> <div><strong>${translations[currentLang]['booking.dateLabel']}:</strong> <span>${date}</span></div> <div><strong>${translations[currentLang]['booking.timeLabel']}:</strong> <span>${time}</span></div> </div> <button type="button" class="btn btn-secondary" onclick="window.location.reload()">${translations[currentLang]['booking.bookAnother']}</button> </div> `; // Scroll to form top smoothly document.getElementById('booking').scrollIntoView({ behavior: 'smooth' }); } // ===== Guest Reviews ===== const SEED_REVIEWS = []; const BAD_WORDS = ['fuck','fucking','shit','cunt','bitch','whore','slut','nigger','faggot','asshole','motherfucker','retard','dick','pussy']; function softFilterText(s) { let o = String(s); BAD_WORDS.forEach(w => { o = o.replace(new RegExp('\\b' + w + '\\b', 'gi'), m => m[0] + '*'.repeat(Math.max(1, m.length - 1))); }); return o; } function escapeHtml(s) { const d = document.createElement('div'); d.textContent = String(s); return d.innerHTML; } function reviewCard(r) { return `<figure class="review-card reveal visible">` + `<blockquote>${escapeHtml(r.text)}</blockquote>` + `<figcaption>— ${escapeHtml(r.name)}${r.date ? ` <span class="rv-date">${escapeHtml(r.date)}</span>` : ''}</figcaption></figure>`; } let fetchedReviews = []; let commentsExpanded = false; function renderReviews() { const grid = document.getElementById('reviewsGrid'); const btnContainer = document.getElementById('reviewsExpandBtnContainer'); if (!grid) return; const allComments = [...fetchedReviews, ...SEED_REVIEWS]; if (allComments.length === 0) { grid.innerHTML = `<p style="text-align:center; color:var(--muted); font-style:italic; margin: 0 auto;">No comments yet. Be the first to leave a few words!</p>`; if(btnContainer) btnContainer.style.display = 'none'; return; } const limit = commentsExpanded ? allComments.length : 4; const visibleComments = allComments.slice(0, limit); grid.innerHTML = visibleComments.map(reviewCard).join(''); if (allComments.length > 4) { if(btnContainer) { btnContainer.style.display = 'block'; btnContainer.innerHTML = `<button type="button" class="btn btn-secondary" onclick="toggleComments()">${commentsExpanded ? 'Show Less' : 'Load More Comments'}</button>`; } } else { if(btnContainer) btnContainer.style.display = 'none'; } } window.toggleComments = function() { commentsExpanded = !commentsExpanded; renderReviews(); } async function loadReviews() { try { const r = await fetch('/api/reviews', { headers: { Accept: 'application/json' } }); if (r.ok) { const d = await r.json(); fetchedReviews = Array.isArray(d.reviews) ? d.reviews : []; } } catch (_) {} renderReviews(); } (function initStars() { const wrap = document.getElementById('starInput'); const out = document.getElementById('rvRating'); if (!wrap) return; const stars = [...wrap.querySelectorAll('.star')]; const paint = v => stars.forEach(s => s.classList.toggle('on', +s.dataset.v <= v)); stars.forEach(s => { s.addEventListener('click', () => { out.value = s.dataset.v; paint(+s.dataset.v); }); s.addEventListener('mouseenter', () => paint(+s.dataset.v)); }); wrap.addEventListener('mouseleave', () => paint(+out.value)); paint(+out.value); })(); const reviewCaptcha = createCaptcha('rvCaptchaCanvas', 'rvCaptchaInput', 'rvCaptchaFeedback'); reviewCaptcha.regenerate(); document.getElementById('rvCaptchaRefresh')?.addEventListener('click', () => reviewCaptcha.regenerate()); renderReviews(); loadReviews(); const reviewForm = document.getElementById('reviewForm'); reviewForm?.addEventListener('submit', async e => { e.preventDefault(); const name = document.getElementById('rvName').value.trim(); const text = document.getElementById('rvText').value.trim(); const rating = parseInt(document.getElementById('rvRating').value, 10) || 5; if (name.length < 2 || text.length < 3) { reviewForm.reportValidity(); return; } if (!reviewCaptcha.verify()) { const fb = document.getElementById('rvCaptchaFeedback'); fb.textContent = translations[currentLang]['booking.captchaError']; fb.style.color = '#9c5f59'; reviewCaptcha.regenerate(true); return; } const clean = { name: softFilterText(name), text: softFilterText(text), rating, date: new Date().toISOString().slice(0, 10) }; const btn = reviewForm.querySelector('button[type="submit"]'); const orig = btn.textContent; btn.disabled = true; btn.textContent = translations[currentLang]['booking.sending'] || 'Sending…'; try { await fetch('/api/reviews', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(clean) }); } catch (_) {} try { await fetch('https://formsubmit.co/ajax/[email protected]', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ _subject: '⭐ New Review — SarahDream', _template: 'table', Name: clean.name, Rating: clean.rating + '/5', Review: clean.text }) }); } catch (_) {} fetchedReviews.unshift(clean); renderReviews(); reviewForm.reset(); document.getElementById('rvRating').value = '5'; document.querySelectorAll('#starInput .star').forEach(s => s.classList.add('on')); reviewCaptcha.regenerate(); btn.disabled = false; btn.textContent = orig; const thanks = document.getElementById('reviewThanks'); if (thanks) { thanks.hidden = false; setTimeout(() => { thanks.hidden = true; }, 6000); } }); // Scroll animations const animObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) entry.target.classList.add('visible'); }); }, { threshold: 0.12, rootMargin: '0px 0px -40px 0px' }); document.querySelectorAll('.reveal, .reveal-left, .reveal-right, .reveal-scale').forEach(el => animObserver.observe(el)); // Smooth scroll for anchors document.querySelectorAll('a[href^="#"]').forEach(anchor => { anchor.addEventListener('click', function(e) { e.preventDefault(); const target = document.querySelector(this.getAttribute('href')); if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' }); }); }); // Add leaf divider to every section header document.querySelectorAll('.section-header h2').forEach(h2 => { if (h2.closest('.hero')) return; const divider = document.createElement('div'); divider.className = 'section-divider'; divider.setAttribute('aria-hidden', 'true'); divider.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M11 20A7 7 0 0 1 9.8 6.5C13.5 5.5 17 7 17 12a5 5 0 0 1-5 5h0"/><path d="M11 20v-6"/><path d="M8 14h6"/></svg>'; h2.after(divider); }); // Protect media from casual download / scraping document.addEventListener('contextmenu', e => { const t = e.target; if (t.tagName === 'IMG' || t.tagName === 'VIDEO' || t.closest('img') || t.closest('video')) { e.preventDefault(); } }); document.addEventListener('dragstart', e => { if (e.target.tagName === 'IMG' || e.target.tagName === 'VIDEO') e.preventDefault(); }); // Page preload — wait for fonts + first paint, then fade in smoothly (function initPageLoad() { const html = document.documentElement; const loader = document.getElementById('pageLoader'); const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const minDelay = reducedMotion ? 0 : 320; const maxWait = reducedMotion ? 0 : 1500; const started = performance.now(); function finishLoad() { const elapsed = performance.now() - started; const wait = Math.max(0, minDelay - elapsed); setTimeout(() => { html.classList.remove('is-preloading'); html.classList.add('is-ready'); if (loader) loader.classList.add('done'); }, wait); } const fontReady = document.fonts ? document.fonts.ready : Promise.resolve(); const pageLoaded = new Promise(resolve => { if (document.readyState === 'complete') resolve(); else window.addEventListener('load', resolve, { once: true }); }); Promise.race([ Promise.all([fontReady, pageLoaded]), new Promise(resolve => setTimeout(resolve, maxWait)) ]).then(finishLoad).catch(finishLoad); })(); // Filmstrip marquee — JS transform loop (CSS var updates were resetting animation every frame) (function initFilmstripMarquee() { return; // Marquee is now pure CSS — runs on every device, no JS, no scroll jank. const trackLeft = document.querySelector('.filmstrip-track.track-left'); const trackRight = document.querySelector('.filmstrip-track.track-right'); if (!trackLeft && !trackRight) return; if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; // Duplicate cards to ensure seamless loop on large desktop screens [trackLeft, trackRight].forEach(track => { if (!track) return; const cards = Array.from(track.children); cards.forEach(card => { const clone = card.cloneNode(true); const img = clone.querySelector('img'); if (img) img.setAttribute('loading', 'lazy'); track.appendChild(clone); }); }); [trackLeft, trackRight].forEach(track => { if (track) track.classList.add('is-animated'); }); let leftPos = 0; let rightPos = 0; let loopLeft = 0; let loopRight = 0; let measured = false; let scrollBoost = 0; let lastScrollY = window.scrollY; const BASE_SPEED = 0.42; const MAX_BOOST = 1.6; let isVisible = true; let rafId = null; function measure() { if (trackLeft) loopLeft = trackLeft.scrollWidth / 2; if (trackRight) loopRight = trackRight.scrollWidth / 2; if (!measured && loopRight) { rightPos = -loopRight; measured = true; } } measure(); window.addEventListener('resize', measure); window.addEventListener('load', measure); trackLeft?.querySelectorAll('img').forEach(img => { if (!img.complete) img.addEventListener('load', measure, { once: true }); }); trackRight?.querySelectorAll('img').forEach(img => { if (!img.complete) img.addEventListener('load', measure, { once: true }); }); window.addEventListener('scroll', () => { if (!isVisible) { lastScrollY = window.scrollY; return; } const delta = window.scrollY - lastScrollY; lastScrollY = window.scrollY; const step = Math.min(Math.abs(delta), 50); // clamp fast/jump scrolls scrollBoost = Math.min(MAX_BOOST, scrollBoost + step * 0.01); }, { passive: true }); function tick() { if (!loopLeft && !loopRight) { measure(); rafId = requestAnimationFrame(tick); return; } scrollBoost *= 0.90; const speed = BASE_SPEED + scrollBoost; if (trackLeft && loopLeft > 0) { leftPos -= speed; if (leftPos <= -loopLeft) leftPos += loopLeft; trackLeft.style.transform = `translate3d(${leftPos}px, 0, 0)`; } if (trackRight && loopRight > 0) { rightPos += speed; if (rightPos >= 0) rightPos -= loopRight; trackRight.style.transform = `translate3d(${rightPos}px, 0, 0)`; } rafId = requestAnimationFrame(tick); } function startStrip() { if (rafId == null) { lastScrollY = window.scrollY; rafId = requestAnimationFrame(tick); } } function stopStrip() { if (rafId != null) { cancelAnimationFrame(rafId); rafId = null; } scrollBoost = 0; } // Only run the loop while the filmstrip is actually on screen — saves // CPU and stops the scroll-boost from building up while it's away. const stripSection = document.querySelector('.filmstrip-section'); if (stripSection && 'IntersectionObserver' in window) { new IntersectionObserver(entries => { entries.forEach(entry => { isVisible = entry.isIntersecting; if (isVisible) startStrip(); else stopStrip(); }); }, { threshold: 0 }).observe(stripSection); document.addEventListener('visibilitychange', () => { if (document.hidden) stopStrip(); else if (isVisible) startStrip(); }); } else { startStrip(); } })(); // Living-moments marquee — JS-driven, seamless endless loop on EVERY // viewport & motion setting. Each row is cloned until it is wider than the // screen (so it never ends/gaps), then loops by exactly one set. // Living-moments marquee — CSS-driven seamless loop with dynamic JS measurement. // It runs natively on the browser compositor thread so it never jitters on scroll. (function filmstripMarquee() { const trackLeft = document.querySelector('.filmstrip-track.track-left'); const trackRight = document.querySelector('.filmstrip-track.track-right'); if (!trackLeft && !trackRight) return; const tracks = [ { el: trackLeft, dir: -1, childCount: 0, base: null }, { el: trackRight, dir: 1, childCount: 0, base: null } ].filter(t => t.el); function build(t) { if (t.base === null) { t.base = t.el.innerHTML; t.childCount = t.el.children.length; } t.el.innerHTML = t.base; t.el.insertAdjacentHTML('beforeend', t.base); // Clone once to measure true offset const c1 = t.el.children[0]; const c2 = t.el.children[t.childCount]; if (!c1 || !c2) return 0; const loop = c2.offsetLeft - c1.offsetLeft; if (!loop) return 0; const need = (window.innerWidth || 1280) + loop + 100; let guard = 1; while (t.el.scrollWidth < need && guard < 24) { t.el.insertAdjacentHTML('beforeend', t.base); guard++; } return loop; } function initAll() { const loopLeft = build(tracks[0]); const loopRight = build(tracks[1]); let marqueeStyle = document.getElementById('marquee-dynamic-style'); if (!marqueeStyle) { marqueeStyle = document.createElement('style'); marqueeStyle.id = 'marquee-dynamic-style'; document.head.appendChild(marqueeStyle); } // SPEED_FACTOR controls speed in pixels per second. const SPEED_FACTOR = 42; const durationLeft = loopLeft ? (loopLeft / SPEED_FACTOR) : 32; const durationRight = loopRight ? (loopRight / SPEED_FACTOR) : 32; marqueeStyle.innerHTML = ` @keyframes marquee-left-dynamic { 0% { transform: translate3d(0, 0, 0); } 100% { transform: translate3d(-${loopLeft}px, 0, 0); } } @keyframes marquee-right-dynamic { 0% { transform: translate3d(-${loopRight}px, 0, 0); } 100% { transform: translate3d(0, 0, 0); } } .filmstrip-track.track-left { animation: marquee-left-dynamic ${durationLeft}s linear infinite !important; } .filmstrip-track.track-right { animation: marquee-right-dynamic ${durationRight}s linear infinite !important; } @media (hover: hover) and (pointer: fine) { .filmstrip:hover .filmstrip-track { animation-play-state: paused !important; } } `; } initAll(); window.addEventListener('load', initAll); let rt; window.addEventListener('resize', () => { clearTimeout(rt); rt = setTimeout(initAll, 200); }); })(); // Hero/about parallax (function initParallax() { if (window.innerWidth < 768) return; // Disable parallax on mobile to prevent layout thrashing and scroll lag const heroVisual = document.querySelector('.hero-visual img'); const aboutImage = document.querySelector('.about-image img'); if (!heroVisual && !aboutImage) return; if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; let ticking = false; function updateParallax() { const docHeight = document.documentElement.scrollHeight - window.innerHeight; const progress = docHeight > 0 ? Math.min(1, Math.max(0, window.scrollY / docHeight)) : 0; if (heroVisual) heroVisual.style.transform = `translateY(${progress * 30}px) scale(${1 + progress * 0.03})`; if (aboutImage) aboutImage.style.transform = `translateY(${progress * -20}px)`; ticking = false; } window.addEventListener('scroll', () => { if (!ticking) { requestAnimationFrame(updateParallax); ticking = true; } }, { passive: true }); updateParallax(); })(); // Video fullscreen — keep full frame visible, no stretched crop (function initVideoFullscreen() { const videos = document.querySelectorAll('.site-video'); if (!videos.length) return; function applyFullscreenStyles(video) { video.classList.add('is-fullscreen'); video.style.objectFit = 'contain'; video.style.objectPosition = 'center center'; video.style.background = '#141010'; } function resetFullscreenStyles(video) { video.classList.remove('is-fullscreen'); video.style.objectFit = ''; video.style.objectPosition = ''; video.style.background = ''; } videos.forEach(video => { video.addEventListener('webkitbeginfullscreen', () => applyFullscreenStyles(video)); video.addEventListener('webkitendfullscreen', () => resetFullscreenStyles(video)); }); document.addEventListener('fullscreenchange', () => { videos.forEach(video => { if (document.fullscreenElement === video) applyFullscreenStyles(video); else resetFullscreenStyles(video); }); }); })(); // Pause videos when offscreen to prevent layout jank and save battery on mobile devices (function initVideoAutoplayObserver() { const videos = document.querySelectorAll('.site-video'); if (!videos.length || !('IntersectionObserver' in window)) return; const videoObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { if (entry.target.paused) { entry.target.play().catch(() => {}); } } else { if (!entry.target.paused) { entry.target.pause(); } } }); }, { threshold: 0.15 }); videos.forEach(video => videoObserver.observe(video)); })(); // Initialize default language and SEO metadata on load setLang(currentLang);
Disponibilità
Switzerland
{}
Posizione
Switzerland
Switzerland