PageSourceSearch

https://select-fotostudio.ch/wp-content/uploads/select-booking/booking.js?v=33

js select-fotostudio.ch collected 2026-10-06 05:17:38 UTC 41,492 bytes, 583 lines download raw bytes

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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
145const escA=s=>escH(s).replace(/"/g,"&quot;");
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.