1 2const PROXY="/booking-api/terminland.php"; 3// Anonymes Funnel-Tracking: zufällige Session-ID, keine Cookies, keine Personendaten. 4// Die ID lebt in sessionStorage: Reloads im selben Tab zählen als EIN Besuch, 5// beim Schliessen des Tabs verschwindet sie â keine Wiedererkennung über Besuche hinweg. 6const SID=(()=>{ const mk=()=>Array.from(crypto.getRandomValues(new Uint8Array(8))).map(b=>b.toString(16).padStart(2,"0")).join(""); 7 try{ let s=sessionStorage.getItem("sf_sid"); if(!s){ s=mk(); sessionStorage.setItem("sf_sid",s); } return s; }catch(e){ return mk(); } })(); 8function track(ev,val){ 9 try{ if(window.dataLayer) window.dataLayer.push({event:"booking_"+ev, booking_value:val||""}); }catch(e){} 10 try{ navigator.sendBeacon(PROXY.replace("terminland.php","stats.php"), JSON.stringify({ev:ev,val:val||"",sid:SID})); }catch(e){} 11} 12const COMPANY=19916; 13const SF_LANG="de"; // wird beim Build für FR/EN ersetzt (booking-fr.js / booking-en.js) 14const DEEPLINK=""; // öffentliche Buchung: kein Partner-Deeplink 15const LIVE_BOOKING=true; // <-- auf true nur für den gemeinsamen Testlauf 16 17// ?booking=â¦-Deeplinks der Landingpages â Vorauswahl. 18// Label-basiert statt über den Funnel-Resolver: die «Allgemein»-Funnels (termin-buchen-button, 19// die häufigsten Parameter) tragen keine a<qid>=<aid>-Parameter, dort liefert ?path=funnel nichts. 20// Der Abgleich läuft gegen die Antwort-Labels des questionary und ist damit sprachunabhängig; 21// Sprachsuffix (__default/__en/__fr) wird ignoriert. Unbekannte Parameter â normaler Ablauf. 22const BOOKING=(new URLSearchParams(location.search).get("booking")||"").replace(/__(default|en|fr)$/,""); 23const STUDIO_RX={"zurich":/z[üu]e?rich/i,"bern":/bern/i,"basel":/basel/i,"luzern":/luzern/i,"st-gallen":/st\.?\s?gallen/i,"genf":/gen[èe]ve|genf/i}; 24const OFFER_RX={ 25 "termin-buchen-button":[], 26 "bewerbungsfotos-standard-shooting":[{a:/^Bewerbungsfotos/},{a:/Standard/}], 27 "bewerbungsfotos-business-shooting":[{a:/^Bewerbungsfotos/},{a:/Business/}], 28 "bewerbungsfotos-premium-shooting":[{a:/^Bewerbungsfotos/},{a:/Premium/}], 29 "bewerbungsfotos-student-shooting":[{a:/^Bewerbungsfotos/},{a:/Student/}], 30 "businessportraits-executive-shooting":[{a:/^Businessportraits/},{a:/Executive/}], 31 "businessportraits-business-shooting":[{a:/^Businessportraits/},{a:/Business/,not:/Executive/}], 32 "passfotos-jetzt-pass-oder-visumfoto-buchen-button":[{a:/^Passfotos$/}], 33 "passfotos-normale-passfotos":[{a:/^Passfotos$/},{a:/ID, Pass, Visum/}], 34 "passfotos-erwachsene-passfotos":[{a:/^Passfotos$/},{a:/ID, Pass, Visum/}], 35 "passfotos-babypassfoto":[{a:/^Passfotos$/},{a:/Baby/}] 36}; 37let prefill=[]; 38(function(){ 39 if(!BOOKING) return; 40 const p=BOOKING.split("__"); 41 if(p.length!==2 || !(STUDIO_RX[p[0]]||p[0]==="alle-standorte") || !OFFER_RX[p[1]]) return; 42 if(STUDIO_RX[p[0]]) prefill.push({a:STUDIO_RX[p[0]]}); 43 prefill=prefill.concat(OFFER_RX[p[1]]); 44 track("deeplink",BOOKING); 45})(); 46const DOW=["So","Mo","Di","Mi","Do","Fr","Sa"], MON=["Jan","Feb","Mär","Apr","Mai","Jun","Jul","Aug","Sep","Okt","Nov","Dez"]; 47const DOWF=["Sonntag","Montag","Dienstag","Mittwoch","Donnerstag","Freitag","Samstag"], MONF=["Januar","Februar","März","April","Mai","Juni","Juli","August","September","Oktober","November","Dezember"]; 48 49// Kundenfreundliche Labels für die (internen) Terminland-Bezeichnungen.
50// Unbekannte Labels fallen automatisch auf den Originaltext zurück. 51const FRIENDLY={ 52 "Bewerbungsfotos - private Nutzungsrechte":{t:"Bewerbungsfotos",s:"Für Bewerbung, CV & LinkedIn â private Nutzung",img:"https://select-fotostudio.ch/wp-content/uploads/2025/04/Business-Shooting.jpeg"}, 53 "Businessportraits - gewerbliche Nutzungsrechte":{t:"Businessportraits",s:"Für Firmen: Website, Team & Marketing",img:"https://select-fotostudio.ch/wp-content/uploads/2025/04/Executive-Shooting.jpg"}, 54 "Passfotos":{t:"Passfotos",s:"Biometrisch für ID, Pass & Visum",img:"https://select-fotostudio.ch/wp-content/uploads/2022/02/passfoto.jpg"}, 55 "CV Standard 15 Min | 69.90 CHF":{t:"CV Standard 15 Min",s:"69.90 CHF",img:"https://select-fotostudio.ch/wp-content/uploads/2025/04/Standard-Shooting.jpeg"}, 56 "CV Business 25 Min | 89.90 statt 99.90 CHF":{t:"CV Business 25 Min",s:"89.90 statt 99.90 CHF",img:"https://select-fotostudio.ch/wp-content/uploads/2025/04/Business-Shooting.jpeg"}, 57 "CV Student 5 Min | 49.90 CHF":{t:"CV Student 5 Min",s:"49.90 CHF",img:"https://select-fotostudio.ch/wp-content/uploads/2025/04/Student-Shooting.jpeg"}, 58 "Business Executive Shooting 45 Min | 159.90 CHF":{t:"Business Executive Shooting 45 Min",s:"159.90 CHF",img:"https://select-fotostudio.ch/wp-content/uploads/2025/04/Executive-Shooting.jpg"}, 59 "Basel Barfüsserplatz Kohlenberg 3":{t:"Basel",s:"Kohlenberg 3",img:"https://select-fotostudio.ch/wp-content/uploads/select-booking/team-nadine.jpg?v=5",n:"Nadine"}, 60 "Bern City Ryffligässchen 4":{t:"Bern",s:"Ryffligässchen 4",img:"https://select-fotostudio.ch/wp-content/uploads/select-booking/team-jonas.jpg?v=5",n:"Jonas"}, 61 "Genève Rue du Mont-Blanc 21":{t:"Genf",s:"Rue du Mont-Blanc 21",img:"https://select-fotostudio.ch/wp-content/uploads/select-booking/team-tomasz.jpg?v=5",n:"Tomasz"}, 62 "Luzern City Floraweg 2":{t:"Luzern",s:"Floraweg 2",img:"https://select-fotostudio.ch/wp-content/uploads/select-booking/team-sharon.jpg?v=5",n:"Sharon"}, 63 "St. Gallen St. Leonhard-Strasse 45":{t:"St. Gallen",s:"St. Leonhard-Strasse 45",img:"https://select-fotostudio.ch/wp-content/uploads/select-booking/team-paula.jpg?v=5",n:"Paula"}, 64 "Zürich City Löwenstrasse 53":{t:"Zürich",s:"Löwenstrasse 53",img:"https://select-fotostudio.ch/wp-content/uploads/select-booking/team-vivi.jpg?v=6",n:"Vivi"} 65}; 66// Terminland-Fragetitel und Feldnamen kommen deutsch aus der API â Anzeige-Ãbersetzung 67// (QT wird beim Build für FR/EN gefüllt; unbekannte Titel fallen aufs Original zurück). 68const QT={}; 69const qlabel=t=>{const k=String(t||"").replace(/:\s*$/,"");return QT[k]||k;}; 70 71/* --- Optionswerte einer Terminland-ComboBox übersetzen («Passfoto Angaben») --- 72 * Terminland liefert die Optionen IMMER deutsch â auch auf /fr/reserver/ und 73 * /en/book-appointment/. Feldname und Platzhalter waren längst übersetzt, die 74 * 15 Optionen darunter nicht. Gemessen: 20 Passfoto-Buchungen in Genf, alle mit 75 * deutscher Auswahlliste; ein Kunde schrieb zweimal «Désolé, je sais pas ce que 76 * ça veut dire» in die Bemerkung, statt zu wählen. 77 * 78 * â ÃBERSETZT WIRD NUR DIE ANZEIGE â der value bleibt der deutsche Originaltext. 79 * Genau der geht beim Buchen an Terminland zurück (customer[f.id] = el.value); 80 * eine übersetzte Antwort würde Terminland nicht kennen. 81 * 82 * â WORTWEISE, NICHT SATZWEISE. Die Auswahlliste wird gerade umgebaut (Länder 83 * künftig ohne Massangaben). Eine Tabelle mit ganzen Sätzen wäre danach sofort 84 * tot; eine mit Ländern und Dokumentarten übersteht den Umbau und deckt neu 85 * ergänzte Länder ohne Codeänderung mit ab. Unbekanntes bleibt stehen â im 86 * schlimmsten Fall steht ein Wort deutsch da, nie eine kaputte Option. 87 */ 88const QOPT={ 89 fr:{"Schweiz":"Suisse","Deutschland":"Allemagne","Italien":"Italie","Frankreich":"France", 90 "Spanien":"Espagne","Grossbritannien":"Royaume-Uni","Finnland":"Finlande","Griechenland":"Grèce", 91 "USA":"Ãtats-Unis","Kanada":"Canada","China":"Chine","Indien":"Inde", 92 "Vereinigte Arabische Emirate":"Ãmirats arabes unis","Ãsterreich":"Autriche", 93 "Niederlande":"Pays-Bas","Polen":"Pologne","Laos":"Laos","Vietnam":"Viêt Nam", 94 "Australien":"Australie","Neuseeland":"Nouvelle-Zélande","Russland":"Russie", 95 "Kroatien":"Croatie","Slowenien":"Slovénie","Kambodscha":"Cambodge","Singapur":"Singapour", 96 "Türkei":"Turquie","Portugal":"Portugal","Irland":"Irlande","Ungarn":"Hongrie", 97 "Reisepass":"passeport","Visum":"visa","Pass/ID":"passeport / carte d'identité", 98 "Pass/Visum":"passeport / visa","Personalausweis":"carte d'identité", 99 "Biometrisch für Pass":"biométrique pour passeport", 100 "SBB & Fahrerausweis":"CFF & permis de conduire", 101 "Anderes Land â bitte in der Bemerkung angeben":"Autre pays â merci de préciser dans la remarque"},
102 en:{"Schweiz":"Switzerland","Deutschland":"Germany","Italien":"Italy","Frankreich":"France", 103 "Spanien":"Spain","Grossbritannien":"United Kingdom","Finnland":"Finland","Griechenland":"Greece", 104 "USA":"USA","Kanada":"Canada","China":"China","Indien":"India", 105 "Vereinigte Arabische Emirate":"United Arab Emirates","Ãsterreich":"Austria", 106 "Niederlande":"Netherlands","Polen":"Poland","Laos":"Laos","Vietnam":"Vietnam", 107 "Australien":"Australia","Neuseeland":"New Zealand","Russland":"Russia", 108 "Kroatien":"Croatia","Slowenien":"Slovenia","Kambodscha":"Cambodia","Singapur":"Singapore", 109 "Türkei":"Türkiye","Portugal":"Portugal","Irland":"Ireland","Ungarn":"Hungary", 110 "Reisepass":"passport","Visum":"visa","Pass/ID":"passport / ID card", 111 "Pass/Visum":"passport / visa","Personalausweis":"ID card", 112 "Biometrisch für Pass":"biometric for passport", 113 "SBB & Fahrerausweis":"SBB & driving licence", 114 "Anderes Land â bitte in der Bemerkung angeben":"Other country â please specify in the remarks"} 115}; 116/* Ganze Sätze, die sich nicht in «Land, Dokument, Format» zerlegen lassen. */ 117const QSATZ={ 118 fr:{'Keiner der oben genannten Punkte (bitte in der "Bemerkung" ergänzen)': 119 'Aucune des options ci-dessus (merci de préciser dans « Remarque »)'}, 120 en:{'Keiner der oben genannten Punkte (bitte in der "Bemerkung" ergänzen)': 121 'None of the above (please specify in the "Remarks" field)'} 122}; 123function qopt(o){ 124 const T=QOPT[SF_LANG]; if(!T) return String(o); // Deutsch: nichts zu tun 125 const s=String(o).replace(/\s+/g," ").trim(); 126 // Ganzsatz-Treffer zuerst â sonst zerlegt der Split unten Sätze wie 127 // «Anderes Land â bitte in der Bemerkung angeben» in unbekannte Teile. 128 const ganz=(QSATZ[SF_LANG]||{})[s]||T[s]; if(ganz) return ganz; 129 /* Getrennt wird an Komma UND Gedankenstrich â «USA â Pass/Visum» ist die 130 Schreibweise der neuen, massfreien Liste. Der Trenner wird mitgeschnitten 131 (Capture-Group) und unverändert wieder eingesetzt, damit «A, B» nicht zu 132 «A â B» wird. */ 133 return s.split(/\s*([,ââ])\s*/).map((teil,i)=>{ 134 if(i%2) return teil==="," ? ", " : " "+teil+" "; // Trenner: unverändert 135 const t=teil.trim(); 136 if(T[t]) return T[t]; 137 const m=t.match(/^(\d+)\s*Prints?$/i); // «4 Prints» â «4 tirages» 138 if(m) return m[1]+" "+(SF_LANG==="fr"?"tirages":"prints"); 139 return t.replace(/\boder\b/g, SF_LANG==="fr"?"ou":"or"); // «35 x 45 mm oder 40 mm» 140 }).join(""); 141} 142/* Die Optionen enthalten & und " («SBB & Fahrerausweis», «"Bemerkung"») â ohne 143 Maskierung bricht das value-Attribut auf und die Antwort geht verstümmelt raus. */ 144const escH=s=>String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">"); 145const escA=s=>escH(s).replace(/"/g,"""); 146 147function fr(label){ 148 const norm=String(label).replace(/\s+/g," ").trim(); 149 if(FRIENDLY[norm]) return FRIENDLY[norm]; 150 const m=norm.split(" | "); 151 if(m.length===2) return {t:QT[m[0]]||m[0],s:m[1]}; 152 return {t:QT[norm]||norm,s:""}; 153} 154 155// Sharon-Loop aus dem Consent-Banner â wiederverwendet für die Dankes-Ansage nach der Buchung. 156const AVATAR_SRC="https://select-fotostudio.ch/wp-content/uploads/consent-banner/sharon-avatar.mp4"; 157const AVATAR_POSTER="https://select-fotostudio.ch/wp-content/uploads/consent-banner/sharon-poster.jpg"; 158 159let answers={}; // {questionId:[answerId]} 160let picks=[]; // breadcrumb labels 161let chosenSlot=null; 162 163function api(path, {method="GET", body=null}={}){ 164 const headers={"Content-Type":"application/json"}; 165 if(DEEPLINK) headers["X-Deeplink"]=DEEPLINK; 166 // Terminland antwortet gelegentlich träge (gemessen: meist ~0.6s, Ausreisser ~5s) â 167 // nach 15s brechen wir ab, damit niemand vor einem ewigen Spinner sitzt. 168 const ctl=new AbortController(); const to=setTimeout(()=>ctl.abort(),15000); 169 return fetch(PROXY+"?path="+encodeURIComponent(path)+(path.includes("questionary")?"&order=onlineBooking":""),{ 170 method, headers, signal:ctl.signal, 171 body: body?JSON.stringify(body):null 172 }).then(async r=>{const t=await r.text();let j;try{j=JSON.parse(t)}catch(e){j={_raw:t}} if(!r.ok) throw new Error((j&&(j.msg||j.error))||("HTTP "+r.status)); return j;}) 173 .catch(e=>{ throw (e&&e.name==="AbortError")?new Error("Zeitüberschreitung â bitte nochmals versuchen."):e; }) 174 .finally(()=>clearTimeout(to)); 175} 176// Abfragefenster: 35 Tage ab jetzt, per «Früher/Später» in 35-Tage-Schritten verschiebbar 177// (winOffset in Tagen; Deckel ~7 Monate â danach hilft der Wunschtermin). 178// Das erste Fenster beginnt bewusst SOFORT und nicht erst morgen: Rund ein Fünftel aller 179// Buchungen über das alte Terminland-Formular waren Gleichtags-Buchungen (Walk-ins, vor
180// allem Passfotos). Zürich hat deshalb gar keinen Vorlauf, die übrigen Studios 30 Minuten. 181let winOffset=0; 182const WIN_LEN=35, WIN_MAX=210; 183const LEAD_ZUERICH=0, LEAD_UEBRIGE=30; 184function leadMinutes(){ 185 const st=(picks.find(p=>/studio/i.test(p.q))||{}).a||""; 186 return /z[üu]e?rich/i.test(st) ? LEAD_ZUERICH : LEAD_UEBRIGE; 187} 188// Lokaler Zeitstempel. NICHT toISOString() verwenden: das liefert UTC und läge im Sommer 189// zwei Stunden zurück â die Maske würde dann bereits vergangene Termine anbieten. 190function tlStamp(d){ 191 const p=n=>String(n).padStart(2,"0"); 192 return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; 193} 194function timeWindow(){ 195 const s=new Date(); 196 if(winOffset===0){ s.setMinutes(s.getMinutes()+leadMinutes(),0,0); } 197 else { s.setDate(s.getDate()+winOffset); s.setHours(0,0,0,0); } 198 const e=new Date(s); e.setDate(e.getDate()+WIN_LEN); e.setHours(23,59,59,0); 199 return {StartTime:tlStamp(s), EndTime:tlStamp(e), Limit:600}; 200} 201function winRangeLabel(){ 202 const s=new Date(); s.setDate(s.getDate()+winOffset); 203 const e=new Date(s); e.setDate(e.getDate()+WIN_LEN-1); 204 return s.getDate()+". "+MON[s.getMonth()]+" â "+e.getDate()+". "+MON[e.getMonth()]; 205} 206window.shiftWindow=d=>{ winOffset=Math.max(0,Math.min(WIN_MAX,winOffset+d*WIN_LEN)); track(d>0?"win_later":"win_earlier",String(winOffset)); step(); }; 207const $=id=>document.getElementById(id); 208function crumbs(){ 209 $("crumbs").style.display=""; 210 // Chips sind klickbar: ein Klick springt zu dieser Frage zurück (alles danach wird verworfen). 211 $("crumbs").innerHTML = picks.map((p,i)=>`<button class="crumb crumb-btn" onclick="backTo(${i})" title="Diese Auswahl ändern">${qlabel(p.q)}: <b>${p.a}</b></button>`).join(""); 212 const sel=picks.map(p=>p.a).join(", "); 213 const txt="Hallo Select Fotostudio! Ich möchte gerne einen Termin"+(sel?` für: ${sel}`:"")+" â könnt ihr mir helfen?"; 214 const wa=$("waLink"); if(wa) wa.href="https://wa.me/41754140340?text="+encodeURIComponent(txt); 215} 216 217async function step(){ 218 crumbs(); 219 $("foot").style.display="none"; 220 $("body").innerHTML='<div class="loading" id="stepLoad"><span class="spin"></span>Lade â¦</div>'; 221 const slowHint=setTimeout(()=>{ const l=$("stepLoad"); if(l) l.innerHTML='<span class="spin"></span>Einen Moment noch â Terminland antwortet gerade etwas langsam â¦'; },4000); 222 let j; 223 try{ j=await api(`api/voice/${COMPANY}/questionary/`,{method:"POST",body:{...timeWindow(),...answers}}); } 224 catch(e){ clearTimeout(slowHint); $("body").innerHTML=`<div class="err">Fehler beim Laden: ${e.message}</div>`; $("foot").style.display="flex"; $("bookBtn").style.display="none"; return; } 225 clearTimeout(slowHint); 226 227 const slots=(j.slots||[]).filter(s=>s.dateTime); 228 if(slots.length){ renderSlots(slots); return; } 229 // Verschobenes Zeitfenster ohne Termine: Slot-Ansicht mit Navigation zeigen statt Fehlermeldung. 230 if(winOffset>0){ renderSlots([]); return; } 231 232 const q=j.question||{}; const ans=(j.answers||[]).filter(a=>a.answer&&a.answer.trim()); 233 if(!ans.length){ $("body").innerHTML='<div class="err">Aktuell keine Auswahl/Termine verfügbar.</div><button class="wish-link" onclick="wishForm()">Trotzdem anfragen? â <b>Wunschtermin einreichen</b></button>'; $("foot").style.display="flex"; return; } 234 // Deeplink-Vorauswahl: passt der nächste Matcher auf eine Antwort dieser Frage, still übernehmen. 235 // Passt er nicht (z. B. alle-standorte: keine Studio-Vorgabe), bleibt die Frage offen und 236 // die restlichen Matcher greifen bei den Folgefragen. 237 if(prefill.length){ 238 const m=prefill[0]; 239 const hit=ans.find(x=>m.a.test(x.answer)&&!(m.not&&m.not.test(x.answer))); 240 if(hit){ prefill.shift(); answers[q.id]=[String(hit.id)]; picks.push({q:q.question||"",a:fr(hit.answer).t,qid:q.id}); step(); return; } 241 } 242 const isFirst = picks.length===0; 243 let lastStudio = null; 244 try{ lastStudio = isFirst ? localStorage.getItem("select_last_studio") : null; }catch(e){} 245 $("body").innerHTML=`<div class="lbl">${qlabel(q.question)||"Bitte wählen"}</div><div class="chips${isFirst?" chips--first":""}">${ 246 ans.map(a=>{const f=fr(a.answer); 247 const remember = isFirst ? `;try{localStorage.setItem("select_last_studio",${JSON.stringify(a.answer)})}catch(e){}` : ""; 248 const badge = (isFirst && lastStudio===a.answer) ? `<span class="chip-badge">Zuletzt gewählt</span>` : ""; 249 return `<button class="chip${f.img?" chip--img":""}" onclick='pick(${q.id},${a.id},${JSON.stringify(q.question||"")},${JSON.stringify(f.t)})${remember}'>${badge}<span class="chip-main"><span class="chip-t">${f.t}</span>${f.s?`<span class="chip-s">${f.s}</span>`:""}</span>${f.img?`<span class="chip-side"><img class="chip-img" src="${f.img}" alt="${f.n||""}" loading="lazy" onerror="this.parentNode.style.display='none'">${f.n?`<span class="chip-pn">${f.n}</span>`:""}</span>`:""}<span class="vf"></span></button>`; 250 }).join("") 251 }</div>`; 252 $("foot").style.display = picks.length ? "flex" : "none"; 253 $("bookBtn").style.display="none"; 254 $("stepBack").style.display = picks.length ? "" : "none"; 255 probeNext(); 256} 257
258// Hintergrund-Abfrage: nächstmöglicher Termin für die bisherige Auswahl. 259// Offene Fragen werden dabei automatisch mit der jeweils ersten Option beantwortet. 260let probeSeq=0; 261async function probeNext(){ 262 const seq=++probeSeq; 263 const info=$("nextInfo"); 264 if(!picks.length){ info.style.display="none"; return; } 265 let a=Object.assign({},answers), label=""; 266 try{ 267 for(let i=0;i<5;i++){ 268 const j=await api(`api/voice/${COMPANY}/questionary/`,{method:"POST",body:{...timeWindow(),...a}}); 269 if(seq!==probeSeq) return; 270 const slots=(j.slots||[]).filter(s=>s.dateTime); 271 if(slots.length){ 272 slots.sort((x,y)=>new Date(x.dateTime)-new Date(y.dateTime)); 273 const d=new Date(slots[0].dateTime); 274 info.innerHTML=`â¡ Nächstmöglicher Termin: <b>${dayLabel(d)}, ${d.getDate()}. ${MON[d.getMonth()]} · ${String(d.getHours()).padStart(2,"0")}:${String(d.getMinutes()).padStart(2,"0")} Uhr</b>`; 275 info.style.display=""; 276 return; 277 } 278 const q=j.question||{}; const ans=(j.answers||[]).filter(x=>x.answer&&x.answer.trim()); 279 if(!ans.length) break; 280 a[q.id]=[String(ans[0].id)]; 281 label=fr(ans[0].answer).t; 282 } 283 }catch(e){} 284 if(seq===probeSeq) info.style.display="none"; 285} 286 287// Wunschtermin einreichen (Anfrage geht per WhatsApp raus) 288window.wishForm=()=>{ 289 track("wish_open"); 290 probeSeq++; $("nextInfo").style.display="none"; 291 $("body").innerHTML=`<div class="lbl">Wunschtermin einreichen</div> 292 <div class="wf-txt">Kein passender Termin dabei? Sagen Sie uns, wann es Ihnen passt â wir melden uns so schnell wie möglich.</div> 293 <form id="wf" onsubmit="return false"> 294 <div class="frow"> 295 <div><label>Wunschdatum</label><input id="wfd" type="date" min="${new Date().toISOString().slice(0,10)}"></div> 296 <div><label>Zeitfenster</label><select id="wft"><option>Vormittag</option><option>Mittag</option><option>Nachmittag</option><option>Abend</option><option>Flexibel</option></select></div> 297 </div> 298 <div><label>Bemerkung (optional)</label><input id="wfn" placeholder="z. B. auch kurzfristig möglich"></div> 299 </form> 300 <button class="btn" style="margin-top:18px" onclick="sendWish()">Anfrage per WhatsApp senden</button>`; 301 $("foot").style.display="flex"; $("bookBtn").style.display="none"; 302}; 303window.sendWish=()=>{ 304 track("wish_sent"); 305 const sel=picks.map(p=>p.a).join(", "); 306 const d=$("wfd").value, t=$("wft").value, n=$("wfn").value; 307 let txt="Hallo Select Fotostudio! Ich möchte einen Wunschtermin anfragen"+(sel?` für: ${sel}`:"")+"."; 308 if(d) txt+=` Wunschdatum: ${d.split("-").reverse().join(".")}.`; 309 if(t) txt+=` Zeitfenster: ${t}.`; 310 if(n) txt+=` Bemerkung: ${n}`; 311 window.open("https://wa.me/41754140340?text="+encodeURIComponent(txt),"_blank","noopener"); 312}; 313window.pick=(qid,aid,qtext,atext)=>{ answers[qid]=[String(aid)]; picks.push({q:qtext,a:atext,qid:qid}); winOffset=0; pushStep(); track("choice_"+picks.length, atext); step(); }; 314 315// Zurück zu Frage i: alle Antworten ab dieser Stelle verwerfen, laufende Reservierung freigeben. 316window.backTo=(i)=>{ 317 prefill=[]; probeSeq++; winOffset=0; $("nextInfo").style.display="none"; 318 for(let j=i;j<picks.length;j++){ if(picks[j].qid!=null) delete answers[picks[j].qid]; } 319 picks=picks.slice(0,i); chosenSlot=null; 320 if(lockData){ api(`api/voice/${COMPANY}/event/unlock/${lockData.eventId}/`,{method:"POST"}).catch(()=>{}); lockData=null; } 321 track("back"); step(); 322}; 323// Vom Formular zurück zur Terminwahl (Auswahl bleibt, Slot wird freigegeben). 324window.goBack=()=>{ if(chosenSlot){ backToSlots(); } else if(picks.length){ backTo(picks.length-1); } }; 325window.backToSlots=()=>{ 326 if(lockData){ api(`api/voice/${COMPANY}/event/unlock/${lockData.eventId}/`,{method:"POST"}).catch(()=>{}); lockData=null; } 327 chosenSlot=null; track("back_slots"); step(); 328}; 329 330let byDay={}; 331function dayLabel(d){ 332 const today=new Date(); today.setHours(0,0,0,0); 333 const diff=Math.round((new Date(d.getFullYear(),d.getMonth(),d.getDate())-today)/86400000); 334 return diff===0?"Heute":diff===1?"Morgen":DOW[d.getDay()]; 335}
336function renderSlots(slots){ 337 byDay={}; 338 slots.sort((a,b)=>new Date(a.dateTime)-new Date(b.dateTime)); 339 slots.forEach(s=>{const d=new Date(s.dateTime);const k=d.toISOString().slice(0,10);(byDay[k]=byDay[k]||[]).push(s);}); 340 const days=Object.keys(byDay).sort(); 341 const tabs=days.map((k,i)=>{ 342 const d=new Date(k+"T00:00:00"); 343 return `<button class="daytab${i===0?" on":""}" data-k="${k}" onclick="selDay(this)"><span class="dt-dow">${dayLabel(d)}</span><span class="dt-date">${d.getDate()}. ${MON[d.getMonth()]}</span></button>`; 344 }).join(""); 345 let next=""; 346 if(slots.length){ 347 const n=slots[0]; const nd=new Date(n.dateTime); 348 const nWhen=`${dayLabel(nd)}, ${nd.getDate()}. ${MON[nd.getMonth()]} · ${String(nd.getHours()).padStart(2,"0")}:${String(nd.getMinutes()).padStart(2,"0")} Uhr`; 349 const nLbl=winOffset===0?"Nächster freier Termin":"Erster Termin in diesem Zeitraum"; 350 next=`<button class="nextslot" onclick='pickNext(${JSON.stringify(String(n.id))},${JSON.stringify(n.dateTime)})'><span>â¡ ${nLbl}: <b>${nWhen}</b></span><span class="ns-cta">Direkt buchen â</span></button>`; 351 } 352 // Zeitraum-Navigation: bringt auch weiter entfernte Monate in Reichweite (z. B. Oktober). 353 const nav=`<div class="win-nav"><button class="win-btn" ${winOffset===0?"disabled":""} onclick="shiftWindow(-1)">â¹ Früher</button><span class="win-lab">${winRangeLabel()}</span><button class="win-btn" ${winOffset>=WIN_MAX?"disabled":""} onclick="shiftWindow(1)">Später âº</button></div>`; 354 // Erstes Fenster: Tages-Tabs (dicht belegt). Spätere Fenster: Agenda-Liste â alle Tage 355 // untereinander mit ihren Zeiten, zum schnellen Ãberfliegen eines ganzen Monats. 356 const agenda=days.map(k=>{ 357 const d=new Date(k+"T00:00:00"); 358 const times=(byDay[k]||[]).map(s=>{const t=new Date(s.dateTime);const hh=String(t.getHours()).padStart(2,"0")+":"+String(t.getMinutes()).padStart(2,"0"); 359 return `<button class="slot agslot" data-id="${s.id}" data-dt="${s.dateTime}" onclick="selSlot(this)">${hh}</button>`;}).join(""); 360 return `<div class="agday"><div class="agday-h">${DOW[d.getDay()]}, ${d.getDate()}. ${MON[d.getMonth()]}</div><div class="agtimes">${times}</div></div>`; 361 }).join(""); 362 const inner=slots.length 363 ? (winOffset===0 ? `<div class="daytabs">${tabs}</div><div id="times"></div>` : `<div class="agenda">${agenda}</div>`) 364 : `<div class="win-empty">In diesem Zeitraum sind keine Online-Termine verfügbar.<br>Blättern Sie weiter oder senden Sie uns einen Wunschtermin.</div>`; 365 $("body").innerHTML=`<div class="lbl">Freien Termin wählen</div>${next}${nav}${inner}<button class="wish-link" onclick="wishForm()">Kein passender Termin dabei? â <b>Wunschtermin einreichen</b></button>`; 366 if(days.length && winOffset===0) renderTimes(days[0]); 367 probeSeq++; $("nextInfo").style.display="none"; 368 // Was hat der Besucher hier gesehen? Damit lässt sich der Absprung an dieser Stelle 369 // auswerten: Studio · Paket · Anzahl freier Termine · Tage bis zum ersten Termin. 370 { 371 const stu=(picks.find(p=>/studio/i.test(p.q))||{}).a||"?"; 372 const pak=(picks[picks.length-1]||{}).a||"?"; 373 let vor="-"; 374 if(slots.length){ const d0=new Date(slots[0].dateTime), t0=new Date(); t0.setHours(0,0,0,0); 375 vor=Math.max(0,Math.round((new Date(d0.getFullYear(),d0.getMonth(),d0.getDate())-t0)/86400000)); } 376 track("slots_shown",[stu,pak,slots.length+" frei",vor+" Tage Vorlauf"].join(" | ").slice(0,80)); 377 } 378 $("foot").style.display="flex"; $("bookBtn").style.display="none"; $("stepBack").style.display=""; 379} 380window.pickNext=(id,dt)=>{ chosenSlot={id:id,dt:dt}; pushStep(); track("slot_selected","nextslot"); showForm(); }; 381window.selDay=el=>{ document.querySelectorAll(".daytab.on").forEach(t=>t.classList.remove("on")); el.classList.add("on"); renderTimes(el.dataset.k); }; 382function renderTimes(k){ 383 const times=(byDay[k]||[]).map(s=>{const t=new Date(s.dateTime);const hh=String(t.getHours()).padStart(2,"0")+":"+String(t.getMinutes()).padStart(2,"0"); 384 return `<div class="slot" data-id="${s.id}" data-dt="${s.dateTime}" onclick="selSlot(this)">${hh}</div>`;}).join(""); 385 $("times").innerHTML=`<div class="times">${times}</div>`; 386} 387window.selSlot=el=>{ document.querySelectorAll(".slot.sel").forEach(s=>s.classList.remove("sel")); el.classList.add("sel"); 388 chosenSlot={id:el.dataset.id, dt:el.dataset.dt}; pushStep(); track("slot_selected"); showForm(); }; 389 390let lockData=null, extraFields=[]; 391async function showForm(){ 392 const d=new Date(chosenSlot.dt); 393 const when=`${DOWF[d.getDay()]}, ${d.getDate()}. ${MONF[d.getMonth()]} · ${String(d.getHours()).padStart(2,"0")}
393:${String(d.getMinutes()).padStart(2,"0")} Uhr`; 394 // Auf dem Formular-Schritt ersetzen wir die Breadcrumb-Chips durch die eine Zusammenfassung 395 // in der gelben Box (sonst steht z. B. das Studio doppelt da). 396 $("crumbs").style.display="none"; 397 // Slot sofort reservieren (Terminland hält ihn ~10 Min.). Die lock-Antwort liefert die für 398 // DIESEN Termin verlangten Pflichtfelder â z. B. «Passfoto Angaben» nur bei Passfoto-Buchungen. 399 // Pflichtfelder ausserhalb des Standardformulars werden darunter dynamisch gerendert. 400 lockData=null; extraFields=[]; 401 if(LIVE_BOOKING){ 402 $("body").innerHTML='<div class="loading"><span class="spin"></span>Termin wird reserviert â¦</div>'; 403 $("foot").style.display="none"; 404 try{ 405 const lock=await api(`api/voice/${COMPANY}/event/lock/${chosenSlot.id}/`,{method:"POST",body:{questionary:answers}}); 406 lockData={eventId:lock.eventId, fields:lock.fields||[], expires:lock.expires}; 407 const STD=["firstName","lastName","email","phone","street","zipCode","city","field13"]; 408 extraFields=(lock.fields||[]).filter(f=>f.required&&!STD.includes(f.key)); 409 }catch(e){ $("body").innerHTML=`<div class="err">Termin konnte nicht reserviert werden: ${e.message}</div>`; $("foot").style.display="flex"; $("bookBtn").style.display="none"; return; } 410 } 411 // Firmenname nur bei gewerblichen Buchungen (Businessportraits) â dort Pflicht, 412 // bei Privatkunden (Bewerbungsfotos/Passfotos) gar nicht erst anzeigen. 413 const isBusiness=picks.some(p=>/businessportrait/i.test(p.a)); 414 const extraHtml=extraFields.map(f=>{ 415 const nm=qlabel(String(f.name||"").trim()); 416 if(f.type==="ComboBox"||f.type==="RadioButton"){ 417 const opts=(f.options||[]).map(o=>`<option value="${escA(o)}">${escH(qopt(o))}</option>`).join(""); 418 return `<div><label>${nm}</label><select id="xf_${f.id}" required><option value="" disabled selected>Bitte wählen â¦</option>${opts}</select></div>`; 419 } 420 if(f.type==="CheckBox") return `<label class="agb"><input type="checkbox" id="xf_${f.id}" required><span>${nm}</span></label>`; 421 return `<div><label>${nm}</label><input id="xf_${f.id}" required></div>`; 422 }).join(""); 423 $("body").innerHTML=` 424 <div class="summary"><b>${when}</b><div class="summary-sel">${picks.map(p=>p.a).join(" · ")}</div><div class="summary-cancel">⺠Kostenlos stornierbar bis 4 Stunden vor dem Termin</div><button type="button" class="sum-back" onclick="backToSlots()">â¹ Termin ändern</button></div> 425 <form id="cust" onsubmit="return false"> 426 <div class="frow"><div><label>Vorname</label><input id="fn" required placeholder="Max"></div><div><label>Nachname</label><input id="ln" required placeholder="Muster"></div></div> 427 ${isBusiness?`<div><label>Firmenname</label><input id="cn" required placeholder="Muster AG"></div>`:""} 428 <div><label>E-Mail</label><input id="em" type="email" required placeholder="[email protected]"></div> 429 <div><label>Telefon</label><input id="ph" type="tel" required placeholder="079 000 00 00 oder +49 â¦"></div> 430 <div><label>Strasse / Nr.</label><input id="st" required placeholder="Musterstrasse 1" onblur="checkAddress()"></div> 431 <div class="frow"><div><label>PLZ</label><input id="zp" required inputmode="numeric" placeholder="8000" onblur="checkAddress()"></div><div><label>Ort</label><input id="ct" required placeholder="Zürich" onblur="checkAddress()"></div></div> 432 <div id="addrHint" class="addr-hint"></div> 433 ${extraHtml} 434 <div><label>Bemerkung (optional)</label><textarea id="nt" rows="2" placeholder="z. B. besondere Wünsche oder Passfoto-Format, falls nicht in der Liste"></textarea></div> 435 <label class="agb"><input type="checkbox" id="agb" required><span>Ich akzeptiere die <a href="https://select-fotostudio.ch/agb/" target="_blank" rel="noopener">AGB</a> und die <a href="https://select-fotostudio.ch/datenschutz/" target="_blank" rel="noopener">Datenschutzbestimmungen</a>. Termine können <b>bis zu 4 Stunden vor dem Beginn des Shootings kostenfrei storniert</b> werden.</span></label> 436 </form>`; 437 $("foot").style.display="flex"; $("stepBack").style.display=""; 438 const b=$("bookBtn"); b.style.display=""; b.disabled=false; 439 b.textContent = LIVE_BOOKING ? "Verbindlich buchen" : "Buchen (Test-Modus aus)"; 440 b.onclick=doBook; 441} 442 443// Sanfte Adressprüfung gegen das amtliche Gebäudeadressverzeichnis (GeoAdmin, gratis, ohne Key). 444// Nur Hinweis, nie blockierend: bei Neubauten/Spezialfällen darf trotzdem gebucht werden. 445let addrSeq=0; 446window.checkAddress=async function(){ 447 const hint=$("addrHint"); if(!hint) return; 448 const st=($("st").value||"").trim(), zp=($("zp").value||"").trim(), ct=($("ct").value||"").trim(); 449 if(!st||!zp||!ct){ hint.textContent=""; return; } 450 const seq=++addrSeq; 451 try{ 452 const r=await fetch("https://api3.geo.admin.ch/rest/services/api/SearchServer?type=locations&origins=address&limit=1&searchText="+encodeURIComponent(st+" "+zp+" "+ct)); 453 const j=await r.json(); 454 if(seq!==addrSeq) return; 455 const res=(j.results||[])[0]; 456 const label=res?String(res.attrs.label).replace(/<[^>]+>/g,"").replace(/\s+/g," ").trim():""; 457 const norm=s=>s.toLowerCase().replace(/\s+/g," ").trim(); 458 if(label && norm(label).startsWith(norm(st)) && label.includes(zp)){ 459 hint.textContent="â Adresse gefunden: "+label; hint.className="addr-hint ok"; 460 }else{ 461 hint.className="addr-hint warn"; 462 const m=label.match(/^(.+)\s(\d{4})\s(.+)$/); 463 if(m){ 464 hint.innerHTML="Hmm, diese Adresse finden wir nicht. Meinten Sie <button type=\"button\" class=\"addr-fix\"></button>?"; 465 const b=hint.querySelector(".addr-fix"); 466 b.textContent=m[1]+", "+m[2]+" "+m[3]; 467 b.onclick=()=>{ $("st").value=m[1]; $("zp").value=m[2];
467 $("ct").value=m[3]; checkAddress(); }; 468 }else{ 469 hint.textContent="Hmm, diese Adresse finden wir nicht â stimmt alles? (Buchen geht trotzdem)"; 470 } 471 } 472 }catch(e){ if(seq===addrSeq) hint.textContent=""; } 473}; 474 475async function doBook(){ 476 const form=$("cust"); if(!form.reportValidity()) return; 477 // Pflichtfelder gemäss Terminland fields-API (23.07.2026): firstName, lastName, email, phone, 478 // street, zipCode, city, field13 (AGB-Checkbox). 479 const cust={firstName:$("fn").value, lastName:$("ln").value, email:$("em").value, phone:$("ph").value, 480 street:$("st").value, zipCode:$("zp").value, city:$("ct").value, field13:true}; 481 if($("nt")&&$("nt").value.trim()) cust.notice=$("nt").value.trim(); 482 if($("cn")&&$("cn").value.trim()) cust.company=$("cn").value.trim(); 483 track(LIVE_BOOKING?"book_attempt":"book_intent", chosenSlot?chosenSlot.dt:""); 484 if(!LIVE_BOOKING){ 485 const d=new Date(chosenSlot.dt); 486 $("body").innerHTML=`<div class="done"><div class="ok">â</div><h2>Bereit zum Buchen</h2> 487 <p>Im Live-Modus würde jetzt der Slot reserviert (<code>lock</code>) und verbindlich gebucht (<code>book</code>) â inkl. Bestätigungsmail von Terminland.<br><br> 488 <b>${d.getDate()}. ${MON[d.getMonth()]}, ${String(d.getHours()).padStart(2,"0")}:${String(d.getMinutes()).padStart(2,"0")} Uhr</b> · ${cust.firstName} ${cust.lastName}</p></div>`; 489 $("foot").style.display="flex"; $("bookBtn").style.display="none"; return; 490 } 491 // --- LIVE path (used only during the coordinated test) --- 492 $("bookBtn").disabled=true; $("bookBtn").textContent="Buche â¦"; 493 try{ 494 // Lock auffrischen, falls abgelaufen (die Reservierung aus showForm hält ~10 Min.) 495 if(!lockData || (lockData.expires && new Date(lockData.expires).getTime()-Date.now()<30000)){ 496 const lock=await api(`api/voice/${COMPANY}/event/lock/${chosenSlot.id}/`,{method:"POST",body:{questionary:answers}}); 497 lockData={eventId:lock.eventId, fields:lock.fields||[], expires:lock.expires}; 498 } 499 // Terminland erwartet die Kundenfelder mit numerischen Feld-IDs (verifiziert 23.07.2026: 500 // Key-Namen ergeben HTTP 400 «Ungültige Konvertierung ⦠in Typ Integer»). Die IDs kommen 501 // aus der fields-Liste der lock-Antwort; Fallback auf die bekannten IDs. 502 const FID_FALLBACK={firstName:2,lastName:3,street:5,zipCode:6,city:7,phone:8,email:9,notice:10,company:11,field13:34}; 503 const fid=Object.fromEntries((lockData.fields||[]).map(f=>[f.key,f.id])); 504 const customer={}; 505 for(const k in cust) customer[String(fid[k]??FID_FALLBACK[k])]=cust[k]; 506 extraFields.forEach(f=>{const el=$("xf_"+f.id); if(el) customer[String(f.id)]=(f.type==="CheckBox")?true:el.value;}); 507 await api(`api/voice/${COMPANY}/event/book/${lockData.eventId}/`,{method:"POST",body:{sendConfirmationEmail:true,customer:customer}}); 508 track("booked",[(picks.find(p=>/studio/i.test(p.q))||{}).a,(picks[picks.length-1]||{}).a,chosenSlot.dt].filter(Boolean).join(" | ")); 509 renderDone(cust, lockData.eventId); 510 }catch(e){ $("bookBtn").disabled=false; $("bookBtn").textContent="Erneut versuchen"; 511 track("book_error",String(e.message||"").slice(0,60)); 512 $("body").insertAdjacentHTML("afterbegin",`<div class="err">Buchung fehlgeschlagen: ${e.message}</div>`); } 513} 514// Dankes-Screen mit Sharons Video-Gruss. Bei «Bewegung reduzieren» bleibt das Standbild. 515// Wichtig: muted/playsinline VOR src setzen, sonst blockiert Chrome das Autoplay dauerhaft. 516function renderDone(cust, eventId){ 517 sfBooked=true; 518 const name=cust.firstName?cust.firstName.trim().charAt(0).toUpperCase()+cust.firstName.trim().slice(1):""; 519 const studio=(picks.find(p=>/studio/i.test(p.q))||{}).a||""; 520 const STUDIO_ADDR={"Zürich":"Löwenstrasse 53, 8001 Zürich","Bern":"Ryffligässchen 4, 3011 Bern","Basel":"Kohlenberg 3, 4051 Basel","Luzern":"Floraweg 2, 6003 Luzern","St. Gallen":"St. Leonhard-Strasse 45, 9001 St. Gallen","Genf":"Rue du Mont-Blanc 21, 1201 Genève"}; 521 const to=STUDIO_ADDR[studio]||""; 522 const from=[cust.street,(cust.zipCode||"")+" "+(cust.city||"")].map(s=>
522String(s).trim()).filter(Boolean).join(", "); 523 const sbb=(to&&from)?`https://www.sbb.ch/de?von=${encodeURIComponent(from)}&nach=${encodeURIComponent(to)}`:""; 524 const IC='fill="none" stroke="#c79f00" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"'; 525 const tiles=`<div class="done-tiles"> 526 ${sbb?`<a class="dtile dtile-link" href="${sbb}" target="_blank" rel="noopener" onclick="track('sbb_click')"> 527 <h3>ð Ihr Weg ins Studio</h3> 528 <p>Von Ihrer Adresse bis vor unsere Tür (${to}) â wir sind direkt beim Bahnhof.</p> 529 <span class="dtile-cta">ÃV-Verbindung auf sbb.ch â</span></a>`:""} 530 <div class="dtile"> 531 <h3>Darauf können Sie zählen</h3> 532 <div class="dtile-bnf"><svg width="15" height="15" viewBox="0 0 24 24" ${IC}><path d="M12 2l8 3.5V11c0 5-3.4 8.6-8 10.5C7.4 19.6 4 16 4 11V5.5L12 2z"/><path d="M9 11.5l2 2 4-4.5"/></svg>100% Geld-zurück-Garantie</div> 533 <div class="dtile-bnf"><svg width="15" height="15" viewBox="0 0 24 24" ${IC}><circle cx="12" cy="7.5" r="3.5"/><path d="M4.5 20c.8-3.7 4-5.5 7.5-5.5s6.7 1.8 7.5 5.5"/></svg>Persönliche Beratung im Studio</div> 534 <div class="dtile-bnf"><svg width="15" height="15" viewBox="0 0 24 24" ${IC}><path d="M12 21.5S5.5 16 5.5 10.5a6.5 6.5 0 0 1 13 0C18.5 16 12 21.5 12 21.5z"/><circle cx="12" cy="10.5" r="2.3"/></svg>Studio direkt beim Bahnhof</div> 535 </div></div>`; 536 $("body").innerHTML=`<div class="done"> 537 <div class="done-avatar-wrap"><img class="done-avatar" id="doneAvatar" src="${AVATAR_POSTER}" alt=""><span class="done-check">â</span></div> 538 <h2>Merci${name?", "+name:""} â bis bald im Studio!</h2> 539 <p>
539Ihr Termin ist fix reserviert. Die Bestätigung mit allen Details ist unterwegs in Ihr Postfach.</p> 540 <p class="done-sub">Buchungs-Nr. ${eventId}</p> 541 <p class="done-cancel">Etwas dazwischengekommen? Kostenlos stornieren bis 4 Stunden vorher â der Link dazu ist in der Mail.</p> 542 ${tiles}</div>`; 543 $("foot").style.display="flex"; $("bookBtn").style.display="none"; $("stepBack").style.display="none"; 544 if(!window.matchMedia("(prefers-reduced-motion: reduce)").matches){ 545 const v=document.createElement("video"); 546 v.muted=true; v.playsInline=true; v.setAttribute("playsinline",""); v.loop=true; v.autoplay=true; 547 v.poster=AVATAR_POSTER; v.className="done-avatar"; v.src=AVATAR_SRC; 548 $("doneAvatar").replaceWith(v); v.play().catch(()=>{}); 549 } 550} 551 552window.restart=()=>{ if(lockData){ api(`api/voice/${COMPANY}/event/unlock/${lockData.eventId}/`,{method:"POST"}).catch(()=>{}); lockData=null; } sfBooked=false; answers={}; picks=[]; chosenSlot=null; prefill=[]; probeSeq++; winOffset=0; $("nextInfo").style.display="none"; step(); }; 553// Erreichbarkeit: MoâSa 08:30â18:00 Standardtext; ausserhalb (und sonntags) Hinweis, 554// dass Nachrichten ab dem nächsten Arbeitstag beantwortet werden. 555(function(){ 556 const n=new Date(), d=n.getDay(), m=n.getHours()*60+n.getMinutes(); 557 if(d>=1&&d<=6&&m>=510&&m<1080) return; 558 const when=(d>=1&&d<=6&&m<510)?"heute ab 08:30":(((d+1)%7===0)?"am Montag ab 08:30":"morgen ab 08:30"); 559 const t=document.querySelector(".wa-txt"); 560 if(t) t.textContent="Gerade offline â "+when+" sind wir wieder für Sie da."; 561})(); 562 563// --- Zurück-Wischen / Browser-Zurück = ein Schritt im Funnel zurück, statt die Seite zu verlassen --- 564// Wichtig für iOS Safari: KEIN pushState innerhalb des popstate-Handlers (wird während der 565// Wisch-Geste ignoriert). Stattdessen legt jeder Vorwärts-Schritt (pick/Slot-Wahl) seinen 566// eigenen History-Eintrag an; der Handler rendert nur den vorherigen Schritt. 567let sfBooked=false; 568function pushStep(){ try{ history.pushState({sf:1},""); }catch(e){} } 569window.addEventListener("popstate",()=>{ 570 if(sfBooked) return; // nach abgeschlossener Buchung: normal verlassen 571 if(chosenSlot){ backToSlots(); return; } 572 if(picks.length){ backTo(picks.length-1); return; } 573 // an der ersten Auswahl: nichts tun â die nächste Geste verlässt die Seite normal 574}); 575 576// Interne Besuche (Team/Tests) dauerhaft markieren: einmal /termin-buchen/?intern öffnen, 577// danach trägt jede Session dieses Geräts das Flag und der Report filtert sie heraus. 578try{ if(/[?&]intern\b/.test(location.search)) localStorage.setItem("sf_int","1"); }catch(e){} 579let refHost="direkt"; 580try{ if(document.referrer){ refHost=new URL(document.referrer).hostname.replace(/^www\./,""); if(refHost==="select-fotostudio.ch") refHost="eigene-website"; } }catch(e){ refHost="unbekannt"; } 581track("start",(matchMedia("(max-width:767px)").matches?"mobil":"desktop")+" | "+refHost+" | "+SF_LANG); 582try{ if(localStorage.getItem("sf_int")) track("internal"); }catch(e){} 583step();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.